Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ functions/.last_import_manifest.json
/ciiir_postboxes.json
/postman_james.svg

# Per-user claim audit exports (functions/audit_user_claims.js) — contain real
# UIDs + GPS movement traces; must never be committed
/audit_*
functions/audit_exports/

.claude/skills

# Desktop is not a build target (mobile-only app; runner scaffolding removed).
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="postbox_social" />
<!-- GDPR: Analytics collection stays OFF (including first_open) until
the user opts in and Analytics.setCollectionEnabled(true) runs.
Crashlytics/Performance deliberately have no such flags — they run
under legitimate interest with in-app Settings opt-outs. -->
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<!-- Home-screen widget. -->
<receiver
android:name=".PostboxWidgetProvider"
Expand Down
88 changes: 88 additions & 0 deletions assets/legal/privacy_policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# The Postbox Game – Privacy Policy

_Last updated: July 2026_

This Privacy Policy explains how The Postbox Game ("the App", "we", "us") collects, uses, and protects your personal data when you use our mobile application. We are committed to complying with the UK General Data Protection Regulation (UK GDPR) and the Data Protection Act 2018.

## 1. Who We Are

The Postbox Game is an independent mobile game. For data protection enquiries, contact us at richard@agilepixel.io.

## 2. Data We Collect

- **Account information:** When you register or sign in with Google, we receive your email address and display name. When you register with email/password, we store your email address and chosen display name.
- **Location data:** With your permission, we collect your precise GPS location to identify nearby postboxes and validate claims. The location you claimed from is stored with each claim for anti-cheat verification and is automatically removed after 90 days.
- **Gameplay data:** Postbox claims you make (postbox ID, timestamp, points awarded), your running scores, streaks, and leaderboard entries (display name and points only).
- **Device token:** A random per-install identifier sent with claims to detect multi-account abuse. It is not derived from your hardware and identifies only the app installation; it is removed from claims after 90 days.
- **Friends list:** User IDs of friends you choose to add within the App.
- **Problem reports:** If you report a postbox data problem, we store your report, its location, and any note or photos you attach (photos may include the time and place they were taken).
- **Anti-abuse records:** Automated checks may record anomaly flags and an internal trust score for your account. While this system runs in "shadow mode" it has no effect on your account or gameplay.
- **Crash and performance data:** Crash reports and performance traces collected by Firebase Crashlytics and Firebase Performance Monitoring to help us fix bugs. You can turn these off in Settings → Privacy.
- **Usage analytics (optional):** Anonymous usage events collected by Firebase Analytics, only if you opt in. Analytics is off until you choose to share it, and you can change your choice any time in Settings → Privacy.

## 3. How We Use Your Data

- To operate the App and provide gameplay features (claims, leaderboards, friends).
- To display your scores and display name on leaderboards and to friends.
- To keep the game fair by detecting location spoofing and multi-account abuse.
- To review postbox data problems you report and improve the postbox database.
- To improve the App by analysing crash reports, performance data, and (with your consent) aggregated usage statistics.
- To associate your gameplay history with your account so it persists across devices.

## 4. Legal Basis for Processing

We process your personal data on the following legal bases under UK GDPR:

- **Contract:** Processing your account and gameplay data is necessary to provide the service you signed up for.
- **Legitimate interests:** Crash reporting and performance monitoring to maintain a working app (you can object via the Settings → Privacy toggles), and anti-abuse checks to keep the game fair.
- **Consent:** Usage analytics (opt-in inside the App) and location access (grant or revoke at any time in your device settings).

## 5. Data Sharing

We do not sell your personal data. We share data only with the following service providers, who process it on our behalf:

- **Google Firebase** (Authentication, Firestore database, Cloud Functions, Crashlytics, Performance Monitoring, Analytics) – processed within Google's infrastructure. See Google's Privacy Policy at policies.google.com/privacy.
- **OpenStreetMap / Nominatim:** If you search for a destination in Route Mode, only the text you type is sent to the OpenStreetMap Nominatim search service – never your GPS position.

Your display name and score are visible to other users on leaderboards and to friends you add in the App. Corrections we submit back to OpenStreetMap from accepted postbox reports contain only postbox data, never anything about you.

## 6. Data Retention

- Account data is retained for as long as your account exists; deleting your account anonymises your claims and removes your personal data (see Your Rights).
- The GPS position, timing metadata, and device token stored with a claim are automatically removed after 90 days.
- Photos and notes attached to a postbox problem report are removed 30 days after the report is reviewed.
- Anti-abuse anomaly records are deleted after 180 days.
- Leaderboard entries are retained for the relevant period (daily / weekly / monthly) then overwritten.
- Crash and analytics data is retained per Firebase's default retention periods (90 days for Crashlytics; configurable for Analytics).

## 7. Your Rights

Under UK GDPR you have the right to:

- **Access and portability:** Use Settings → Privacy → "Download my data" in the App to receive a machine-readable copy of everything we store about you.
- **Rectification** of inaccurate data (e.g. update your display name in the App).
- **Erasure** ("right to be forgotten"): use Settings → "Delete account" in the App. Your personal data is deleted and your claims are anonymised immediately.
- **Restriction or objection** to certain processing, including turning off crash reporting, performance monitoring, and analytics in Settings → Privacy.
- **Withdraw consent** for analytics (Settings → Privacy) or location access (device settings) at any time.

To exercise any right, or if you have any questions, contact us at richard@agilepixel.io. You also have the right to lodge a complaint with the Information Commissioner's Office (ICO) at ico.org.uk.

## 8. Location Data

The App requests access to your device's precise location (while the app is in use) to find nearby postboxes. We do not track your location in the background. The position you claimed from is stored with that claim for anti-cheat verification and automatically removed after 90 days. Other players never see your location – nearby postboxes are shown only as fuzzy directions.

## 9. Children

The App is not directed at children under 13. We do not knowingly collect personal data from children under 13. If you believe a child has provided us with personal data, contact us and we will delete it.

## 10. Security

All data is transmitted over HTTPS and stored in Google Firebase's encrypted infrastructure. Access to Firestore is restricted by server-side security rules; users can only read and write data they are authorised to access.

## 11. Changes to This Policy

We may update this policy occasionally. Material changes will be notified within the App. The "last updated" date at the top of this page will always reflect the current version.

## 12. Contact

For any privacy-related questions or requests: richard@agilepixel.io.
85 changes: 83 additions & 2 deletions docs/plans/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,38 @@ exceptions in key flows to non-fatals.

---

## v1.4 — Trust & safety (Queued)
## v1.4 — Trust & safety (In flight)

**Theme**: harden the platform now that we can see what's happening (v1.3 observability).
**Ship gate**: account-deletion flow tested end-to-end in staging; impossible-travel detector in shadow mode logging flags but not blocking; Remote Config drives `claim_radius_meters` and `points_by_monarch` end-to-end (client + Cloud Functions) with the safety bounds enforced.
**Ship gate**: account-deletion flow tested end-to-end in staging; impossible-travel detector in shadow mode logging flags but not blocking; Remote Config drives `claim_radius_meters` and `points_by_monarch` end-to-end (client + Cloud Functions) with the safety bounds enforced; analytics consent gate live (fresh install fires no events before opt-in — verified in DebugView); `exportMyData` returns a complete bundle in staging; `dataRetentionSweep` executed once in staging with logged strip/delete counts.

> **Status** (branch `feat/v1.4-trust-safety`, `1.4.0+18`): all four workstreams
> implemented (Remote Config balance, GDPR deletion, shadow-mode abuse, and the
> GDPR compliance finish below); `flutter analyze` + Dart tests + functions
> lint/tests green. Remaining before ship: the manual staging passes (account
> deletion, consent DebugView check, export bundle, one forced retention-sweep
> run) and the Remote Config console setup (publish `claim_radius_meters` +
> `points_by_monarch` to the client AND server templates with values equal to
> the code defaults). Two roadmap divergences, both deliberate: (1) GDPR uses a
> **self-contained `onUserDeleted` function, not the Delete-User-Data extension**
> (the extension can only delete, not anonymise claims, which would break
> leaderboard integrity); (2) abuse **enforcement/voiding stays deferred
> (Phase 2)** — the ship gate requires shadow mode, so `SHADOW_MODE` remains
> `true`. Remote Config was scoped to the ship-gate pair only (no
> `quiz_required_streak`, other constants left hard-coded).

> **App Check enforcement moved to v1.7** (it's gated on iOS `AppleProvider`
> wiring, which lands in v1.7's iOS work). See the App Check item under v1.7.

### Remote Config for game balance and copy (was #107, moved from v1.3)

> ✅ Implemented (ship-gate pair only). Client: `RemoteConfigService.claimRadiusMeters`
> + `pointsForCipher`/`pointsByMonarch` (bounds-checked, defaults = constants), force-refetch
> on login. Backend: `functions/src/_config.ts` (`getServerTemplate` + 5-min cache, never
> throws) wired into `startScoring` + `_recomputeScores`. Fallback constants stay canonical
> (guarded by `test/cross_language_sync_test.dart`). Other tunables + `quiz_required_streak`
> intentionally out of scope.

The Remote Config *infrastructure* (typed `RemoteConfigService`, fetch lifecycle,
push updates, kill switches, `maintenance_mode`) already shipped in v1.3. What
remains is the game-balance tuning layer: centralise tunable values so balance,
Expand All @@ -184,6 +206,14 @@ and Cloud Functions read Remote Config.

### GDPR "Delete User Data" Firebase Extension (was #99)

> ✅ Implemented as a **self-contained `onUserDeleted` v1 Auth trigger** (europe-west2)
> + `functions/src/_accountDeletion.ts`, NOT the extension (the extension can't anonymise
> claims). Anonymises claims/reports (strips PII incl. the new `deviceIdHash`), hard-deletes
> user docs + `report_photos/` (and future-proof `claims-photos/` via
> `storagePrefixesForUser`), audit counter at `deletions/{day}`. Flutter re-auth + `User.delete()`
> in `lib/user_repository.dart`, Settings "Delete account" UI. Helpers unit-tested; full-flow
> e2e is the staging pass below.

Install the official extension. Configure paths:

- Firestore: `users/{UID}`, `fcmTokens/{UID}`, `recaps/{UID}/periods/{DOC}`.
Expand All @@ -201,6 +231,14 @@ Test in staging first: create a user with claims, photos, friends; delete; asser

### Abuse detection (impossible-travel and claim anomalies) (was #112)

> ✅ Implemented in **shadow mode** (`SHADOW_MODE = true`, stays that way per the ship gate).
> All four signals now fire: impossible-travel, out-of-window, coord-cluster, and the new
> **repeated-device** signal (`repeatedDeviceSignal` + `evaluateClaimSignals` in
> `_abuseSignals.ts`, wired in `onClaimCreated`; client sends a stable per-install
> `deviceIdHash` via `lib/services/device_id_service.dart`; composite index added). Flags →
> `moderationFlags`, trust decay → `trustScores`, admin review UI `admin_abuse_screen.dart`.
> Enforcement / claim-voiding remains the deferred **Phase 2** below.

`startScoring` already has a travel-speed anti-spoof check. Extend to:

- **Impossible travel**: two claims < N minutes apart separated by distance requiring > 200 km/h (start with 180 km/h / 50 m/s).
Expand All @@ -224,6 +262,49 @@ Admin UI: mirror `lib/admin/admin_reports_screen.dart` as `lib/admin/admin_abuse

Shadow mode first — log flags, no user-facing effect — for 2 weeks; tune thresholds from Firestore exports. False-positive guard: require **two** signals (e.g. impossible travel AND repeated device hash) before any action.

### GDPR compliance finish (consent, DSAR export, retention) (new)

> ✅ Implemented. Closes the four blockers a full GDPR review (2026-07-17) found
> after the deletion work shipped: no consent/transparency surface, no
> self-serve right-of-access, no retention limits, and a partial report
> anonymisation. Also fixed the dependency skew that broke Dart test loading
> (`firebase_core_platform_interface` ^8.0.0 + `fake_cloud_firestore` 4.2.0).

- **Analytics consent (opt-in)**: `firebase_analytics_collection_enabled=false`
manifest flag keeps the SDK silent from cold boot; consent recorded on the new
final intro step (new users) or the one-time `ConsentGate` prompt in
`lib/consent_screen.dart` (existing installs); Crashlytics/Performance stay on
under legitimate interest. Settings → Privacy has toggles for all three, all
persisted in `lib/consent_preferences.dart` (SharedPreferences, pre-login-safe)
and applied by `applyStoredTelemetryPreferences()` at every cold start.
- **Privacy policy**: single source `assets/legal/privacy_policy.md`, rendered
in-app by `lib/legal/privacy_policy_screen.dart` (no markdown dep) and mirrored
at `web/privacy-policy.html` (hosted at `/privacy-policy`, for the Play
listing); `test/privacy_policy_sync_test.dart` pins the two against each other.
Content now discloses the device token, anti-abuse records, retention
schedule, and in-app rights surfaces.
- **DSAR export (Art. 15/20)**: `exportMyData` callable returns a JSON bundle of
every per-user store (mirrors `_accountDeletion.ts`'s authoritative list, incl.
moderation flags and the Auth record); Settings → Privacy → "Download my data"
shares it as a file via `share_plus`. Claims capped at 20k newest with a
truncation flag (10 MB callable limit).
- **Retention (Art. 5(1)(e))**: `functions/src/dataRetention.ts` nightly sweep
(03:30 London) — strips `CLAIM_PII_FIELDS` off claims after 90 days
(watermark-paged, never rescans), purges report photos/notes 30 days after
review (Storage blobs + doc fields), deletes `moderationFlags` after 180 days.
Sweep chosen over Firestore TTL deliberately (backfill needed either way,
compliance-evidence logs, unit-testable).
- **Erasure gap closed**: `anonymiseReportsForUser` now strips
`REPORT_PII_FIELDS` (note + photos incl. EXIF GPS and uid-bearing storage
paths); report `lat`/`lng` kept for pending-review integrity (documented).
- **Hygiene**: `audit_*` exports gitignored and evicted from the repo root;
`audit_user_claims.js` defaults to `./audit_exports`; `startScoring.ts`'s
deviceIdHash comment corrected (random install token, not a hardware hash).

Risk: the first sweep night backfills history in bounded pages (~6k docs/night)
— watch the `pagesRemaining` log until the backlog drains. iOS
(`FirebaseAnalyticsCollectionEnabled`) is a v1.7 checklist item.

---

## v1.5 — Engagement & avatars (Deferred)
Expand Down
8 changes: 8 additions & 0 deletions firestore.indexes.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
{ "fieldPath": "timestamp", "order": "DESCENDING" }
]
},
{
"collectionGroup": "claims",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "deviceIdHash", "order": "ASCENDING" },
{ "fieldPath": "timestamp", "order": "DESCENDING" }
]
},
{
"collectionGroup": "postbox",
"queryScope": "COLLECTION",
Expand Down
9 changes: 6 additions & 3 deletions functions/audit_user_claims.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
*
* Usage (run from the functions/ directory so node_modules resolve):
*
* node audit_user_claims.js --uid <userid> --project the-postbox-game [--out-dir ./audit]
* node audit_user_claims.js --uid <userid> --project the-postbox-game [--out-dir ./audit_exports]
*
* Output defaults to ./audit_exports (gitignored) — reports contain real UIDs
* and GPS movement traces, so they must never land somewhere committable.
*
* Authentication:
* Set GOOGLE_APPLICATION_CREDENTIALS to a service account JSON path, OR run
Expand Down Expand Up @@ -47,7 +50,7 @@ function classifySpeed(mPerMin) {
}

function parseArgs(argv) {
const opts = { uid: null, projectId: null, outDir: '.', help: false };
const opts = { uid: null, projectId: null, outDir: './audit_exports', help: false };
const args = argv.slice(2);
for (let i = 0; i < args.length; i++) {
const a = args[i];
Expand All @@ -61,7 +64,7 @@ function parseArgs(argv) {
}

function usage() {
console.log(`Usage: node audit_user_claims.js --uid <userid> --project <projectId> [--out-dir <dir>]`);
console.log(`Usage: node audit_user_claims.js --uid <userid> --project <projectId> [--out-dir <dir>] (default out-dir: ./audit_exports)`);
}

function median(arr) {
Expand Down
Loading
Loading