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
39 changes: 21 additions & 18 deletions docs/plans/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ lands). Their original bodies are preserved below for diff archaeology.
|---|---|---|
| **v1.2** | Intro polish | Done |
| **v1.3** | Platform foundations (EU region + observability) | Done |
| **v1.4** | Trust & safety (App Check, GDPR, anti-cheat) | Queued |
| **v1.4** | Trust & safety (GDPR, anti-cheat) | In flight |
| **v1.5** | Engagement & avatars (social loops + Postie avatar) | Deferred |
| **v1.6** | Collection & content | Deferred |
| **v1.7** | Reach (iOS, localisation) | Deferred |
| **v1.7** | Reach (iOS, localisation, App Check) | Deferred |
| **v2.0** | Growth (Analytics + experimentation) | Deferred |
| **Backlog** | Speculative big-rocks | Speculative |

Expand Down Expand Up @@ -162,7 +162,10 @@ exceptions in key flows to non-fatals.
## v1.4 — Trust & safety (Queued)

**Theme**: harden the platform now that we can see what's happening (v1.3 observability).
**Ship gate**: App Check denial rate < 1 % in monitor mode; 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.

> **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)

Expand All @@ -179,18 +182,6 @@ and Cloud Functions read Remote Config.
- Rollout: ship client first with defaults equal to current code, then backend, then tune via console.
- Risk: stale client cache hides a bad value — force refetch on login. Cost drift if `points_by_monarch` mis-set — sanity-check rejecting values outside `[1, 50]`.

### App Check enforcement audit and hardening (was #96)

App Check is configured client-side for Android release (`AndroidPlayIntegrityProvider`). Audit and **enforce** server-side.

1. `AndroidDebugProvider` for dev — token committed to developer machines, not the repo.
2. iOS: activate `AppleProvider` (`DeviceCheck` / `AppAttest`) once iOS builds are wired up.
3. Firebase Console: enforce on Cloud Functions, Firestore, Storage, RTDB.
4. Functions code: every callable rejects with `failed-precondition` if `context.app` absent (defence-in-depth on top of platform enforcement). Wrap in `functions/src/_appCheck.ts`.
5. Cloud Monitoring alert on App Check denial rate spikes.

Roll out in **monitor** mode for 7 days, then **enforce** if denial rate < 1 %. Keep a break-glass env var to temporarily disable explicit `context.app` checks during provider outages.

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

Install the official extension. Configure paths:
Expand Down Expand Up @@ -418,21 +409,33 @@ No backend changes — `nearbyPostboxes` already returns enough metadata for the

**Theme**: open the door to users outside the current Android-phone + Wear OS + Android Auto + (eventual) XR funnel.

**Ship gate**: signed iOS build available on TestFlight; at least one non-English locale ships and is selectable in Settings; no English-language strings remain in user-facing widgets per `flutter_lints` rule.
**Ship gate**: signed iOS build available on TestFlight; at least one non-English locale ships and is selectable in Settings; no English-language strings remain in user-facing widgets per `flutter_lints` rule; App Check enforced server-side with denial rate < 1 % in monitor mode.

### iOS support (new)

CLAUDE.md notes `firebase_options.dart` has iOS config but no `Podfile` exists. Real work:

- `cd ios && pod install` (generates the Podfile).
- Verify `Info.plist` permissions: `NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, `NSCameraUsageDescription`, `NSPhotoLibraryUsageDescription` (last two already present per CLAUDE.md).
- Wire `AppleProvider` (`DeviceCheck` / `AppAttest`) for App Check (already noted as a blocker in v1.4's App Check item).
- Wire `AppleProvider` (`DeviceCheck` / `AppAttest`) for App Check (see the App Check enforcement item below — the two land together in v1.7).
- App Store Connect listing: name, screenshots, age rating, privacy nutrition labels (matching what the GDPR plan in v1.4 implements).
- Mac Catalyst evaluation — `firebase_options.dart` already has a macOS config; trivial scope or skip.

Smoke-test gates: login (email + Google), nearby scan, claim quiz, report submission with photo, route mode end-to-end. Wear/Android Auto are not relevant on iOS.

Backend risk: callable region pinning (v1.3) and App Check enforcement (v1.4) both touch iOS, so v1.7 ideally lands *after* both.
Backend risk: callable region pinning (v1.3, done) and App Check enforcement (a sibling v1.7 item below) both touch iOS — wire `AppleProvider` as part of the App Check work.

### App Check enforcement audit and hardening (was #96, moved from v1.4)

App Check is configured client-side for Android release (`AndroidPlayIntegrityProvider`). Audit and **enforce** server-side. Moved here from v1.4 because iOS `AppleProvider` wiring is part of v1.7's iOS work, so the two are best done together.

1. `AndroidDebugProvider` for dev — token committed to developer machines, not the repo.
2. iOS: activate `AppleProvider` (`DeviceCheck` / `AppAttest`) once iOS builds are wired up (the iOS support item above).
3. Firebase Console: enforce on Cloud Functions, Firestore, Storage, RTDB.
4. Functions code: every callable rejects with `failed-precondition` if `context.app` absent (defence-in-depth on top of platform enforcement). Wrap in `functions/src/_appCheck.ts`.
5. Cloud Monitoring alert on App Check denial rate spikes.

Roll out in **monitor** mode for 7 days, then **enforce** if denial rate < 1 %. Keep a break-glass env var to temporarily disable explicit `context.app` checks during provider outages.

### Localisation infrastructure (new)

Expand Down
18 changes: 18 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,23 @@ service cloud.firestore {
allow write: if false;
}

// ── moderationFlags collection ───────────────────────────────────────────
// Shadow-mode abuse-detection flags, written exclusively by the
// onClaimCreated Cloud Function (Admin SDK) and updated by reviewFlag.
// Readable only by users with the `admin` custom claim (the in-app abuse
// review interface). No client writes.
match /moderationFlags/{flagId} {
allow read: if request.auth != null && request.auth.token.admin == true;
allow write: if false;
}

// ── trustScores collection ───────────────────────────────────────────────
// Per-user server-only trust score decayed by the abuse detector. Never
// client-readable (kept out of the world-readable users/{uid} doc so it
// can't leak) and never client-writable — Cloud Functions (Admin SDK) only.
match /trustScores/{uid} {
allow read, write: if false;
}

}
}
113 changes: 113 additions & 0 deletions functions/src/_abuseSignals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Pure abuse-detection helpers for the shadow-mode claim anomaly detector.
//
// These functions intentionally hold no Firestore/IO so they're unit-testable
// without an emulator (same approach as buildOsmChange / nextQuotaState in
// reports.ts). The onClaimCreated trigger (abuse.ts) wires them to claim data.
//
// SHADOW MODE: signals are recorded, never used to block or void a claim.
//
// Thresholds are constants for now; a follow-up makes them Remote-Config-driven
// alongside the v1.4 "Remote Config for game balance" item.

import { MAX_METRES_PER_MIN } from "./_travelSpeed";

/** Implied-travel speed (m/min) at or above which a claim is *flagged* in shadow
* mode. Deliberately BELOW the live hard reject (MAX_METRES_PER_MIN = 1900 in
* _travelSpeed.ts): claims above the hard limit never get created, so a flag
* threshold ≥ 1900 would be dead code. 1500 m/min (~90 km/h) surfaces the
* fast-but-not-rejected band for offline tuning. */
export const SHADOW_TRAVEL_FLAG_M_PER_MIN = 1500;

/** Max tolerated gap between the server's claim timestamp and the client-supplied
* timestamp before the claim is flagged as out-of-window (clock skew / replay). */
export const OUT_OF_WINDOW_MS = 120_000; // 2 minutes

/** Decimal places to which a claim's coordinate is rounded for clustering. */
export const CLUSTER_DECIMALS = 6;

/** Number of prior claims at the identical rounded coordinate that trips the
* clustering signal (the current claim plus this many repeats). */
export const CLUSTER_MIN_REPEATS = 3;

/** Trust score assigned to a user with no prior flags. */
export const DEFAULT_TRUST_SCORE = 100;

// Sanity: the shadow flag must stay below the hard reject or it can never fire.
if (SHADOW_TRAVEL_FLAG_M_PER_MIN >= MAX_METRES_PER_MIN) {
throw new Error("SHADOW_TRAVEL_FLAG_M_PER_MIN must be below MAX_METRES_PER_MIN");
}

export type Severity = "low" | "med" | "high";

export interface SignalResult {
/** True when this signal's threshold was breached. */
flagged: boolean;
/** The measured quantity (speed / delta-ms / repeat-count) for diagnostics. */
value: number;
}

export interface NamedSignal {
reason: string;
flagged: boolean;
}

/** Stable string key grouping claims that share a coordinate to `decimals` dp.
* Used both by startScoring (stored on the claim) and the trigger (equality
* query), so it must be deterministic. */
export function coordKey(lat: number, lng: number, decimals = CLUSTER_DECIMALS): string {
return `${lat.toFixed(decimals)},${lng.toFixed(decimals)}`;
}

/** Impossible-travel signal. `travelSpeedMPerMin` is the value startScoring
* already computed against the user's previous claim; undefined means there was
* no previous claim to compare against (first claim), so nothing is flagged. */
export function impossibleTravelSignal(travelSpeedMPerMin?: number): SignalResult {
if (travelSpeedMPerMin === undefined || !Number.isFinite(travelSpeedMPerMin)) {
return { flagged: false, value: 0 };
}
return {
flagged: travelSpeedMPerMin >= SHADOW_TRAVEL_FLAG_M_PER_MIN,
value: travelSpeedMPerMin,
};
}

/** Out-of-window signal: the client's reported timestamp disagrees with the
* server's by more than OUT_OF_WINDOW_MS. Absent client timestamp (legacy/web
* clients) is not flagged. */
export function outOfWindowSignal(serverTsMs: number, clientTsMs?: number): SignalResult {
if (clientTsMs === undefined || !Number.isFinite(clientTsMs)) {
return { flagged: false, value: 0 };
}
const delta = Math.abs(serverTsMs - clientTsMs);
return { flagged: delta > OUT_OF_WINDOW_MS, value: delta };
}

/** Coordinate-clustering signal: the user has claimed from the identical rounded
* coordinate at least CLUSTER_MIN_REPEATS times. `repeatCount` is supplied by
* the caller from a Firestore count query on `coordKey6`. */
export function coordClusterSignal(repeatCount: number): SignalResult {
return { flagged: repeatCount >= CLUSTER_MIN_REPEATS, value: repeatCount };
}

function severityForCount(n: number): Severity {
if (n >= 3) return "high";
if (n === 2) return "med";
return "low";
}

/** Reduce the fired signals to the reason list + an overall severity that rises
* as signals co-occur (a single signal is "low"; the future enforcement phase
* requires ≥ 2 before acting). */
export function summariseFlags(signals: NamedSignal[]): { reasons: string[]; severity: Severity } {
const reasons = signals.filter((s) => s.flagged).map((s) => s.reason);
return { reasons, severity: severityForCount(reasons.length) };
}

const DECAY_BY_SEVERITY: Record<Severity, number> = { low: 5, med: 15, high: 30 };

/** Apply a trust-score penalty for a flag of the given severity, clamped to
* [0, DEFAULT_TRUST_SCORE]. Time-based recovery is a deferred follow-up. */
export function applyTrustDecay(current: number, severity: Severity): number {
const next = current - DECAY_BY_SEVERITY[severity];
return Math.max(0, Math.min(DEFAULT_TRUST_SCORE, next));
}
168 changes: 168 additions & 0 deletions functions/src/abuse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import "./adminInit";
import * as admin from "firebase-admin";
import * as functions from "firebase-functions";
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { FIRESTORE_TRIGGER_REGION } from "./_region";
import {
impossibleTravelSignal,
outOfWindowSignal,
coordClusterSignal,
summariseFlags,
applyTrustDecay,
DEFAULT_TRUST_SCORE,
type NamedSignal,
} from "./_abuseSignals";

// SHADOW MODE: the detector only records flags + decays an internal trust score.
// It NEVER blocks, voids, or otherwise affects a claim. Enforcement (step-up
// verification, voiding) is a deferred follow-up gated behind this flag.
const SHADOW_MODE = true;

const database = admin.firestore();

/**
* onClaimCreated — shadow-mode claim anomaly detector.
*
* 2nd-gen Firestore trigger in europe-west4 (eur3's Eventarc region; eur3 has no
* Gen1 Firestore triggers — see FIRESTORE_TRIGGER_REGION). Runs out-of-band so
* it adds zero latency/risk to the claim path. Reads the inputs startScoring
* persisted on the claim (travelSpeed, clientTsMs, coordKey6) plus one count
* query, evaluates the pure signals, and on any breach writes a
* `moderationFlags/{id}` doc and decays `trustScores/{uid}`.
*
* Deliberately never throws: a detector fault must not affect anything.
*/
export const onClaimCreated = onDocumentCreated(
{ document: "claims/{claimId}", region: FIRESTORE_TRIGGER_REGION },
async (event) => {
try {
const snap = event.data;
if (!snap) return;
const claim = snap.data();
const claimId = event.params.claimId;
const uid = claim.userid as string | undefined;
if (!uid) return;

const serverTsMs =
(claim.timestamp as admin.firestore.Timestamp | undefined)?.toMillis?.() ?? Date.now();
const clientTsMs = typeof claim.clientTsMs === "number" ? (claim.clientTsMs as number) : undefined;
const travelSpeed = typeof claim.travelSpeed === "number" ? (claim.travelSpeed as number) : undefined;
const coordKey6 = typeof claim.coordKey6 === "string" ? (claim.coordKey6 as string) : undefined;

// Coordinate clustering: how many of this user's claims sit at the same
// rounded coordinate (this one included). Two equality filters are served
// by single-field indexes (zigzag merge) — no composite index needed.
let repeatCount = 0;
if (coordKey6) {
const agg = await database
.collection("claims")
.where("userid", "==", uid)
.where("coordKey6", "==", coordKey6)
.count()
.get();
repeatCount = agg.data().count;
}

const travel = impossibleTravelSignal(travelSpeed);
const window = outOfWindowSignal(serverTsMs, clientTsMs);
const cluster = coordClusterSignal(repeatCount);

const named: NamedSignal[] = [
{ reason: "impossible_travel", flagged: travel.flagged },
{ reason: "out_of_window", flagged: window.flagged },
{ reason: "coord_cluster", flagged: cluster.flagged },
];
const { reasons, severity } = summariseFlags(named);
if (reasons.length === 0) return; // nothing anomalous

// Internal visibility via Cloud Logging — values only, never exact coords.
console.warn(
`[abuse:shadow] uid=${uid} claim=${claimId} reasons=${reasons.join(",")} severity=${severity} ` +
`speedMPerMin=${travel.value.toFixed(1)} clockSkewMs=${window.value} coordRepeat=${cluster.value}`,
);

// Surface the claim coordinate on the (admin-only) flag doc so the review
// screen can show a map link — admins can't read other users' claim docs
// directly (claims are owner-read-only).
const flagLat = typeof claim.userLat === "number" ? (claim.userLat as number) : undefined;
const flagLng = typeof claim.userLng === "number" ? (claim.userLng as number) : undefined;

await database.collection("moderationFlags").add({
uid,
claimId,
reasons,
severity,
shadow: SHADOW_MODE,
signals: {
travelSpeedMPerMin: travel.value,
clockSkewMs: window.value,
coordRepeatCount: cluster.value,
},
...(flagLat !== undefined ? { lat: flagLat } : {}),
...(flagLng !== undefined ? { lng: flagLng } : {}),
createdAt: admin.firestore.FieldValue.serverTimestamp(),
reviewed: false,
});

// Decay the server-only trust score. Best-effort: a failure here must not
// surface anywhere, so it's caught by the outer try/catch.
const trustRef = database.collection("trustScores").doc(uid);
await database.runTransaction(async (tx) => {
const tSnap = await tx.get(trustRef);
const current = (tSnap.data()?.score as number | undefined) ?? DEFAULT_TRUST_SCORE;
tx.set(
trustRef,
{ score: applyTrustDecay(current, severity), updatedAt: admin.firestore.FieldValue.serverTimestamp() },
{ merge: true },
);
});
} catch (e) {
console.error("onClaimCreated detector failed (non-fatal):", e);
}
},
);

interface ReviewFlagData {
flagId?: unknown;
action?: unknown;
reviewNote?: unknown;
}

/**
* reviewFlag — admin marks a moderation flag as reviewed.
*
* Admin-gated exactly like reviewReport. Shadow phase only stamps the flag; it
* does NOT void claims (that's a deferred enforcement follow-up reusing
* recomputeUserAggregates in _recomputeScores.ts).
*/
export const reviewFlag = functions.https.onCall(async (request) => {
if (request.auth?.token?.admin !== true) {
throw new functions.https.HttpsError("permission-denied", "Admin access required");
}
const adminUid = request.auth.uid;
const data = (request.data as ReviewFlagData) ?? {};

const flagId = data.flagId;
if (typeof flagId !== "string" || flagId.length === 0 || flagId.includes("/")) {
throw new functions.https.HttpsError("invalid-argument", "flagId is required");
}
const action = typeof data.action === "string" ? data.action.slice(0, 200) : undefined;
const reviewNote = typeof data.reviewNote === "string" ? data.reviewNote.slice(0, 500) : undefined;

const ref = database.collection("moderationFlags").doc(flagId);
const snap = await ref.get();
if (!snap.exists) {
throw new functions.https.HttpsError("not-found", "Flag not found");
}

const update: Record<string, unknown> = {
reviewed: true,
reviewedBy: adminUid,
reviewedAt: admin.firestore.FieldValue.serverTimestamp(),
};
if (action !== undefined) update.action = action;
if (reviewNote !== undefined) update.reviewNote = reviewNote;
await ref.set(update, { merge: true });

return { ok: true };
});
Loading
Loading