From b32f9ca9dc5e831a3cfbb28a253e698fec5c4846 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Tue, 23 Jun 2026 23:49:10 +0100 Subject: [PATCH] feat(abuse): shadow-mode claim anomaly detector (v1.4) Adds the v1.4 Trust & safety "abuse detection" item in shadow mode: the detector observes every claim, records flags for internal review, and never blocks or voids a claim. The existing 1900 m/min hard reject in startScoring is left intact. Backend: - _abuseSignals.ts: pure, unit-tested helpers (impossible-travel, out-of-window, coordinate-cluster signals; severity rollup; trust-score decay; coordKey). Shadow flag threshold (1500 m/min) sits below the hard reject so it can fire. - startScoring: accepts/validates optional clientTsMs; enforceTravelSpeedLimit returns the computed speed; each claim persists clientTsMs, travelSpeed and coordKey6 for the detector. - abuse.ts: onClaimCreated 2nd-gen trigger (europe-west4, eur3 Eventarc region) writes moderationFlags + decays server-only trustScores; never throws. reviewFlag admin callable stamps a flag reviewed (no voiding yet). Client: - Send clientTsMs with claims (claim_quiz_sheet, wear_claim_page). - admin_abuse_screen.dart: Open/Reviewed queue mirroring the reports screen, wired into the Home admin menu. Rules: moderationFlags (admin-read, server-write) + trustScores (server-only). Trust score is kept out of the world-readable users/{uid} doc so it can't leak; flat moderationFlags/trustScores collections realise the roadmap's intent. Roadmap: App Check enforcement moved v1.4 -> v1.7 (gated on iOS AppleProvider). Tests: 400 functions tests + 413 flutter tests pass; flutter analyze clean; firestore.rules validated. Deferred follow-ups: device-hash signal + two-signal guard; enforcement/voiding; move thresholds to Remote Config. Co-Authored-By: Claude Opus 4.8 --- docs/plans/ROADMAP.md | 39 ++-- firestore.rules | 18 ++ functions/src/_abuseSignals.ts | 113 ++++++++++++ functions/src/abuse.ts | 168 +++++++++++++++++ functions/src/index.ts | 3 + functions/src/startScoring.ts | 41 +++-- functions/src/test/test.index.ts | 153 ++++++++++++++++ lib/admin/admin_abuse_screen.dart | 287 ++++++++++++++++++++++++++++++ lib/home.dart | 16 ++ lib/wear/wear_claim_page.dart | 2 + lib/widgets/claim_quiz_sheet.dart | 3 + test/claim_quiz_sheet_test.dart | 36 ++++ 12 files changed, 851 insertions(+), 28 deletions(-) create mode 100644 functions/src/_abuseSignals.ts create mode 100644 functions/src/abuse.ts create mode 100644 lib/admin/admin_abuse_screen.dart diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index c857eca4..d098a2f3 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -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 | @@ -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) @@ -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: @@ -418,7 +409,7 @@ 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) @@ -426,13 +417,25 @@ CLAUDE.md notes `firebase_options.dart` has iOS config but no `Podfile` exists. - `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) diff --git a/firestore.rules b/firestore.rules index 3c5abacf..acea3013 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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; + } + } } diff --git a/functions/src/_abuseSignals.ts b/functions/src/_abuseSignals.ts new file mode 100644 index 00000000..6150aafc --- /dev/null +++ b/functions/src/_abuseSignals.ts @@ -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 = { 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)); +} diff --git a/functions/src/abuse.ts b/functions/src/abuse.ts new file mode 100644 index 00000000..2e11bbd3 --- /dev/null +++ b/functions/src/abuse.ts @@ -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 = { + 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 }; +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index a274de11..eae1523b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,6 +9,7 @@ import { registerFcmToken, onFriendAdded } from "./_notifications"; import { userClaimHistory } from "./userClaimHistory"; import { submitReport, reviewReport } from "./reports"; import { routePostboxes } from "./routePostboxes"; +import { onClaimCreated, reviewFlag } from "./abuse"; export { nearbyPostboxes, @@ -22,4 +23,6 @@ export { submitReport, reviewReport, routePostboxes, + onClaimCreated, + reviewFlag, }; diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index e0d6ae15..f6d75c97 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -8,6 +8,7 @@ import { updateUserLeaderboards, mergeLifetimeEntries, LifetimeLeaderboardEntry, import { computeNewStreak } from "./_streakUtils"; import { notifyFriendsFirstClaim, notifyFriendOvertake } from "./_notifications"; import { checkTravelSpeed } from "./_travelSpeed"; +import { coordKey } from "./_abuseSignals"; const database = admin.firestore(); @@ -18,6 +19,9 @@ const CLAIM_RADIUS_METERS = 30; interface StartScoringCallData { lat?: number; lng?: number; + /** Client wall-clock at claim time (ms since epoch). Optional — legacy/web + * clients omit it. Stored for the shadow-mode out-of-window anomaly signal. */ + clientTsMs?: number; } export const startScoring = functions.https.onCall(async (request) => { @@ -26,7 +30,7 @@ export const startScoring = functions.https.onCall(async (request) => { throw new functions.https.HttpsError("unauthenticated", "Must be signed in to claim a postbox"); } - const { lat, lng } = (request.data as StartScoringCallData) ?? {}; + const { lat, lng, clientTsMs } = (request.data as StartScoringCallData) ?? {}; if (lat === undefined || lat === null || lng === undefined || lng === null) { throw new functions.https.HttpsError("invalid-argument", "lat and lng are required"); } @@ -36,13 +40,17 @@ export const startScoring = functions.https.onCall(async (request) => { if (!Number.isFinite(lng) || lng < -180 || lng > 180) { throw new functions.https.HttpsError("invalid-argument", "lng must be a finite number between -180 and 180"); } + if (clientTsMs !== undefined && (typeof clientTsMs !== "number" || !Number.isFinite(clientTsMs))) { + throw new functions.https.HttpsError("invalid-argument", "clientTsMs must be a finite number when provided"); + } // Anti-spoof: reject claims whose implied travel speed from the user's // previous claim exceeds a liberal physical limit. Fail-open when the // previous claim's location can't be resolved (e.g. legacy claims where // neither userLat/userLng nor the referenced postbox geopoint are - // available). - await enforceTravelSpeedLimit(userid, lat, lng); + // available). Returns the computed speed (m/min) so it can be persisted on + // the new claim for the shadow-mode anomaly detector; undefined when skipped. + const travelSpeed = await enforceTravelSpeedLimit(userid, lat, lng); // Hoist date computation so all return paths include dailyDate for consistency. const todayLondon = getTodayLondon(); @@ -108,8 +116,16 @@ export const startScoring = functions.https.onCall(async (request) => { // check uses the user's own position, not the postbox coordinates. userLat: lat, userLng: lng, + // Rounded coordinate key for the shadow-mode clustering signal — lets + // the onClaimCreated trigger count same-spot claims with an equality + // query instead of scanning raw doubles. + coordKey6: coordKey(lat, lng), }; if (postbox.monarch !== undefined) claimData.monarch = postbox.monarch; + // Shadow-mode anomaly inputs (optional — omitted when unavailable so we + // never write `undefined` to Firestore). + if (clientTsMs !== undefined) claimData.clientTsMs = clientTsMs; + if (travelSpeed !== undefined) claimData.travelSpeed = travelSpeed; tx.set(claimRef, claimData); // Keep dailyClaim on the postbox doc for display purposes (shows // "someone found this today" in future UI); does not gate claiming. @@ -411,17 +427,21 @@ export const startScoring = functions.https.onCall(async (request) => { * the implied travel speed exceeds MAX_METRES_PER_MIN. Location resolution * order: (1) userLat/userLng stored on the claim doc (written going * forward); (2) geopoint of the referenced postbox (legacy fallback). If - * neither yields a coordinate pair, the check is skipped. */ -async function enforceTravelSpeedLimit(userid: string, currentLat: number, currentLng: number): Promise { + * neither yields a coordinate pair, the check is skipped. + * + * Returns the implied speed (m/min) for the caller to persist on the new claim + * (shadow-mode anomaly detector), or `undefined` when the check was skipped + * (no previous claim / no resolvable location). Throws on hard-limit breach. */ +async function enforceTravelSpeedLimit(userid: string, currentLat: number, currentLng: number): Promise { const lastSnap = await database.collection("claims") .where("userid", "==", userid) .orderBy("timestamp", "desc") .limit(1) .get(); - if (lastSnap.empty) return; + if (lastSnap.empty) return undefined; const last = lastSnap.docs[0].data(); const tsMs = (last.timestamp as admin.firestore.Timestamp | undefined)?.toMillis?.(); - if (!tsMs) return; + if (!tsMs) return undefined; let lastLat = typeof last.userLat === "number" ? (last.userLat as number) : undefined; let lastLng = typeof last.userLng === "number" ? (last.userLng as number) : undefined; @@ -431,11 +451,11 @@ async function enforceTravelSpeedLimit(userid: string, currentLat: number, curre const postboxKey = typeof postboxPath === "string" ? postboxPath.replace(/^\/postbox\//, "") : undefined; - if (!postboxKey) return; + if (!postboxKey) return undefined; const postboxSnap = await database.collection("postbox").doc(postboxKey).get(); - if (!postboxSnap.exists) return; + if (!postboxSnap.exists) return undefined; const geo = getLatLng((postboxSnap.data() ?? {}).geopoint); - if (!geo) return; + if (!geo) return undefined; lastLat = geo.lat; lastLng = geo.lng; } @@ -455,4 +475,5 @@ async function enforceTravelSpeedLimit(userid: string, currentLat: number, curre "You're travelling too fast — slow down before your next postbox claim." ); } + return check.speedMPerMin; } diff --git a/functions/src/test/test.index.ts b/functions/src/test/test.index.ts index 8c99d776..920fb0a6 100644 --- a/functions/src/test/test.index.ts +++ b/functions/src/test/test.index.ts @@ -894,6 +894,7 @@ describe("region pinning", () => { const DEFAULT_REGION = "europe-west2"; const REGION_OVERRIDES: Record = { onFriendAdded: "europe-west4", + onClaimCreated: "europe-west4", }; type WithEndpoint = { __endpoint?: { region?: string[]; platform?: string } }; @@ -914,6 +915,12 @@ describe("region pinning", () => { .__endpoint; assert.strictEqual(endpoint?.platform, "gcfv2"); }); + + it("onClaimCreated is a 2nd-gen trigger (eur3 has no Gen1 Firestore triggers)", () => { + const endpoint = (myFunctions as Record)["onClaimCreated"] + .__endpoint; + assert.strictEqual(endpoint?.platform, "gcfv2"); + }); }); describe("Cloud Functions", function (this: Mocha.Suite) { @@ -1095,6 +1102,18 @@ describe("Cloud Functions", function (this: Mocha.Suite) { } }); + it("should throw invalid-argument when clientTsMs is not finite", async function (this: Mocha.Context) { + this.timeout(5000); + const req = { data: { lat: 51.45, lng: -0.95, clientTsMs: "nope" }, auth: { uid: "test-uid" } }; + try { + await wrappedStartScoring(req); + assert.fail("Expected invalid-argument error"); + } catch (e: unknown) { + const err = e as { code?: string }; + assert.strictEqual(err.code, "invalid-argument"); + } + }); + it("should throw invalid-argument when lat is out of range", async function (this: Mocha.Context) { this.timeout(5000); const req = { data: { lat: 999, lng: -0.95 }, auth: { uid: "test-uid" } }; @@ -2917,6 +2936,41 @@ describe("submitReport / reviewReport (onCall) — auth & validation", function }); }); +describe("reviewFlag (onCall) — auth & validation", function (this: Mocha.Suite) { + this.timeout(10000); + const wrappedReviewFlag = testEnv.wrap(myFunctions.reviewFlag) as (data: unknown) => Promise; + + it("throws permission-denied for a non-admin caller", async () => { + try { + await wrappedReviewFlag({ data: { flagId: "f1" }, auth: { uid: "u1" } }); + assert.fail("expected permission-denied"); + } catch (e) { + assert.strictEqual((e as { code?: string }).code, "permission-denied"); + } + }); + + it("throws permission-denied with auth but admin claim false", async () => { + try { + await wrappedReviewFlag({ data: { flagId: "f1" }, auth: { uid: "u1", token: { admin: false } } }); + assert.fail("expected permission-denied"); + } catch (e) { + assert.strictEqual((e as { code?: string }).code, "permission-denied"); + } + }); + + it("throws invalid-argument for a missing flagId (admin caller)", async () => { + try { + await wrappedReviewFlag({ data: {}, auth: { uid: "admin1", token: { admin: true } } }); + assert.fail("expected invalid-argument"); + } catch (e) { + // invalid-argument is reached only for admins; non-emulator runs may also + // surface permission-denied on the subsequent Firestore read, but the + // arg check runs first so invalid-argument is expected here. + assert.strictEqual((e as { code?: string }).code, "invalid-argument"); + } + }); +}); + // ── filterToCorridor ────────────────────────────────────────────────────────── // // Fixture: start = (0, 0), end = (0, 0.01) (roughly ~1.1 km due-east at equator) @@ -3313,3 +3367,102 @@ describe("finaliseRoute", () => { assert.strictEqual(r.totalSeconds, state.timeUsed + r.closingSeconds); }); }); + +// ── Abuse-detection signals (pure, shadow mode) ─────────────────────────────── +import { + coordKey, + impossibleTravelSignal, + outOfWindowSignal, + coordClusterSignal, + summariseFlags, + applyTrustDecay, + SHADOW_TRAVEL_FLAG_M_PER_MIN, + OUT_OF_WINDOW_MS, + CLUSTER_MIN_REPEATS, + DEFAULT_TRUST_SCORE, +} from "../_abuseSignals"; + +describe("abuse signals: coordKey", () => { + it("rounds to 6 dp and joins lat,lng", () => { + assert.strictEqual(coordKey(51.4532109, -0.9543201), "51.453211,-0.954320"); + }); + it("two positions that agree to 6 dp produce the same key", () => { + assert.strictEqual(coordKey(51.4532104, -0.9543204), coordKey(51.4532101, -0.9543198)); + }); + it("positions differing at 6 dp produce different keys", () => { + assert.notStrictEqual(coordKey(51.453210, -0.954320), coordKey(51.453219, -0.954320)); + }); +}); + +describe("abuse signals: impossibleTravelSignal", () => { + it("flags a speed at or above the shadow threshold", () => { + const r = impossibleTravelSignal(SHADOW_TRAVEL_FLAG_M_PER_MIN); + assert.strictEqual(r.flagged, true); + assert.strictEqual(r.value, SHADOW_TRAVEL_FLAG_M_PER_MIN); + }); + it("does not flag a speed below the threshold", () => { + assert.strictEqual(impossibleTravelSignal(SHADOW_TRAVEL_FLAG_M_PER_MIN - 1).flagged, false); + }); + it("does not flag when no travel speed is available (first claim)", () => { + assert.strictEqual(impossibleTravelSignal(undefined).flagged, false); + }); +}); + +describe("abuse signals: outOfWindowSignal", () => { + const server = 1_000_000_000_000; + it("flags when client clock is off by more than the window", () => { + const r = outOfWindowSignal(server, server - (OUT_OF_WINDOW_MS + 1)); + assert.strictEqual(r.flagged, true); + assert.strictEqual(r.value, OUT_OF_WINDOW_MS + 1); + }); + it("does not flag when within the window", () => { + assert.strictEqual(outOfWindowSignal(server, server + OUT_OF_WINDOW_MS).flagged, false); + }); + it("does not flag when client timestamp is absent", () => { + assert.strictEqual(outOfWindowSignal(server, undefined).flagged, false); + }); +}); + +describe("abuse signals: coordClusterSignal", () => { + it("flags once repeats reach the minimum", () => { + assert.strictEqual(coordClusterSignal(CLUSTER_MIN_REPEATS).flagged, true); + }); + it("does not flag below the minimum", () => { + assert.strictEqual(coordClusterSignal(CLUSTER_MIN_REPEATS - 1).flagged, false); + }); +}); + +describe("abuse signals: summariseFlags", () => { + it("collects only the fired reasons", () => { + const r = summariseFlags([ + { reason: "impossible_travel", flagged: true }, + { reason: "out_of_window", flagged: false }, + { reason: "coord_cluster", flagged: true }, + ]); + assert.deepStrictEqual(r.reasons, ["impossible_travel", "coord_cluster"]); + }); + it("escalates severity with the number of co-occurring signals", () => { + const sig = (n: number) => + summariseFlags( + ["a", "b", "c"].slice(0, n).map((reason) => ({ reason, flagged: true })), + ).severity; + assert.strictEqual(sig(1), "low"); + assert.strictEqual(sig(2), "med"); + assert.strictEqual(sig(3), "high"); + }); +}); + +describe("abuse signals: applyTrustDecay", () => { + it("decrements more for higher severity", () => { + assert.ok( + applyTrustDecay(DEFAULT_TRUST_SCORE, "high") < + applyTrustDecay(DEFAULT_TRUST_SCORE, "low"), + ); + }); + it("never drops below zero", () => { + assert.strictEqual(applyTrustDecay(0, "high"), 0); + }); + it("never rises above the default ceiling", () => { + assert.ok(applyTrustDecay(DEFAULT_TRUST_SCORE, "low") <= DEFAULT_TRUST_SCORE); + }); +}); diff --git a/lib/admin/admin_abuse_screen.dart b/lib/admin/admin_abuse_screen.dart new file mode 100644 index 00000000..bb8e0abd --- /dev/null +++ b/lib/admin/admin_abuse_screen.dart @@ -0,0 +1,287 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:postbox_game/admin/admin_access.dart'; +import 'package:postbox_game/maintenance_guard.dart'; +import 'package:postbox_game/theme.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// In-app review queue for shadow-mode abuse-detection flags. Visible only to +/// users holding the `admin` custom claim (see [AdminAccess]). Flags are written +/// by the `onClaimCreated` Cloud Function; "Mark reviewed" calls `reviewFlag`. +/// Shadow mode: these flags have no user-facing effect — reviewing is triage +/// only (no claim is voided here). +class AdminAbuseScreen extends StatefulWidget { + const AdminAbuseScreen({super.key}); + + @override + State createState() => _AdminAbuseScreenState(); +} + +class _AdminAbuseScreenState extends State { + // Force a token refresh once on entry to pick up a freshly-granted admin + // claim, then cache the result (mirrors AdminReportsScreen). + late final Future _adminCheck = AdminAccess.isAdmin(forceRefresh: true); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _adminCheck, + builder: (context, snap) { + if (!snap.hasData) { + return const Scaffold(body: Center(child: CircularProgressIndicator(color: postalRed))); + } + if (snap.data != true) { + return Scaffold( + appBar: AppBar(title: const Text('Abuse review')), + body: const Center(child: Text('You do not have access to this area.')), + ); + } + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Abuse review'), + bottom: const TabBar(tabs: [Tab(text: 'Open'), Tab(text: 'Reviewed')]), + ), + body: const TabBarView( + children: [_FlagList(reviewed: false), _FlagList(reviewed: true)], + ), + ), + ); + }, + ); + } +} + +class _FlagList extends StatelessWidget { + const _FlagList({required this.reviewed}); + final bool reviewed; + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: FirebaseFirestore.instance + .collection('moderationFlags') + .where('reviewed', isEqualTo: reviewed) + .snapshots(), + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator(color: postalRed)); + } + if (snap.hasError) return Center(child: Text('Could not load flags: ${snap.error}')); + final docs = (snap.data?.docs ?? []).toList() + ..sort((a, b) { + final ta = a.data()['createdAt']; + final tb = b.data()['createdAt']; + if (ta is Timestamp && tb is Timestamp) return tb.compareTo(ta); + return 0; + }); + if (docs.isEmpty) { + return Center(child: Text(reviewed ? 'No reviewed flags' : 'No open flags')); + } + return ListView.separated( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: docs.length, + separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.md), + itemBuilder: (_, i) => _FlagCard(id: docs[i].id, data: docs[i].data()), + ); + }, + ); + } +} + +class _FlagCard extends StatefulWidget { + const _FlagCard({required this.id, required this.data}); + final String id; + final Map data; + + @override + State<_FlagCard> createState() => _FlagCardState(); +} + +class _FlagCardState extends State<_FlagCard> { + bool _busy = false; + + Map get d => widget.data; + bool get isReviewed => d['reviewed'] == true; + double? get _lat => (d['lat'] as num?)?.toDouble(); + double? get _lng => (d['lng'] as num?)?.toDouble(); + + static const _severityColors = { + 'low': Colors.amber, + 'med': Colors.orange, + 'high': Colors.redAccent, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final created = d['createdAt']; + final when = + created is Timestamp ? DateFormat.yMMMd().add_jm().format(created.toDate().toLocal()) : ''; + final reasons = (d['reasons'] as List?)?.cast().map((e) => '$e').toList() ?? const []; + final severity = (d['severity'] as String?) ?? 'low'; + final uid = (d['uid'] as String?) ?? '?'; + final claimId = d['claimId'] as String?; + final signals = (d['signals'] as Map?)?.cast() ?? const {}; + + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.gpp_maybe_outlined, color: postalRed), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text('Flag · ${uid.length > 8 ? '${uid.substring(0, 8)}…' : uid}', + style: theme.textTheme.titleMedium), + ), + Chip( + label: Text(severity.toUpperCase()), + backgroundColor: + (_severityColors[severity] ?? Colors.grey).withValues(alpha: 0.18), + visualDensity: VisualDensity.compact, + ), + ], + ), + if (when.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(when, style: theme.textTheme.bodySmall), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: 4, + children: [ + for (final r in reasons) + Chip( + label: Text(_reasonLabel(r)), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _InfoRow( + icon: Icons.insights_outlined, + child: Text(_signalSummary(signals)), + ), + if (claimId != null) + _InfoRow(icon: Icons.receipt_long_outlined, child: Text('Claim: $claimId')), + if (_lat != null && _lng != null) + _InfoRow( + icon: Icons.place_outlined, + child: Row( + children: [ + Expanded(child: Text('${_lat!.toStringAsFixed(6)}, ${_lng!.toStringAsFixed(6)}')), + TextButton.icon( + onPressed: () => _openMap(_lat!, _lng!), + icon: const Icon(Icons.map_outlined, size: 18), + label: const Text('Map'), + ), + ], + ), + ), + if (!isReviewed) ...[ + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: _busy ? null : _markReviewed, + child: const Text('Mark reviewed'), + ), + ], + ), + ], + if (isReviewed && (d['reviewNote'] as String?)?.isNotEmpty == true) + _InfoRow(icon: Icons.notes, child: Text(d['reviewNote'] as String)), + ], + ), + ), + ); + } + + String _reasonLabel(String key) { + switch (key) { + case 'impossible_travel': + return 'Impossible travel'; + case 'out_of_window': + return 'Out of window'; + case 'coord_cluster': + return 'Coordinate cluster'; + default: + return key; + } + } + + String _signalSummary(Map s) { + final speed = (s['travelSpeedMPerMin'] as num?)?.toDouble(); + final skew = (s['clockSkewMs'] as num?)?.toInt(); + final repeat = (s['coordRepeatCount'] as num?)?.toInt(); + final parts = []; + if (speed != null) parts.add('${speed.toStringAsFixed(0)} m/min'); + if (skew != null) parts.add('skew ${(skew / 1000).toStringAsFixed(0)} s'); + if (repeat != null) parts.add('×$repeat same spot'); + return parts.isEmpty ? 'no signal values' : parts.join(' · '); + } + + Future _openMap(double lat, double lng) async { + final uri = Uri.parse('https://www.openstreetmap.org/?mlat=$lat&mlon=$lng#map=19/$lat/$lng'); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication) && mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open the map'))); + } + } + + Future _markReviewed() async { + if (MaintenanceGuard.blocked(context, actionLabel: 'review flags')) return; + setState(() => _busy = true); + try { + await appFunctions.httpsCallable('reviewFlag').call({'flagId': widget.id}); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Flag marked reviewed'))); + } + } on FirebaseFunctionsException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Failed: ${e.message ?? e.code}'))); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Failed. Please try again.'))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.icon, required this.child}); + final IconData icon; + final Widget child; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: DefaultTextStyle.merge( + style: Theme.of(context).textTheme.bodyMedium, child: child), + ), + ], + ), + ); + } +} diff --git a/lib/home.dart b/lib/home.dart index 318da434..e6227ee9 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:postbox_game/admin/admin_abuse_screen.dart'; import 'package:postbox_game/admin/admin_access.dart'; import 'package:postbox_game/admin/admin_remote_config_screen.dart'; import 'package:postbox_game/admin/admin_reports_screen.dart'; @@ -165,6 +166,12 @@ class _HomeState extends State { MaterialPageRoute( builder: (_) => const AdminReportsScreen()), ); + case 'adminAbuse': + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AdminAbuseScreen()), + ); case 'adminRemoteConfig': Navigator.push( context, @@ -191,6 +198,15 @@ class _HomeState extends State { contentPadding: EdgeInsets.zero, ), ), + if (_isAdmin) + const PopupMenuItem( + value: 'adminAbuse', + child: ListTile( + leading: Icon(Icons.gpp_maybe_outlined), + title: Text('Admin · Abuse'), + contentPadding: EdgeInsets.zero, + ), + ), if (_isAdmin) const PopupMenuItem( value: 'adminRemoteConfig', diff --git a/lib/wear/wear_claim_page.dart b/lib/wear/wear_claim_page.dart index ddbc9e28..31a9f2a9 100644 --- a/lib/wear/wear_claim_page.dart +++ b/lib/wear/wear_claim_page.dart @@ -192,6 +192,8 @@ class _WearClaimPageState extends State { final result = await _claimCallable.call({ 'lat': position.latitude, 'lng': position.longitude, + // Client wall-clock for the shadow-mode out-of-window anomaly signal. + 'clientTsMs': DateTime.now().millisecondsSinceEpoch, }); final found = result.data?['found'] == true; final allClaimedToday = result.data?['allClaimedToday'] == true; diff --git a/lib/widgets/claim_quiz_sheet.dart b/lib/widgets/claim_quiz_sheet.dart index 6bd667d0..04cb785f 100644 --- a/lib/widgets/claim_quiz_sheet.dart +++ b/lib/widgets/claim_quiz_sheet.dart @@ -505,6 +505,9 @@ class _ClaimQuizSheetState extends State final r = await _claimCallable({ 'lat': position.latitude, 'lng': position.longitude, + // Client wall-clock for the shadow-mode out-of-window anomaly + // signal (server compares it against its own claim timestamp). + 'clientTsMs': DateTime.now().millisecondsSinceEpoch, }); trace.putAttribute(PerfTraces.attrOutcome, 'ok'); return r; diff --git a/test/claim_quiz_sheet_test.dart b/test/claim_quiz_sheet_test.dart index 51aad66f..1d1ba9c2 100644 --- a/test/claim_quiz_sheet_test.dart +++ b/test/claim_quiz_sheet_test.dart @@ -364,4 +364,40 @@ void main() { expect(strip.controller.pendingMessage, isNotNull); expect(strip.controller.pendingMessage, isNotEmpty); }); + + testWidgets('claim sends clientTsMs to startScoring for the anomaly detector', + (tester) async { + Map? claimPayload; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ClaimQuizSheet( + scanPosition: const LatLng(51.5, -0.12), + compact: true, + nearbyCallable: _nearbyUnknownCipher, + positionProvider: () async => _fakePos(), + startScoringCallable: (payload) async { + claimPayload = payload; + return _FakeResult({ + 'found': true, + 'claimed': 1, + 'points': 9, + 'allClaimedToday': false, + }); + }, + onCompleted: (_) {}, + ), + ), + )); + await _settle(tester); + await tester.pump(const Duration(milliseconds: 50)); + + // Unknown cipher → no quiz → claim goes straight to the callable. + await tester.tap(find.text('Claim this postbox!')); + await _settle(tester); + + expect(claimPayload, isNotNull); + expect(claimPayload!['clientTsMs'], isA(), + reason: 'the claim must carry a client timestamp for the ' + 'shadow-mode out-of-window anomaly signal'); + }); }