From 312de6bd073d5389f6191a7b12770109e4492f01 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 13 Oct 2026 10:17:34 -0700 Subject: [PATCH 1/2] feat: add atomic fictional race enrollment refund and transfer service --- GITHUB_ISSUES.md | 2 + IMPLEMENTATION_PLAN.md | 2 + OPERATIONS_RUNBOOK.md | 2 + SECURITY.md | 2 + STRIPE_COMMERCE_DESIGN.md | 2 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 2 + docs/officers/STAGING_COMMUNITY.md | 4 + docs/officers/SYSTEM_MAPS.md | 16 ++ .../fictionalRaceService.emulator.test.js | 163 ++++++++++++++ functions/fictionalRaceService.js | 207 ++++++++++++++++++ functions/fictionalRaceService.test.js | 178 +++++++++++++++ functions/fictionalRaceTransitions.js | 36 ++- .../testSupport/fictionalRaceFixtures.js | 25 +++ tests/firestore-rules/shop-and-locked.test.js | 19 ++ 15 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 functions/fictionalRaceService.emulator.test.js create mode 100644 functions/fictionalRaceService.js create mode 100644 functions/fictionalRaceService.test.js create mode 100644 functions/testSupport/fictionalRaceFixtures.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 41f9a0d..263cbe1 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -168,6 +168,8 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [ ## CI-001 — Repair test gates and secure the deployment pipeline +**RACE-SIM-001B #729 — source transaction implementation:** extend #727's fictional model with bounded server-only event/entry/unique enrollment and immutable command/audit/rate transactions. Read-only native Auth metadata after exact expiring disposable permission/marker checks; no financial/provider mutation. Include server-configured fictional entry creation to prove concurrent final-seat and duplicate-recipient safety. Recheck authority before replay, reject changed request IDs, preserve original credit/waiver/bib and never reinterpret simulation as paid/refunded evidence. Source-only local/demo proof precedes genuine Auth/App Check/RPC/client/backend-first staging deployment/UI/cleanup; no new IAM/service/dependency/workflow or production action. + **RACE-SIM-001A #727 — source-only fictional transition foundation:** Dave approved fictional staging refund/bib-transfer rules after #724 closed. Pure strict synthetic schema/cents/time/revision/ownership/terminal/deadline/waiver transitions, without runtime/client/bundle integration or deployment. Full simulated return releases one place; one recipient-accepted transfer preserves bib/count/original credit/waiver. Actual native authorization, target marking, transactions/immutable command receipts/concurrency/retries and staging UI/deployment/cleanup are separate follow-ons. Real policy/provider money/production and financial parents remain open. **COMMUNITY-STATUS-001 #724 — staging acceptance verified 2026-10-13:** PR725/`485be670`, exact six PR/main CI38359747304/38360107295 jobs/every step, communityActions version 5/native archive `a4c2eefa`/91,973 bytes and exact Hosting pass. All six availability states, cancellation history/own cancellation, failed-read clearing, exact immutable retry and natural term expiry passed actual UI; read-only invariance and one-account/15-document fenced cleanup/sign-out/tab closure were verified. Additive response-only enum/after-read clock; existing schemas, mutations, policy, authorization, security/resource scope and profiles unchanged. No new service/dependency/IAM/Rules/polling/receiver/permission or production/payment action. Dated no-terminal handoff updated; real financial/production and broad backend completion remain separate. Dave approved fictional race rules for the next staging outcome, not real club policy. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7fc1a2f..a4b5f91 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,7 @@ # MPRC Secure Commerce Implementation Plan +**#729 progression — SOURCE / DEMO ONLY:** implement fictional entry creation, unique enrollment and atomic transitions/immutable retries before screens. Bounded native Auth metadata, expiring disposable authority and exact SDK realm checks are tested with real demo transactions; existing Rules deny the nine namespaces. Continue with platform-verified caller/App Check, minimal status/RPC/client contracts and exact backend-first staging release/UI/owned cleanup. No current deployed import, new workflow/service/IAM, financial or production action; #727/#729 are dependencies, not full backend completion. + **#727 fictional race foundation — SOURCE ONLY:** first test a closed pure transition model without provider calls or deployed integration. Then implement native Auth/App Check/disposable-target/permission admission and atomic event/entry/command receipts; test actual concurrency and immutable retries. Only afterward add minimal staging screens and perform exact backend-first Function/Hosting deployment, genuine disposable UI/security/lifecycle checks and owned cleanup. No real rules/payments/production authority is implied; #724 core stays unchanged. **#719 focused continuation — STAGING VERIFIED 2026-10-12:** verified #716 dependency is followed by deliberate fictional receipt linking/unchanged term activation in the same staging service/UI. Separate capability/marker, authoritative Auth, pending revision, atomic uniqueness, permission-first retry and cancellation-through-term guards passed local full checks and 44 hosted cases, including real screens/offline/races/natural expiry. PR720/b6a8fd5 exact PR/main CI passed; Function v4/archive and staging Hosting match; six users/47 documents cleaned. Close only the scoped child after final native handoff; #115/#81, real rules/payments and production launch remain separate. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index fdf51ec..fee48c1 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**#729 fictional persistence — SOURCE / DEMO ONLY:** no current service release is needed: new files remain outside the deployed entrypoint/archive. Local tests use only explicitly selected loopback Auth/Firestore demo SDKs; caller context is synthetic, not genuine native token/App Check proof. Nine server-only namespaces hold fictional event/policy/entry/enrollment/target/permission/command/audit/rate state. A specialist must supply current expiring markers and own-entry permissions for all involved verified/enabled disposable accounts; receipts never override revocation, expiry, missing indexes or corrupt state. No member/officer edits a record, enters real payment data or uses a legacy substitute/refund control. Keep real policy/provider money and production untouched. Future callable/client integration and exact hosted security/UI/cleanup evidence remain unavailable. Local cleanup removes only task-owned demo Auth UIDs and test namespaces, verifies absence and shuts down emulators before removing owned idle logs; no retention/backfill/hosted cleanup decision is made here. + **#727 fictional race foundation — NOT AVAILABLE YET:** pure helper/tests only; it is intentionally excluded from callable/client imports and the community runtime archive. Do not deploy unchanged Functions or use legacy real-payment/runner-substitution screens for this test. Future operation needs current native identity/authority, expiring owner-marked disposable targets, strict fictional configuration, atomic event/entry/immutable receipt persistence, bounded retry/rate/audit and actual backend-first staging/UI/cleanup proof. Source simulation has no provider/account state to undo. Current #724 staging remains unchanged; real club rules, financial/provider work and production are separate. **#724 current-status correction — STAGING VERIFIED 2026-10-13:** same scoped staging service/runtime, response-only availability and no migration. PR725/`485be670` passed six exact PR/main jobs/every step before backend-first publication. communityActions version 5/native 91,973-byte archive `a4c2eefa` and staging Hosting match; deployment and read-only verification returned success. Profiles, resource/security/IAM scope remain unchanged. All six availability states, cancelled-event confirmed history/own cancellation, failed refresh clearing, immutable saved retry and natural synthetic term expiry passed on the actual member page. Failed-read proof used invalid owned configuration, not offline simulation. One account/15 owned documents were removed with identity/update-time fences and absence checks, then sign-out/tab closure. Follow the [no-terminal review](./docs/officers/STAGING_COMMUNITY.md#current-member-status--724-staging-verified). No extra service/polling, receiver, browser permission, real policy/payment or production action. Fictional race rules are approved only for the next staging outcome; those workflows are not yet deployed. diff --git a/SECURITY.md b/SECURITY.md index 7898f1b..5e03457 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # MPRC Security, Privacy, and Risk Plan +**#729 fictional server transactions — SOURCE / DEMO ONLY:** strict account-bound commands, closed record/config/grant schemas, exact staging/demo app/realm, unsupported-tenant denial, current expiring marker/own-entry permission and bounded read-only verified/enabled Auth metadata checks precede both mutation and receipt replay. Original/current runner enrollment indexes must match; orphans and corrupt receipts deny. Server controls fictitious cents, bib, deadlines, counts, waiver and revisions; unknown price/role/profile fields reject before Auth lookup. Atomic fingerprinted receipt/audit/rate plus entry/index/count state resolves retries/races; no mutable audit array or request/token/profile logging. Browser reads/lists/creates/updates/deletes remain denied, even for admins, by unchanged Rules. These source/demo tests supply no native callable token-signature or App Check proof and no instantaneous Auth/Firestore distributed revocation guarantee. No deployed integration, IAM/service/dependency or real financial/production change; genuine platform binding and hosted denial/UI/cleanup proof remain required. + **#727 fictional race foundation — SOURCE ONLY:** strict closed synthetic record/request shapes, integer bounds, expected revisions, fixed safe errors and original evidence preservation apply only to an unconnected pure helper. Fake identifier prefixes or simulation markers are not identity, disposable-target, permission, App Check, atomic persistence/retry or provider proof. No callable, client, release-bundle import, IAM/Rule/service change or deployment; existing #724 staging and production boundaries unchanged. A later server boundary must independently supply native verified identities, current scoped authority, target markers, time, transactional command receipts/counts and genuine staging denial/race/retry/cleanup evidence before the screen is available. **#719 staging linking — STAGING VERIFIED 2026-10-12:** live expiring exact v3 association permission remains separate. Native recent verified TOTP/App Check, exact caller/input, owner-marked disposable UID, authoritative enabled/verified Auth and pending null-authority revision are required. Atomic receipt/account uniqueness and permission-first replay prevent duplicate activation/reassignment/renewal; original evidence/term and profiles/claims remain unchanged. Minimal linked receipt projection exposes no target/contact data. Both new collections remain default-denied even to browser admins; runtime keeps the same six project-scoped permissions. Native source/spec/security and 32 denial cases plus hosted races/retries/UI/revocation/cancel/expiry and exact cleanup passed. This is fictional staging proof, not production security qualification. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 2b58eb6..82c1def 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,5 +1,7 @@ # Stripe, Race Registration, and Merchandise Design +**#729 simulation persistence — SOURCE / DEMO ONLY:** server-configured fictional entry creation and atomic simulated credit return/bib transfer use separate records and never Stripe/payment state. No client amount, discount, refund total, arbitrary runner profile or offer deadline is accepted. Original fictitious credit remains immutable; transferred runners cannot redirect it. Same-command receipts replay without writes after renewed authority/identity/state checks; lost-acknowledgement recovery and native demo final-seat/competing transitions are tested. Read-only Auth metadata is the only external dependency of the transaction, not a provider money action. Default-tenant verified password context here is for member-owned fictional credits only; it does not replace finance capability/recent MFA, provider truth, refund operation/reconciliation or real policy gates. No live callable/client/deployment from this child. + **#727 fictional race foundation — SOURCE ONLY:** a separate unconnected pure model uses explicit fictional cents/returned-credit fields and closed simulation markers. It neither reads nor creates a Checkout, PaymentIntent, Refund, provider object, webhook or real payment status. Test-only full credit return/recipient-accepted bib transfer rules do not approve actual club policy or close PAY-004/PAY-005/RACE-001. Real refunds still require verified provider state, finance permission/recent MFA, durable idempotent operations and reconciliation below. No runtime/client/release-bundle import or deployment. **#719 fictional activation — STAGING VERIFIED 2026-10-12:** separately authorized disposable accounts may receive only an unchanged fictional receipt term. This is not payment verification, Stripe reconciliation, a real dues decision, renewal, refund or Auth-role grant. Receipt evidence stays immutable; association is separate and server-only. Native Function v4/Hosting b6a8fd5 and actual cancellation/expiry tests passed; six users/47 documents were cleaned. Production and real payments remain untouched and unavailable. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 3bd3c13..8971e53 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**#729 fictional race transactions — SOURCE / DEMO ONLY:** an unconnected server service uses an explicitly same-project SDK app in staging with commerce disabled, or loopback Auth/Firestore under a demo project. Exact expiring disposable markers and per-account `manageOwnEntry` permission precede at most three native Auth metadata reads; verified/enabled accounts and a recent default-tenant password context are required. Browser flags/admin roles grant nothing. A server-only fictional policy supplies amount/window/waiver; deterministic entry/bib and unique per-event enrollment prevent duplicate seats and two bibs for one recipient. Event/entry/index plus immutable fingerprinted request receipt, append audit and rate update commit atomically. Current authority/identity/index/state checks precede even old receipt replay; replay is read-only, never current eligibility or payment proof. Time is sampled after all reads before writes. Original fictional credit/bib/waiver are preserved. One simulation enrollment per account/event, with no rejoin after cancellation/transfer, is a technical test rule only. Local real SDK transaction/race tests and existing Rules denials qualify source, not native token verification/App Check or hosted callable/UI. No current entrypoint/client/archive integration, migration, IAM/service/dependency/workflow, provider-money or production action. Auth metadata is not transactionally locked with Firestore; its check is admission evidence, not instant cross-service revocation. + **#727 fictional race foundation — SOURCE ONLY / NOT AVAILABLE YET:** `fictionalRaceTransitions.js` is a pure, strictly bounded model, not imported by an entrypoint, client or runtime release bundle. Dave approved fictional test rules only: full simulated credit return before race start; one recipient-accepted bib transfer with a short offer deadline, original credit/waiver/bib preservation and no capacity increase. Expected event/entry revisions and terminal guards prevent an admitted transition from using stale supplied records; this is not native authorization, atomic persistence, immutable retry/journal or concurrency proof. No payment/provider evidence, real rules, new service/dependency/IAM/Rules, migration or deployment. Follow-on trusted Auth/App Check/disposable-target/capability boundary, transactional command receipts/capacity, minimal staging UI and backend-first hosted tests/cleanup remain required. Current #724 Function/Hosting and production remain unchanged. **#724 member-status correction — STAGING VERIFIED 2026-10-13:** the existing staging status read adds a response-only closed `signupStatus` enum (open/not_open_yet/closed/ended/full/cancelled), derived from validated event/window/capacity with server time sampled after reads. No stored schema, mutation authorization, count, registration/waiver/audit, membership policy, IAM/Rules/service/dependency or production change. UI distinguishes cancelled events from stored signups, fails closed on missing/unknown availability and hides old membership/event status/actions on uncertain reads while preserving exact saved retry and account/provider fences. No polling. PR725 merged `485be670244c7ecf59cc7ab32ac465f5b3372a1f`; exact PR/main CI38359747304/38360107295 passed all six jobs/every step. Native Function version 5 and staging Hosting match that source; the 91,973-byte archive matches SHA-256 `a4c2eefaa3e7c3a6b40580ef7dcd7d668c80dedf4563a13565cb5034a449ba1c`. Deployment/read-only verification returned success; profiles/security/resource scope are unchanged. Actual UI proved all six states, cancelled-event history/own cancellation, failed-read clearing, immutable saved retry and natural synthetic term expiry. Nine initial documents/update times stayed unchanged by reads; one disposable account/15 owned documents were removed with fences and absence checks, followed by sign-out/tab closure. Backend failure used an invalid owned manifest, not offline simulation. No new service, receiver or browser permission was needed. Production and real payments remain separate; fictional race rules were approved only for subsequent staging tests. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 194564c..39d43b5 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -4,6 +4,8 @@ **Current status:** live commerce is **NOT AVAILABLE YET**. +**#729 fictional transactions — SOURCE / DEMO ONLY:** server-controlled fake signup, credit return and recipient-accepted bib transfer have local transaction/retry/race proof. There is still no usable staging screen or deployed callable from this child. Ask Dave/platform specialist for the next dated native release/UI proof before trying it; do not use legacy controls, enter real details or edit a record. Real refunds still need finance permission/MFA, verified provider state and approved policy. See [the unavailable staging handoff](./STAGING_COMMUNITY.md#fictional-race-refunds-and-bib-transfers--727-source-foundation-not-available-yet). + **Fictional race follow-on, #727:** Dave approved fictional refund and bib-transfer rules for staging tests, not real club policy. The [source-only handoff](./STAGING_COMMUNITY.md#fictional-race-refunds-and-bib-transfers--727-source-foundation-not-available-yet) models credit return and recipient-accepted transfer with made-up records. No usable screen, native authorization, transaction/retry proof, provider refund or deployment is supplied by that foundation. Do not use legacy refund/substitute controls or edit a real entry as a workaround. **Use this guide when:** a request touches registrations, members-only access, products, payments, refunds, waivers, or private data. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 19399c0..09f5dd6 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -29,6 +29,10 @@ ## Fictional race refunds and bib transfers — #727 source foundation, NOT AVAILABLE YET +**#729 update — SOURCE / DEMO ONLY:** saved actions, fictional signup, refund and transfer now have a transactional source implementation. Real local emulator tests prove final-seat, duplicate cancellation, competing transfer/revoke/refund and lost-acknowledgement recovery; browser-admin data access is denied by the unchanged Rules. This does not supply a usable screen, deployed service or native token/App Check proof. The specialist must next connect the reviewed platform boundary and staging screen and verify actual deployment, disposable sign-in, failures/retries and cleanup. No officer terminal or record edit is needed for that review. Auth metadata can change outside the database transaction, so these tests do not promise instant cross-service revocation. + +The test policy permits one enrollment per disposable account/event. Cancelled or transferred enrollments cannot rejoin. A recipient already enrolled cannot accept another bib. These are fictional test limits, not the club's real race policy. Server-configured fictional amounts/windows and a fixed test waiver supply the signup; no checkout or money is created. All previous **NOT AVAILABLE YET**, approval, stop and specialist-only requirements below still apply. + **Purpose:** prepare a safe demonstration without moving money. **Approver/escalation:** Dave, sole staging owner; no backup is appointed. **Prerequisites:** the later reviewed server transaction, verified disposable accounts/markers, narrow permissions, genuine Auth/App Check and actual staging screen/deployment/cleanup proof. None is supplied by this source foundation. There is no usable race refund or transfer button from #727. Dave approved fictional test rules, not real club rules. The source models a full return of fictional credit before race start, releasing a seat once. A transfer keeps the same bib and booked count, requires the named recipient's fictional-waiver acceptance before a short deadline, and preserves the original fictional credit owner and waiver. An outstanding offer must be revoked before cancellation. Only one transfer is permitted in this test model. After transfer, only the current runner may cancel; simulated credit returns to its original owner. No card, bank, Stripe object, real refund or paid membership is involved. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 0e565ca..ae4af35 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -2,6 +2,22 @@ These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. +## Fictional race transactions — #729 source and demo only + +```mermaid +flowchart LR + Test["Synthetic caller context; no live screen"] --> Service["Exact demo SDK / closed staging source guard"] + Service --> Grant["Current permission, disposable markers and indexes"] + Grant --> Auth["Up to 3 read-only Auth metadata checks"] + Auth --> Tx["Fresh time; transaction decides signup/refund/transfer"] + Tx --> State["Entry, unique enrollment and changed count"] + Tx --> Receipt["Immutable command, audit and rate"] + Receipt -- "Saved retry rechecks authority; no writes" --> Grant + Future["Native caller/App Check + staging screen NOT AVAILABLE YET"] -.-> Service +``` + +Text alternative: source/demo tests check current disposable authority and bounded Auth metadata before atomically changing fictional entry/enrollment/count and receipt/audit/rate state, while saved retries recheck authority without writes and actual native caller/App Check, staging screen, deployment and cleanup remain future gates; Auth metadata is not locked by the Firestore transaction. + ## Member status freshness — #724 staging verified ```mermaid diff --git a/functions/fictionalRaceService.emulator.test.js b/functions/fictionalRaceService.emulator.test.js new file mode 100644 index 0000000..1ff95fa --- /dev/null +++ b/functions/fictionalRaceService.emulator.test.js @@ -0,0 +1,163 @@ +const admin = require('firebase-admin'); +const { createFictionalRaceService, raceEnvironment } = require('./fictionalRaceService'); +const { raceEnrollmentId } = require('./fictionalRaceTransitions'); +const f = require('./testSupport/fictionalRaceFixtures'); + +const describeDemo = process.env.FIRESTORE_EMULATOR_HOST ? describe : describe.skip; +describeDemo('fictional race native Auth metadata and Firestore transactions — demo only, no callable/attestation proof', () => { + let app; let db; let auth; let now; let service; let sequence = 0; + const ownedUids = new Set(); + const assertDemo = () => expect(['demo-mprc-local', 'demo-functions-test']).toContain(raceEnvironment(process.env)); + beforeAll(() => { + assertDemo(); + app = admin.initializeApp({ projectId: process.env.GCLOUD_PROJECT }, 'fictional-race-729'); + db = app.firestore(); auth = app.auth(); + }); + async function clean() { + assertDemo(); + for (const root of f.ROOTS) await db.recursiveDelete(db.collection(root)); + for (const uid of ownedUids) { + expect((await db.collection(`communityRaceCommands/${uid}/requests`).get()).empty).toBe(true); + await auth.deleteUser(uid); + await expect(auth.getUser(uid)).rejects.toHaveProperty('code', 'auth/user-not-found'); + ownedUids.delete(uid); + } + } + beforeEach(async () => { + await clean(); now = Date.now(); service = createFictionalRaceService(app, () => now); + await db.doc(`communityRaceEvents/${f.eventId}`).create(f.event(now)); + await db.doc(`communityRacePolicies/${f.eventId}`).create(f.policy(now)); + }, 45000); + afterAll(async () => { if (db) await clean(); if (app) await app.delete(); }, 45000); + async function member() { + const uid = `stage_race_729_${sequence++}`; + // Successful creation, not a name prefix, establishes cleanup ownership. + await auth.createUser({ uid, email: `${uid}@example.invalid`, emailVerified: true, disabled: false }); + ownedUids.add(uid); + await db.doc(`communityRaceTargets/${uid}`).create(f.marker(uid, now)); + await db.doc(`communityRacePermissions/${uid}`).create(f.permission(uid, now)); + return uid; + } + const run = (uid, request) => service.execute(uid, f.token(now), request); // Synthetic callable context, not native token verification. + async function joined(uid, eventRevision = 1) { + const request = f.command(uid, 'join_simulation', { expectedEventRevision: eventRevision }); + return { request, result: await run(uid, request), entryId: request.entryId }; + } + const change = (uid, entryId, action, revision, extra = {}) => f.command(uid, action, { + entryId, expectedEntryRevision: revision, expectedEventRevision: 2, ...extra, + }); + async function snapshots() { + const result = []; + for (const root of f.ROOTS) { + const docs = await db.collection(root).get(); + for (const doc of docs.docs) result.push([doc.ref.path, [doc.updateTime.seconds, doc.updateTime.nanoseconds], doc.data()]); + } + for (const uid of ownedUids) { + const commands = await db.collection(`communityRaceCommands/${uid}/requests`).get(); + for (const doc of commands.docs) result.push([doc.ref.path, [doc.updateTime.seconds, doc.updateTime.nanoseconds], doc.data()]); + } + return result.sort(([a], [b]) => a.localeCompare(b)); + } + + test('twenty native final-seat attempts admit one; five duplicate cancellations release exactly once', async () => { + const members = []; for (let n = 0; n < 20; n++) members.push(await member()); + const results = await Promise.allSettled(members.map(uid => run(uid, f.command(uid)))); + const winners = results.flatMap((result, n) => result.status === 'fulfilled' ? [n] : []); + expect(winners).toHaveLength(1); + expect((await db.doc(`communityRaceEvents/${f.eventId}`).get()).data()).toMatchObject({ confirmedCount: 1, revision: 2 }); + expect((await db.collection('communityRaceEntries').get()).size).toBe(1); + expect((await db.collection('communityRaceEnrollment').get()).size).toBe(1); + const uid = members[winners[0]]; const entryId = raceEnrollmentId(f.eventId, uid); + const refund = change(uid, entryId, 'simulate_refund', 1); + await Promise.all(Array.from({ length: 5 }, () => run(uid, refund))); + expect((await db.doc(`communityRaceEvents/${f.eventId}`).get()).data()).toMatchObject({ confirmedCount: 0, revision: 3 }); + expect((await db.doc(`communityRaceEntries/${entryId}`).get()).data()).toMatchObject({ status: 'cancelled', fictionalReturnedCents: 1000, revision: 2 }); + expect((await db.collection('communityRaceAudit').get()).size).toBe(2); + expect((await db.collection(`communityRaceCommands/${uid}/requests`).get()).size).toBe(2); + }, 45000); + + test('exact replay preserves native update times; changed request and old successful join cannot resurrect cancelled entry', async () => { + const uid = await member(); const { request, entryId } = await joined(uid); + const refund = change(uid, entryId, 'simulate_refund', 1); await run(uid, refund); + const before = await snapshots(); await run(uid, refund); await run(uid, request); + expect(await snapshots()).toEqual(before); + await expect(run(uid, { ...refund, expectedEntryRevision: 2 })).rejects.toBeDefined(); + expect(await snapshots()).toEqual(before); + }, 45000); + + test('competing refund and transfer offer resolve once; no double state or negative seat', async () => { + const uid = await member(); const recipient = await member(); const { entryId } = await joined(uid); + const outcomes = await Promise.allSettled([ + run(uid, change(uid, entryId, 'simulate_refund', 1)), + run(uid, change(uid, entryId, 'offer_transfer', 1, { recipientUid: recipient })), + ]); + expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1); + const entry = (await db.doc(`communityRaceEntries/${entryId}`).get()).data(); + const event = (await db.doc(`communityRaceEvents/${f.eventId}`).get()).data(); + expect(entry.revision).toBe(2); + expect(event.confirmedCount).toBe(entry.status === 'cancelled' ? 0 : 1); + expect(entry.status === 'cancelled' ? entry.offer === null : entry.offer.recipientUid === recipient).toBe(true); + expect((await db.collection('communityRaceAudit').get()).size).toBe(2); + }, 45000); + + test('recipient accept races owner revoke; one transition wins and original evidence/count stays correct', async () => { + const uid = await member(); const recipient = await member(); const { entryId } = await joined(uid); + const original = (await db.doc(`communityRaceEntries/${entryId}`).get()).data(); + await run(uid, change(uid, entryId, 'offer_transfer', 1, { recipientUid: recipient })); + const outcomes = await Promise.allSettled([ + run(recipient, change(recipient, entryId, 'accept_transfer', 2)), + run(uid, change(uid, entryId, 'revoke_transfer', 2)), + ]); + expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1); + const current = (await db.doc(`communityRaceEntries/${entryId}`).get()).data(); + expect(current).toMatchObject({ revision: 3, offer: null, bibId: original.bibId, creditOwnerUid: uid }); + expect(current.originalWaiver).toEqual(original.originalWaiver); + expect((await db.doc(`communityRaceEvents/${f.eventId}`).get()).data().confirmedCount).toBe(1); + expect((await db.collection('communityRaceAudit').get()).size).toBe(3); + }, 45000); + + test('two offered bibs cannot enroll the same recipient twice', async () => { + await db.doc(`communityRaceEvents/${f.eventId}`).set(f.event(now, 2)); + const a = await member(); const b = await member(); const recipient = await member(); + const one = await joined(a); const two = await joined(b, 2); + const offer = (uid, entryId) => change(uid, entryId, 'offer_transfer', 1, { recipientUid: recipient, expectedEventRevision: 3 }); + await run(a, offer(a, one.entryId)); await run(b, offer(b, two.entryId)); + const accepts = await Promise.allSettled([ + run(recipient, change(recipient, one.entryId, 'accept_transfer', 2, { expectedEventRevision: 3 })), + run(recipient, change(recipient, two.entryId, 'accept_transfer', 2, { expectedEventRevision: 3 })), + ]); + expect(accepts.filter(value => value.status === 'fulfilled')).toHaveLength(1); + const enrollment = (await db.doc(`communityRaceEnrollment/${raceEnrollmentId(f.eventId, recipient)}`).get()).data(); + expect([one.entryId, two.entryId]).toContain(enrollment.entryId); + const entries = await db.collection('communityRaceEntries').get(); + expect(entries.docs.filter(doc => doc.data().runnerUid === recipient)).toHaveLength(1); + expect((await db.doc(`communityRaceEvents/${f.eventId}`).get()).data().confirmedCount).toBe(2); + }, 45000); + + test('native disabled/unverified account or revoked/expired marker denies replay without any document change', async () => { + const uid = await member(); const { request } = await joined(uid); + await auth.updateUser(uid, { disabled: true }); let before = await snapshots(); + await expect(run(uid, request)).rejects.toHaveProperty('code', 'permission-denied'); expect(await snapshots()).toEqual(before); + await auth.updateUser(uid, { disabled: false, emailVerified: false }); + await expect(run(uid, request)).rejects.toHaveProperty('code', 'permission-denied'); expect(await snapshots()).toEqual(before); + await auth.updateUser(uid, { emailVerified: true }); + await db.doc(`communityRacePermissions/${uid}`).update({ enabled: false }); before = await snapshots(); + await expect(run(uid, request)).rejects.toHaveProperty('code', 'permission-denied'); expect(await snapshots()).toEqual(before); + await db.doc(`communityRacePermissions/${uid}`).set(f.permission(uid, now)); + await db.doc(`communityRaceTargets/${uid}`).update({ expiresAtMs: now }); before = await snapshots(); + await expect(run(uid, request)).rejects.toHaveProperty('code', 'permission-denied'); expect(await snapshots()).toEqual(before); + }, 45000); + + test('real commit followed by a synthetic lost acknowledgement recovers only the saved receipt', async () => { + const uid = await member(); const request = f.command(uid); let lost = true; + const facade = { options: app.options, auth: () => auth, firestore: () => ({ doc: db.doc.bind(db), + runTransaction: async fn => { const result = await db.runTransaction(fn); if (lost) { lost = false; throw Error('synthetic lost acknowledgement'); } return result; } }) }; + const guarded = createFictionalRaceService(facade, () => now); + await expect(guarded.execute(uid, f.token(now), request)).rejects.toHaveProperty('code', 'unavailable'); + const before = await snapshots(); + const result = await guarded.execute(uid, f.token(now), request); expect(result.entryRevision).toBe(1); + expect(await snapshots()).toEqual(before); + expect((await db.collection('communityRaceEntries').get()).size).toBe(1); + expect((await db.collection('communityRaceAudit').get()).size).toBe(1); + }, 45000); +}); diff --git a/functions/fictionalRaceService.js b/functions/fictionalRaceService.js new file mode 100644 index 0000000..f824df6 --- /dev/null +++ b/functions/fictionalRaceService.js @@ -0,0 +1,207 @@ +'use strict'; + +// Server-only source; no callable/client/runtime-bundle integration yet. +// Only read-only Auth metadata is external to the Firestore transaction. +const functions = require('firebase-functions'); +const { createHash } = require('node:crypto'); +const { parseStrictObject } = require('./requestValidation'); +const { POLICY, WAIVER, reduceFictionalRace, readFictionalRaceRecords, raceEnrollmentId, createFictionalEntry } = require('./fictionalRaceTransitions'); +const MESSAGE = 'Fictional race action unavailable. Refresh or sign in again.'; +const ID = /^stage_race_[A-Za-z0-9_-]{1,64}$/; +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const ACTIONS = new Set(['join_simulation', 'simulate_refund', 'offer_transfer', 'accept_transfer', 'revoke_transfer']); +const safeErrors = new WeakSet(); +const hash = value => createHash('sha256').update(value).digest('hex'); +const strict = (value, keys) => parseStrictObject(value, { requiredKeys: keys, optionalKeys: [] }); +const integer = (value, max = 8640000000000000) => Number.isSafeInteger(value) && value >= 0 && value <= max; +const identifier = value => typeof value === 'string' && ID.test(value); +function fail(code = 'failed-precondition') { + const error = new functions.https.HttpsError(code, MESSAGE); safeErrors.add(error); throw error; +} +const check = (value, code) => { if (!value) fail(code); }; + +function raceEnvironment(env) { + const project = env.GCLOUD_PROJECT; + const demo = /^demo-[a-z0-9-]+$/.test(project || '') + && /^127\.0\.0\.1:\d+$/.test(env.FIRESTORE_EMULATOR_HOST || '') + && /^127\.0\.0\.1:\d+$/.test(env.FIREBASE_AUTH_EMULATOR_HOST || ''); + check((project === 'run-mprc-staging' && env.COMMERCE_ENABLED === 'false' + && !env.FIRESTORE_EMULATOR_HOST && !env.FIREBASE_AUTH_EMULATOR_HOST) || demo); + return project; +} + +function parseRaceCommand(input, uid) { + try { + const parsed = parseStrictObject(input, { requiredKeys: ['action', 'accountUid', 'requestId', 'eventId', + 'entryId', 'expectedEventRevision', 'expectedEntryRevision'], optionalKeys: ['recipientUid', 'waiverAccepted', 'waiverVersion'] }); + const extra = parsed.action === 'offer_transfer' ? ['recipientUid'] + : ['join_simulation', 'accept_transfer'].includes(parsed.action) ? ['waiverAccepted', 'waiverVersion'] : []; + strict(parsed, ['action', 'accountUid', 'requestId', 'eventId', 'entryId', 'expectedEventRevision', 'expectedEntryRevision', ...extra]); + check(identifier(uid) && parsed.accountUid === uid, 'permission-denied'); + check(ACTIONS.has(parsed.action) && identifier(parsed.eventId) && identifier(parsed.entryId) + && typeof parsed.requestId === 'string' && UUID.test(parsed.requestId) + && integer(parsed.expectedEventRevision, 999999) && parsed.expectedEventRevision > 0 + && integer(parsed.expectedEntryRevision, 999999) + && (parsed.action === 'join_simulation' ? parsed.expectedEntryRevision === 0 : parsed.expectedEntryRevision > 0) + && (!extra.includes('recipientUid') || (identifier(parsed.recipientUid) && parsed.recipientUid !== uid)) + && (!extra.includes('waiverAccepted') || (parsed.waiverAccepted === true && parsed.waiverVersion === WAIVER)), 'invalid-argument'); + if (parsed.action === 'join_simulation') check(parsed.entryId === raceEnrollmentId(parsed.eventId, uid), 'invalid-argument'); + return parsed; + } catch (error) { if (safeErrors.has(error)) throw error; fail('invalid-argument'); } +} + +function actor(token, now) { + check(token?.email_verified === true && token.firebase?.sign_in_provider === 'password' && token.firebase.tenant === undefined + && integer(token.auth_time, Number.MAX_SAFE_INTEGER) && token.auth_time * 1000 <= now + 30000 + && now - token.auth_time * 1000 <= 3600000, 'permission-denied'); +} +function grant(input, uid, now, permission = false) { + try { + const value = strict(input, ['schemaVersion', 'simulationOnly', 'enabled', 'uid', 'revision', 'expiresAtMs', ...(permission ? ['manageOwnEntry'] : [])]); + check(value.schemaVersion === 1 && value.simulationOnly === true && value.enabled === true + && value.uid === uid && integer(value.revision, 999999) && value.revision > 0 + && integer(value.expiresAtMs) && value.expiresAtMs > now && value.expiresAtMs - now <= 86400000 + && (!permission || value.manageOwnEntry === true), 'permission-denied'); + return value; + } catch { fail('permission-denied'); } +} +function enrollment(input, eventId, uid, entryId, status) { + const value = strict(input, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', 'uid', 'entryId', 'status', 'revision']); + check(value.schemaVersion === 1 && value.simulationOnly === true && value.policyVersion === POLICY + && value.eventId === eventId && value.uid === uid && value.entryId === entryId && value.status === status + && integer(value.revision, 999999) && value.revision > 0); + return value; +} +const enrollmentValue = (eventId, uid, entryId) => ({ schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, + eventId, uid, entryId, status: 'confirmed', revision: 1 }); +function resultView(input, request) { + const value = strict(input, ['schemaVersion', 'simulationOnly', 'action', 'eventId', 'entryId', 'entryRevision', 'eventRevision', 'offerExpiresAtMs']); + check(value.schemaVersion === 1 && value.simulationOnly === true && value.action === request.action + && value.eventId === request.eventId && value.entryId === request.entryId + && integer(value.entryRevision, 999999) && value.entryRevision === request.expectedEntryRevision + 1 + && integer(value.eventRevision, 999999) && value.eventRevision === request.expectedEventRevision + (['join_simulation', 'simulate_refund'].includes(request.action) ? 1 : 0) + && (request.action === 'offer_transfer' ? integer(value.offerExpiresAtMs) : value.offerExpiresAtMs === null)); + return value; +} +function replay(input, uid, request, fingerprint, now) { + const value = strict(input, ['schemaVersion', 'simulationOnly', 'policyVersion', 'actorUid', 'requestId', + 'eventId', 'entryId', 'action', 'fingerprint', 'createdAtMs', 'result']); + check(value.schemaVersion === 1 && value.simulationOnly === true && value.policyVersion === POLICY + && value.actorUid === uid && value.requestId === request.requestId && value.action === request.action + && value.eventId === request.eventId && value.entryId === request.entryId + && integer(value.createdAtMs) && value.createdAtMs <= now); + check(value.fingerprint === fingerprint, 'already-exists'); + const result = resultView(value.result, request); + if (request.action === 'offer_transfer') check(result.offerExpiresAtMs > value.createdAtMs && result.offerExpiresAtMs - value.createdAtMs <= 300000); + return result; +} + +function createFictionalRaceService(app, clock = Date.now, env = process.env) { + let db; let auth; + try { + const project = raceEnvironment(env); + check(app.options.projectId === project); // Native SDK instances must use the same explicitly selected app. + db = app.firestore(); auth = app.auth(); + } catch (error) { if (safeErrors.has(error)) throw error; fail(); } + const indexRef = (eventId, uid) => db.doc(`communityRaceEnrollment/${raceEnrollmentId(eventId, uid)}`); + async function execute(uid, token, input) { + try { + raceEnvironment(env); const request = parseRaceCommand(input, uid); + const fingerprint = hash(JSON.stringify(Object.fromEntries(Object.keys(request).sort().map(key => [key, request[key]])))); + return await db.runTransaction(async tx => { + const began = clock(); check(integer(began)); actor(token, began); + const permissionRef = db.doc(`communityRacePermissions/${uid}`); + const callerMarkerRef = db.doc(`communityRaceTargets/${uid}`); + const eventRef = db.doc(`communityRaceEvents/${request.eventId}`); + const entryRef = db.doc(`communityRaceEntries/${request.entryId}`); + const commandRef = db.doc(`communityRaceCommands/${uid}/requests/${request.requestId}`); + const [permissionDoc, callerMarkerDoc, eventDoc, entryDoc, commandDoc] = await tx.getAll(permissionRef, callerMarkerRef, eventRef, entryRef, commandRef); + const permission = grant(permissionDoc.data(), uid, began, true); + const callerMarker = grant(callerMarkerDoc.data(), uid, began); + const joining = request.action === 'join_simulation'; + const readAt = clock(); check(integer(readAt)); + const before = entryDoc.exists ? readFictionalRaceRecords(eventDoc.data(), entryDoc.data(), readAt) : null; + check(joining || before !== null); + check(!commandDoc.exists || before !== null); // Never acknowledge an orphaned successful receipt. + if (before) check([before.entry.runnerUid, before.entry.creditOwnerUid, before.entry.offer?.recipientUid].includes(uid), 'permission-denied'); + // Mark each involved UID before a bounded native Auth lookup; no email matching or roster query. + const targets = [...new Set([uid, before?.entry.runnerUid, before?.entry.creditOwnerUid, + before?.entry.offer?.recipientUid, request.recipientUid].filter(Boolean))]; + check(targets.length <= 3); + const markers = await tx.getAll(...targets.map(target => db.doc(`communityRaceTargets/${target}`))); + markers.forEach((doc, n) => grant(doc.data(), targets[n], began)); + const users = await Promise.all(targets.map(target => auth.getUser(target))); + users.forEach((user, n) => check(user.uid === targets[n] && user.emailVerified === true && user.disabled === false, 'permission-denied')); + const indexDocs = await tx.getAll(...targets.map(target => indexRef(request.eventId, target))); + const indexByUid = new Map(targets.map((target, n) => [target, indexDocs[n]])); + if (before) { + const entry = before.entry; + enrollment(indexByUid.get(entry.runnerUid).data(), request.eventId, entry.runnerUid, entry.entryId, entry.status); + if (entry.transferCount === 1) enrollment(indexByUid.get(entry.creditOwnerUid).data(), request.eventId, entry.creditOwnerUid, entry.entryId, 'transferred'); + } + // Sampling after reads prevents a slow Auth/read from extending a permission, offer or signup window. + const now = clock(); check(integer(now)); actor(token, now); + grant(permission, uid, now, true); grant(callerMarker, uid, now); + markers.forEach((doc, n) => grant(doc.data(), targets[n], now)); + if (commandDoc.exists) return replay(commandDoc.data(), uid, request, fingerprint, now); + const rateRef = db.doc(`communityRaceLimits/${uid}`); + const rateDoc = await tx.get(rateRef); + const rate = rateDoc.exists ? strict(rateDoc.data(), ['window', 'count']) : { window: Math.floor(now / 60000), count: 0 }; + check(integer(rate.window, Number.MAX_SAFE_INTEGER) && integer(rate.count, 12)); + const writes = []; let policyInput; + if (joining) { + check(!entryDoc.exists && !indexByUid.get(uid).exists && eventDoc.data()?.revision === request.expectedEventRevision); + const policyDoc = await tx.get(db.doc(`communityRacePolicies/${request.eventId}`)); + policyInput = policyDoc.data(); + writes.push([indexRef(request.eventId, uid), enrollmentValue(request.eventId, uid, request.entryId), false]); + } else { + if (request.action === 'offer_transfer' || request.action === 'accept_transfer') { + const recipient = request.action === 'offer_transfer' ? request.recipientUid : uid; + check(!indexByUid.get(recipient).exists); + } + if (request.action === 'simulate_refund' || request.action === 'accept_transfer') { + const previousUid = before.entry.runnerUid; + const previousIndex = enrollment(indexByUid.get(previousUid).data(), request.eventId, previousUid, request.entryId, 'confirmed'); + check(previousIndex.revision < 999999); + writes.push([indexRef(request.eventId, previousUid), { ...previousIndex, revision: previousIndex.revision + 1, + status: request.action === 'simulate_refund' ? 'cancelled' : 'transferred' }, true]); + if (request.action === 'accept_transfer') writes.push([indexRef(request.eventId, uid), enrollmentValue(request.eventId, uid, request.entryId), false]); + } + } + // Recheck expiry after all reads, before first write. Firestore conflicts rerun the whole boundary. + const admitted = clock(); check(integer(admitted)); actor(token, admitted); + grant(permission, uid, admitted, true); markers.forEach((doc, n) => grant(doc.data(), targets[n], admitted)); + const window = Math.floor(admitted / 60000); check(rate.window <= window); + const count = rate.window === window ? rate.count : 0; check(count < 12, 'resource-exhausted'); + const reducedRequest = { action: request.action, eventId: request.eventId, entryId: request.entryId, + expectedEventRevision: request.expectedEventRevision, expectedEntryRevision: request.expectedEntryRevision, + ...(request.action === 'offer_transfer' ? { recipientUid: request.recipientUid, expiresAtMs: Math.min(admitted + 300000, before.event.startAtMs) } : {}), + ...(request.action === 'accept_transfer' ? { waiverVersion: request.waiverVersion } : {}) }; + const changed = joining ? createFictionalEntry(eventDoc.data(), policyInput, uid, request.waiverVersion, admitted) + : reduceFictionalRace(before.event, before.entry, reducedRequest, uid, admitted); + const result = resultView({ schemaVersion: 1, simulationOnly: true, action: request.action, + eventId: request.eventId, entryId: request.entryId, entryRevision: changed.entry.revision, + eventRevision: changed.event.revision, offerExpiresAtMs: changed.entry.offer?.expiresAtMs ?? null }, request); + if (changed.event.revision !== eventDoc.data().revision) writes.push([eventRef, changed.event, true]); + writes.push([entryRef, changed.entry, entryDoc.exists]); + for (const [ref, value, exists] of writes) { if (exists) tx.set(ref, value); else tx.create(ref, value); } + tx.set(rateRef, { window, count: count + 1 }); + tx.create(commandRef, { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, actorUid: uid, + requestId: request.requestId, eventId: request.eventId, entryId: request.entryId, action: request.action, + fingerprint, createdAtMs: admitted, result }); + tx.create(db.doc(`communityRaceAudit/${hash(`${uid}:${request.requestId}`)}`), { + schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, action: request.action, + actorRef: hash(uid), eventId: request.eventId, entryId: request.entryId, createdAtMs: admitted, + permissionRevision: permission.revision, markerRevision: callerMarker.revision, + fromEntryRevision: before?.entry.revision ?? 0, toEntryRevision: changed.entry.revision, + fromCount: eventDoc.data().confirmedCount, toCount: changed.event.confirmedCount, + fromRunnerRef: before ? hash(before.entry.runnerUid) : null, toRunnerRef: hash(changed.entry.runnerUid), + }); + return result; + }); + } catch (error) { if (safeErrors.has(error)) throw error; fail('unavailable'); } + } + return { execute }; +} + +module.exports = { createFictionalRaceService, raceEnvironment, parseRaceCommand }; diff --git a/functions/fictionalRaceService.test.js b/functions/fictionalRaceService.test.js new file mode 100644 index 0000000..06cf795 --- /dev/null +++ b/functions/fictionalRaceService.test.js @@ -0,0 +1,178 @@ +const { createFictionalRaceService, raceEnvironment, parseRaceCommand } = require('./fictionalRaceService'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const f = require('./testSupport/fictionalRaceFixtures'); +const { WAIVER, raceEnrollmentId } = require('./fictionalRaceTransitions'); +const owner = 'stage_race_owner'; const recipient = 'stage_race_recipient'; +describe('fictional race transactional service (test double, not native concurrency proof)', () => { + let docs; let db; let auth; let service; let now; let app; + beforeEach(() => { + ({ docs, db } = memoryDb()); now = 1700000000000; + auth = { getUser: jest.fn(async uid => ({ uid, emailVerified: true, disabled: false })) }; + app = { options: { projectId: f.ENV.GCLOUD_PROJECT }, firestore: () => db, auth: () => auth }; + service = createFictionalRaceService(app, () => now, f.ENV); + docs.set(`communityRaceEvents/${f.eventId}`, f.event(now)); + docs.set(`communityRacePolicies/${f.eventId}`, f.policy(now)); + for (const uid of [owner, recipient, 'stage_race_other']) { + docs.set(`communityRaceTargets/${uid}`, f.marker(uid, now)); + docs.set(`communityRacePermissions/${uid}`, f.permission(uid, now)); + } + }); + const run = (uid, request, atToken = null) => service.execute(uid, atToken || f.token(now), request); + const snapshot = () => JSON.stringify([...docs].sort(([a], [b]) => a.localeCompare(b))); + async function deny(uid, request, code, atToken = null) { + const before = snapshot(); + await expect(run(uid, request, atToken)).rejects.toMatchObject({ message: 'Fictional race action unavailable. Refresh or sign in again.', ...(code ? { code } : {}) }); + expect(snapshot()).toBe(before); + } + async function joined() { + const request = f.command(owner); const result = await run(owner, request); + return { request, result, entryId: result.entryId }; + } + const next = (action, entryId, revision = 1, extra = {}) => f.command(owner, action, { entryId, expectedEventRevision: 2, expectedEntryRevision: revision, ...extra }); + + test('signup, full simulated return and exact old retry are immutable; no resurrected entry', async () => { + const { request, result, entryId } = await joined(); + expect(result).toMatchObject({ simulationOnly: true, entryRevision: 1, eventRevision: 2 }); + const original = docs.get(`communityRaceEntries/${entryId}`); + const refund = next('simulate_refund', entryId); await run(owner, refund); + const current = docs.get(`communityRaceEntries/${entryId}`); + expect(current).toMatchObject({ revision: 2, status: 'cancelled', fictionalReturnedCents: 1000 }); + expect(current.originalWaiver).toEqual(original.originalWaiver); + expect(docs.get(`communityRaceEvents/${f.eventId}`).confirmedCount).toBe(0); + const before = snapshot(); + expect(await run(owner, request)).toEqual(result); await run(owner, refund); + expect(snapshot()).toBe(before); + expect([...docs.keys()].filter(path => path.startsWith(`communityRaceCommands/${owner}/requests/`))).toHaveLength(2); + expect([...docs.keys()].filter(path => path.startsWith('communityRaceAudit/'))).toHaveLength(2); + await deny(owner, f.command(owner, 'join_simulation', { expectedEventRevision: 3 })); + }); + test('recipient accepts one bib; original credit, original waiver and counts preserved', async () => { + const { entryId } = await joined(); const before = docs.get(`communityRaceEntries/${entryId}`); + const offered = await run(owner, next('offer_transfer', entryId, 1, { recipientUid: recipient })); + expect(offered.offerExpiresAtMs).toBe(now + 300000); + const accept = f.command(recipient, 'accept_transfer', { entryId, expectedEntryRevision: 2, expectedEventRevision: 2 }); + await run(recipient, accept); + const entry = docs.get(`communityRaceEntries/${entryId}`); + expect(entry).toMatchObject({ runnerUid: recipient, creditOwnerUid: owner, bibId: before.bibId, transferCount: 1 }); + expect(entry.originalWaiver).toEqual(before.originalWaiver); + expect(entry.recipientWaiver).toEqual({ uid: recipient, version: WAIVER, acceptedAtMs: now }); + expect(docs.get(`communityRaceEnrollment/${raceEnrollmentId(f.eventId, owner)}`).status).toBe('transferred'); + expect(docs.get(`communityRaceEnrollment/${raceEnrollmentId(f.eventId, recipient)}`).entryId).toBe(entryId); + expect(docs.get(`communityRaceEvents/${f.eventId}`).confirmedCount).toBe(1); + const immutable = snapshot(); await run(recipient, accept); expect(snapshot()).toBe(immutable); + await deny(owner, next('simulate_refund', entryId, 3)); + await run(recipient, f.command(recipient, 'simulate_refund', { entryId, expectedEventRevision: 2, expectedEntryRevision: 3 })); + expect(docs.get(`communityRaceEntries/${entryId}`)).toMatchObject({ creditOwnerUid: owner, fictionalReturnedCents: 1000, status: 'cancelled' }); + }); + test('revoking an expired offer allows cancellation but never acceptance of old intent', async () => { + const { entryId } = await joined(); await run(owner, next('offer_transfer', entryId, 1, { recipientUid: recipient })); + now += 300000; + await deny(recipient, f.command(recipient, 'accept_transfer', { entryId, expectedEventRevision: 2, expectedEntryRevision: 2 })); + await run(owner, next('revoke_transfer', entryId, 2)); await run(owner, next('simulate_refund', entryId, 3)); + expect(docs.get(`communityRaceEvents/${f.eventId}`).confirmedCount).toBe(0); + }); + test.each(['communityRacePermissions', 'communityRaceTargets'])('revocation or expiry denies saved replay: %s', async root => { + const { request } = await joined(); + const path = `${root}/${owner}`; const previous = docs.get(path); + docs.set(path, { ...previous, enabled: false }); await deny(owner, request, 'permission-denied'); + docs.set(path, { ...previous, expiresAtMs: now }); await deny(owner, request, 'permission-denied'); + expect([...docs.keys()].filter(path => path.startsWith('communityRaceAudit/'))).toHaveLength(1); + }); + test.each(['missing', 'unverified', 'disabled'])('native metadata denies caller before any writes: %s', async state => { + auth.getUser.mockImplementation(async uid => { + if (state === 'missing') throw Error('private synthetic provider response'); + return { uid, emailVerified: state !== 'unverified', disabled: state === 'disabled' }; + }); + await deny(owner, f.command(owner)); + }); + test('unmarked targets stop before native Auth lookup, even with browser admin role', async () => { + docs.delete(`communityRaceTargets/${owner}`); + await deny(owner, f.command(owner), 'permission-denied', { ...f.token(now), role: 'admin', admin: true }); + expect(auth.getUser).not.toHaveBeenCalled(); + }); + test.each([null, 'stage-tenant'])('unsupported Auth tenant denies before any reads: %s', async tenant => { + await deny(owner, f.command(owner), 'permission-denied', { ...f.token(now), firebase: { sign_in_provider: 'password', tenant } }); + expect(auth.getUser).not.toHaveBeenCalled(); + }); + test('unqualified or previously enrolled recipient cannot be offered a second place', async () => { + const { entryId } = await joined(); docs.delete(`communityRaceTargets/${recipient}`); auth.getUser.mockClear(); + await deny(owner, next('offer_transfer', entryId, 1, { recipientUid: recipient }), 'permission-denied'); + expect(auth.getUser).not.toHaveBeenCalled(); + docs.set(`communityRaceTargets/${recipient}`, f.marker(recipient, now)); + docs.set(`communityRaceEnrollment/${raceEnrollmentId(f.eventId, recipient)}`, { unrelated: true }); + await deny(owner, next('offer_transfer', entryId, 1, { recipientUid: recipient })); + }); + test.each(['policyVersion', 'simulationOnly', 'currency', 'fictionalAmountCents'])('malformed server price fails unchanged: %s', async key => { + const path = `communityRacePolicies/${f.eventId}`; + docs.set(path, { ...docs.get(path), [key]: key === 'fictionalAmountCents' ? 1.5 : 'invalid' }); + await deny(owner, f.command(owner)); + }); + test('slow Auth crosses exact permission or window deadline and cannot admit late signup', async () => { + const target = `communityRacePermissions/${owner}`; + docs.set(target, { ...docs.get(target), expiresAtMs: now + 1000 }); + auth.getUser.mockImplementation(async uid => { now += 1000; return { uid, emailVerified: true, disabled: false }; }); + await deny(owner, f.command(owner), 'permission-denied'); + docs.set(target, f.permission(owner, now)); + const policyPath = `communityRacePolicies/${f.eventId}`; + docs.set(policyPath, { ...f.policy(now), closesAtMs: now + 1000 }); + await deny(owner, f.command(owner)); + }); + test('slow last database read uses fresh time before first write', async () => { + const originalTransaction = db.runTransaction; + const closesAt = now + 1000; + docs.set(`communityRacePolicies/${f.eventId}`, { ...f.policy(now), closesAtMs: closesAt }); + db.runTransaction = fn => originalTransaction(tx => fn({ ...tx, get: async ref => { const result = await tx.get(ref); + if (ref.path.startsWith('communityRacePolicies/')) now = closesAt; return result; } })); + await deny(owner, f.command(owner)); + }); + test('changed request fingerprint, orphaned receipt or inconsistent projection is refused', async () => { + const { request, entryId } = await joined(); + const path = `communityRaceCommands/${owner}/requests/${request.requestId}`; + const original = docs.get(path); docs.set(path, { ...original, fingerprint: 'changed' }); + await deny(owner, request, 'already-exists'); docs.set(path, original); + docs.set(path, { ...original, result: { ...original.result, entryRevision: 999999 } }); await deny(owner, request); + docs.set(path, original); docs.delete(`communityRaceEntries/${entryId}`); await deny(owner, request); + }); + test('rate bound stops new commands, but saved replay is read-only', async () => { + const { request, entryId } = await joined(); docs.set(`communityRaceLimits/${owner}`, { window: Math.floor(now / 60000), count: 12 }); + const before = snapshot(); await run(owner, request); expect(snapshot()).toBe(before); + await deny(owner, next('simulate_refund', entryId), 'resource-exhausted'); + }); + test('changed fictional catalog amount cannot change an existing credit return', async () => { + const { entryId } = await joined(); + docs.set(`communityRacePolicies/${f.eventId}`, { ...f.policy(now), fictionalAmountCents: 9000 }); + await run(owner, next('simulate_refund', entryId)); + expect(docs.get(`communityRaceEntries/${entryId}`).fictionalReturnedCents).toBe(1000); + }); + test.each(['amountCents', 'expiresAtMs', 'currency', 'admin', 'paymentStatus', 'newRunner'])('request injection %s denies before native reads', async key => { + await deny(owner, { ...f.command(owner), [key]: 'untrusted' }, 'invalid-argument'); expect(auth.getUser).not.toHaveBeenCalled(); + }); + test.each([false, 'true', 1, null])('waiver acceptance must be exactly true: %s', async waiverAccepted => { + await deny(owner, { ...f.command(owner), waiverAccepted }, 'invalid-argument'); + }); + test('account spoof, outsider, stale revision, wrong enrollment index and dead token deny', async () => { + await deny(owner, f.command(recipient), 'permission-denied'); + const { entryId } = await joined(); + await deny('stage_race_other', f.command('stage_race_other', 'simulate_refund', { entryId, expectedEntryRevision: 1, expectedEventRevision: 2 }), 'permission-denied'); + await deny(owner, next('simulate_refund', entryId, 2)); + await deny(owner, next('simulate_refund', entryId), 'permission-denied', { ...f.token(now), email_verified: false }); + docs.delete(`communityRaceEnrollment/${raceEnrollmentId(f.eventId, owner)}`); await deny(owner, next('simulate_refund', entryId)); + }); + test.each([ + { GCLOUD_PROJECT: 'run-mprc-production', COMMERCE_ENABLED: 'false' }, + { GCLOUD_PROJECT: 'run-mprc-staging', COMMERCE_ENABLED: 'true' }, + { GCLOUD_PROJECT: 'demo-mprc-local', FIRESTORE_EMULATOR_HOST: 'remote:8080', FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' }, + ])('invalid environment cannot acquire SDK services', env => { + const forbidden = { options: {}, firestore: jest.fn(), auth: jest.fn() }; + expect(() => createFictionalRaceService(forbidden, () => now, env)).toThrow('Fictional race action unavailable.'); + expect(forbidden.firestore).not.toHaveBeenCalled(); expect(forbidden.auth).not.toHaveBeenCalled(); + }); + test('mismatched native app and hostile request getters fail fixed and private', async () => { + expect(() => createFictionalRaceService({ ...app, options: { projectId: 'production' } }, () => now, f.ENV)).toThrow('Fictional race action unavailable.'); + const getter = jest.fn(() => { throw Error('private synthetic exception'); }); + const input = f.command(owner); Object.defineProperty(input, 'action', { get: getter, enumerable: true }); + await deny(owner, input, 'invalid-argument'); expect(getter).not.toHaveBeenCalled(); + expect(raceEnvironment(f.ENV)).toBe('demo-mprc-local'); + expect(parseRaceCommand(f.command(owner), owner).waiverVersion).toBe(WAIVER); + }); +}); diff --git a/functions/fictionalRaceTransitions.js b/functions/fictionalRaceTransitions.js index e0aae64..020087d 100644 --- a/functions/fictionalRaceTransitions.js +++ b/functions/fictionalRaceTransitions.js @@ -5,6 +5,7 @@ // A future trusted boundary must supply native identity/time, disposable markers, // capabilities and atomic record + immutable command-receipt persistence. const { parseStrictObject } = require('./requestValidation'); +const { createHash } = require('node:crypto'); const POLICY = 'fictional-race-v1-not-money'; const WAIVER = 'fictional-race-waiver-v1'; const ID = /^stage_race_[A-Za-z0-9_-]{1,64}$/; @@ -112,4 +113,37 @@ function reduceFictionalRace(eventInput, entryInput, commandInput, callerUid, no } catch { throw new Error(MESSAGE); } } -module.exports = { POLICY, WAIVER, reduceFictionalRace }; +function readFictionalRaceRecords(event, entry, now) { + try { return records(event, entry, now); } catch { throw new Error(MESSAGE); } +} + +function raceEnrollmentId(eventId, uid) { + check(id(eventId) && id(uid)); + return `stage_race_${createHash('sha256').update(JSON.stringify([eventId, uid])).digest('hex')}`; +} + +function createFictionalEntry(eventInput, policyInput, uid, waiverVersion, now) { + try { + const policy = strict(policyInput, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', + 'revision', 'fictionalAmountCents', 'currency', 'waiverVersion', 'opensAtMs', 'closesAtMs']); + check(policy.schemaVersion === 1 && policy.simulationOnly === true && policy.policyVersion === POLICY + && id(policy.eventId) && id(uid) && integer(policy.revision, 999999) && policy.revision > 0 + && integer(policy.fictionalAmountCents, 10000) && policy.fictionalAmountCents > 0 + && policy.currency === 'USD' && policy.waiverVersion === WAIVER && waiverVersion === WAIVER + && time(now) && time(policy.opensAtMs) && time(policy.closesAtMs) + && policy.opensAtMs < policy.closesAtMs && policy.closesAtMs - policy.opensAtMs <= 86400000 + && policy.opensAtMs <= now && now < policy.closesAtMs); + const entryId = raceEnrollmentId(policy.eventId, uid); + const entry = { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventId: policy.eventId, + entryId, revision: 1, bibId: `stage_race_${createHash('sha256').update(`bib:${entryId}`).digest('hex')}`, + runnerUid: uid, creditOwnerUid: uid, fictionalAmountCents: policy.fictionalAmountCents, + fictionalReturnedCents: 0, currency: policy.currency, status: 'confirmed', transferCount: 0, + originalWaiver: { uid, version: WAIVER, acceptedAtMs: now }, recipientWaiver: null, offer: null }; + const { event } = records(eventInput, entry, now); + check(event.status === 'open' && event.confirmedCount < event.capacity && policy.closesAtMs <= event.startAtMs); + const nextEvent = { ...event, revision: event.revision + 1, confirmedCount: event.confirmedCount + 1 }; + return Object.freeze({ simulationOnly: true, ...readFictionalRaceRecords(nextEvent, entry, now) }); + } catch { throw new Error(MESSAGE); } +} + +module.exports = { POLICY, WAIVER, reduceFictionalRace, readFictionalRaceRecords, raceEnrollmentId, createFictionalEntry }; diff --git a/functions/testSupport/fictionalRaceFixtures.js b/functions/testSupport/fictionalRaceFixtures.js new file mode 100644 index 0000000..544cbd8 --- /dev/null +++ b/functions/testSupport/fictionalRaceFixtures.js @@ -0,0 +1,25 @@ +const { randomUUID } = require('node:crypto'); +const { POLICY, WAIVER, raceEnrollmentId } = require('../fictionalRaceTransitions'); +const ROOTS = ['communityRaceEvents', 'communityRacePolicies', 'communityRaceEntries', 'communityRaceEnrollment', + 'communityRaceTargets', 'communityRacePermissions', 'communityRaceCommands', 'communityRaceAudit', 'communityRaceLimits']; +const ENV = { GCLOUD_PROJECT: 'demo-mprc-local', FIRESTORE_EMULATOR_HOST: '127.0.0.1:8080', FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' }; +const eventId = 'stage_race_fixture'; +function event(now, capacity = 1) { + return { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventId, revision: 1, + startAtMs: now + 3600000, status: 'open', capacity, confirmedCount: 0 }; +} +function policy(now) { + return { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventId, revision: 1, + fictionalAmountCents: 1000, currency: 'USD', waiverVersion: WAIVER, opensAtMs: now - 1000, closesAtMs: now + 1800000 }; +} +function marker(uid, now) { + return { schemaVersion: 1, simulationOnly: true, enabled: true, uid, revision: 1, expiresAtMs: now + 7200000 }; +} +const permission = (uid, now) => ({ ...marker(uid, now), manageOwnEntry: true }); +const token = now => ({ email_verified: true, auth_time: Math.floor(now / 1000), firebase: { sign_in_provider: 'password' } }); +function command(uid, action = 'join_simulation', extra = {}) { + return { accountUid: uid, action, eventId, entryId: raceEnrollmentId(eventId, uid), + requestId: randomUUID(), expectedEventRevision: 1, expectedEntryRevision: 0, + ...(action === 'join_simulation' || action === 'accept_transfer' ? { waiverAccepted: true, waiverVersion: WAIVER } : {}), ...extra }; +} +module.exports = { ROOTS, ENV, eventId, event, policy, marker, permission, token, command }; diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index 6e31ebf..ff89262 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -362,6 +362,25 @@ describe('orders collection', () => { }); describe('server-only operational collections', () => { + test.each([ + 'communityRaceEvents/stage-race', 'communityRacePolicies/stage-race', 'communityRaceEntries/stage-entry', + 'communityRaceEnrollment/stage-enrollment', 'communityRaceTargets/runner-a', 'communityRacePermissions/runner-a', + 'communityRaceCommands/runner-a/requests/test', 'communityRaceAudit/stage-audit', 'communityRaceLimits/runner-a', + ])('fictional race path %s and children deny every browser operation, including admin', async path => { + await seed(path, { schemaVersion: 1, simulationOnly: true }); + await seed(`${path}/private/test`, { schemaVersion: 1 }); + for (const auth of [undefined, { uid: 'runner-a', role: 'member', emailVerified: true }, { uid: 'admin-a', role: 'admin', emailVerified: true }]) { + const client = await db(auth); + await assertFails(client.doc(path).get()); + await assertFails(client.doc(path).parent.get()); + await assertFails(client.doc(path).set({ schemaVersion: 1 })); + await assertFails(client.doc(`${path}-new`).set({ schemaVersion: 1 })); + await assertFails(client.doc(path).update({ simulationOnly: false })); + await assertFails(client.doc(path).delete()); + await assertFails(client.doc(`${path}/private/test`).get()); + await assertFails(client.doc(`${path}/private/test`).set({ schemaVersion: 1 })); + } + }); test.each([ 'communityMemberships/runner-a', 'communityEvents/test', 'communityEvents/test/registrations/runner-a', 'communityConfig/events', From 0dc6fd6797ccf31cdc4666907b9b377f59d8d5a1 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 13 Oct 2026 10:27:11 -0700 Subject: [PATCH 2/2] fix: preserve owner recovery when a transfer recipient loses access --- OPERATIONS_RUNBOOK.md | 2 +- docs/officers/STAGING_COMMUNITY.md | 2 +- docs/officers/SYSTEM_MAPS.md | 4 ++-- functions/fictionalRaceService.emulator.test.js | 13 +++++++++++++ functions/fictionalRaceService.js | 15 +++++++++------ functions/fictionalRaceService.test.js | 16 ++++++++++++++++ 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index fee48c1..a81b7bd 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,6 +1,6 @@ # MPRC Platform Operations Runbook -**#729 fictional persistence — SOURCE / DEMO ONLY:** no current service release is needed: new files remain outside the deployed entrypoint/archive. Local tests use only explicitly selected loopback Auth/Firestore demo SDKs; caller context is synthetic, not genuine native token/App Check proof. Nine server-only namespaces hold fictional event/policy/entry/enrollment/target/permission/command/audit/rate state. A specialist must supply current expiring markers and own-entry permissions for all involved verified/enabled disposable accounts; receipts never override revocation, expiry, missing indexes or corrupt state. No member/officer edits a record, enters real payment data or uses a legacy substitute/refund control. Keep real policy/provider money and production untouched. Future callable/client integration and exact hosted security/UI/cleanup evidence remain unavailable. Local cleanup removes only task-owned demo Auth UIDs and test namespaces, verifies absence and shuts down emulators before removing owned idle logs; no retention/backfill/hosted cleanup decision is made here. +**#729 fictional persistence — SOURCE / DEMO ONLY:** no current service release is needed: new files remain outside the deployed entrypoint/archive. Local tests use only explicitly selected loopback Auth/Firestore demo SDKs; caller context is synthetic, not genuine native token/App Check proof. Nine server-only namespaces hold fictional event/policy/entry/enrollment/target/permission/command/audit/rate state. A specialist supplies current expiring markers and own-entry permissions for actors and relevant verified/enabled disposable mutation targets; receipts never override caller revocation/expiry, missing indexes or corrupt state. Read-only acknowledgement and owner withdrawal do not require continued recipient access. No member/officer edits a record, enters real payment data or uses a legacy substitute/refund control. Keep real policy/provider money and production untouched. Future callable/client integration and exact hosted security/UI/cleanup evidence remain unavailable. Local cleanup removes only task-owned demo Auth UIDs and test namespaces, verifies absence and shuts down emulators before removing owned idle logs; no retention/backfill/hosted cleanup decision is made here. **#727 fictional race foundation — NOT AVAILABLE YET:** pure helper/tests only; it is intentionally excluded from callable/client imports and the community runtime archive. Do not deploy unchanged Functions or use legacy real-payment/runner-substitution screens for this test. Future operation needs current native identity/authority, expiring owner-marked disposable targets, strict fictional configuration, atomic event/entry/immutable receipt persistence, bounded retry/rate/audit and actual backend-first staging/UI/cleanup proof. Source simulation has no provider/account state to undo. Current #724 staging remains unchanged; real club rules, financial/provider work and production are separate. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 09f5dd6..d52c36c 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -31,7 +31,7 @@ **#729 update — SOURCE / DEMO ONLY:** saved actions, fictional signup, refund and transfer now have a transactional source implementation. Real local emulator tests prove final-seat, duplicate cancellation, competing transfer/revoke/refund and lost-acknowledgement recovery; browser-admin data access is denied by the unchanged Rules. This does not supply a usable screen, deployed service or native token/App Check proof. The specialist must next connect the reviewed platform boundary and staging screen and verify actual deployment, disposable sign-in, failures/retries and cleanup. No officer terminal or record edit is needed for that review. Auth metadata can change outside the database transaction, so these tests do not promise instant cross-service revocation. -The test policy permits one enrollment per disposable account/event. Cancelled or transferred enrollments cannot rejoin. A recipient already enrolled cannot accept another bib. These are fictional test limits, not the club's real race policy. Server-configured fictional amounts/windows and a fixed test waiver supply the signup; no checkout or money is created. All previous **NOT AVAILABLE YET**, approval, stop and specialist-only requirements below still apply. +The test policy permits one enrollment per disposable account/event. Cancelled or transferred enrollments cannot rejoin. A recipient already enrolled cannot accept another bib. A recipient losing access does not block the owner's read-only saved acknowledgement or withdrawal of their own offer; current owner authority and stored entry/index state still apply. These are fictional test limits, not the club's real race policy. Server-configured fictional amounts/windows and a fixed test waiver supply the signup; no checkout or money is created. All previous **NOT AVAILABLE YET**, approval, stop and specialist-only requirements below still apply. **Purpose:** prepare a safe demonstration without moving money. **Approver/escalation:** Dave, sole staging owner; no backup is appointed. **Prerequisites:** the later reviewed server transaction, verified disposable accounts/markers, narrow permissions, genuine Auth/App Check and actual staging screen/deployment/cleanup proof. None is supplied by this source foundation. There is no usable race refund or transfer button from #727. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index ae4af35..93afa8e 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -8,7 +8,7 @@ These diagrams show where pages, information, and deployments go. Each diagram i flowchart LR Test["Synthetic caller context; no live screen"] --> Service["Exact demo SDK / closed staging source guard"] Service --> Grant["Current permission, disposable markers and indexes"] - Grant --> Auth["Up to 3 read-only Auth metadata checks"] + Grant --> Auth["Caller plus relevant targets; up to 3 Auth reads"] Auth --> Tx["Fresh time; transaction decides signup/refund/transfer"] Tx --> State["Entry, unique enrollment and changed count"] Tx --> Receipt["Immutable command, audit and rate"] @@ -16,7 +16,7 @@ flowchart LR Future["Native caller/App Check + staging screen NOT AVAILABLE YET"] -.-> Service ``` -Text alternative: source/demo tests check current disposable authority and bounded Auth metadata before atomically changing fictional entry/enrollment/count and receipt/audit/rate state, while saved retries recheck authority without writes and actual native caller/App Check, staging screen, deployment and cleanup remain future gates; Auth metadata is not locked by the Firestore transaction. +Text alternative: source/demo tests check current disposable authority and relevant bounded Auth metadata before atomically changing fictional entry/enrollment/count and receipt/audit/rate state, while read-only saved acknowledgements recheck current caller authority/state without requiring recipient access, owner withdrawal survives recipient revocation, and actual native caller/App Check, staging screen, deployment and cleanup remain future gates; Auth metadata is not locked by the Firestore transaction. ## Member status freshness — #724 staging verified diff --git a/functions/fictionalRaceService.emulator.test.js b/functions/fictionalRaceService.emulator.test.js index 1ff95fa..7d761ca 100644 --- a/functions/fictionalRaceService.emulator.test.js +++ b/functions/fictionalRaceService.emulator.test.js @@ -160,4 +160,17 @@ describeDemo('fictional race native Auth metadata and Firestore transactions — expect((await db.collection('communityRaceEntries').get()).size).toBe(1); expect((await db.collection('communityRaceAudit').get()).size).toBe(1); }, 45000); + test('recipient revocation cannot lock an original owner into an offer or block old acknowledgement', async () => { + const uid = await member(); const recipient = await member(); const { entryId } = await joined(uid); + const offer = change(uid, entryId, 'offer_transfer', 1, { recipientUid: recipient }); + const result = await run(uid, offer); + await auth.updateUser(recipient, { disabled: true }); + await db.doc(`communityRaceTargets/${recipient}`).update({ enabled: false }); + const before = await snapshots(); expect(await run(uid, offer)).toEqual(result); expect(await snapshots()).toEqual(before); + await expect(run(recipient, change(recipient, entryId, 'accept_transfer', 2))).rejects.toHaveProperty('code', 'permission-denied'); + await run(uid, change(uid, entryId, 'revoke_transfer', 2)); + await run(uid, change(uid, entryId, 'simulate_refund', 3)); + expect((await db.doc(`communityRaceEvents/${f.eventId}`).get()).data().confirmedCount).toBe(0); + expect((await db.doc(`communityRaceEntries/${entryId}`).get()).data().fictionalReturnedCents).toBe(1000); + }, 45000); }); diff --git a/functions/fictionalRaceService.js b/functions/fictionalRaceService.js index f824df6..d077917 100644 --- a/functions/fictionalRaceService.js +++ b/functions/fictionalRaceService.js @@ -124,16 +124,19 @@ function createFictionalRaceService(app, clock = Date.now, env = process.env) { check(joining || before !== null); check(!commandDoc.exists || before !== null); // Never acknowledge an orphaned successful receipt. if (before) check([before.entry.runnerUid, before.entry.creditOwnerUid, before.entry.offer?.recipientUid].includes(uid), 'permission-denied'); - // Mark each involved UID before a bounded native Auth lookup; no email matching or roster query. - const targets = [...new Set([uid, before?.entry.runnerUid, before?.entry.creditOwnerUid, - before?.entry.offer?.recipientUid, request.recipientUid].filter(Boolean))]; - check(targets.length <= 3); + // Read-only confirmation needs current caller authority, never renewed + // recipient access. Withdrawing one's own offer must also survive a lost + // recipient account. Current entry/index state is still checked below. + const targets = commandDoc.exists ? [uid] : [...new Set([uid, before?.entry.runnerUid, before?.entry.creditOwnerUid, + ...(request.action === 'revoke_transfer' ? [] : [before?.entry.offer?.recipientUid]), request.recipientUid].filter(Boolean))]; + const indexUids = [...new Set([uid, before?.entry.runnerUid, before?.entry.creditOwnerUid, request.recipientUid].filter(Boolean))]; + check(targets.length <= 3 && indexUids.length <= 3); const markers = await tx.getAll(...targets.map(target => db.doc(`communityRaceTargets/${target}`))); markers.forEach((doc, n) => grant(doc.data(), targets[n], began)); const users = await Promise.all(targets.map(target => auth.getUser(target))); users.forEach((user, n) => check(user.uid === targets[n] && user.emailVerified === true && user.disabled === false, 'permission-denied')); - const indexDocs = await tx.getAll(...targets.map(target => indexRef(request.eventId, target))); - const indexByUid = new Map(targets.map((target, n) => [target, indexDocs[n]])); + const indexDocs = await tx.getAll(...indexUids.map(target => indexRef(request.eventId, target))); + const indexByUid = new Map(indexUids.map((target, n) => [target, indexDocs[n]])); if (before) { const entry = before.entry; enrollment(indexByUid.get(entry.runnerUid).data(), request.eventId, entry.runnerUid, entry.entryId, entry.status); diff --git a/functions/fictionalRaceService.test.js b/functions/fictionalRaceService.test.js index 06cf795..01892b1 100644 --- a/functions/fictionalRaceService.test.js +++ b/functions/fictionalRaceService.test.js @@ -71,6 +71,22 @@ describe('fictional race transactional service (test double, not native concurre await run(owner, next('revoke_transfer', entryId, 2)); await run(owner, next('simulate_refund', entryId, 3)); expect(docs.get(`communityRaceEvents/${f.eventId}`).confirmedCount).toBe(0); }); + test('recipient losing access cannot block read-only offer acknowledgement or owner withdrawal', async () => { + const { entryId } = await joined(); + const offer = next('offer_transfer', entryId, 1, { recipientUid: recipient }); + const result = await run(owner, offer); const before = snapshot(); + docs.delete(`communityRaceTargets/${recipient}`); + auth.getUser.mockImplementation(async uid => { + if (uid === recipient) throw Error('synthetic recipient removed'); + return { uid, emailVerified: true, disabled: false }; + }); + const qualified = snapshot(); + expect(await run(owner, offer)).toEqual(result); expect(snapshot()).toBe(qualified); + await run(owner, next('revoke_transfer', entryId, 2)); + await run(owner, next('simulate_refund', entryId, 3)); + expect(docs.get(`communityRaceEvents/${f.eventId}`).confirmedCount).toBe(0); + expect(before).not.toBe(qualified); + }); test.each(['communityRacePermissions', 'communityRaceTargets'])('revocation or expiry denies saved replay: %s', async root => { const { request } = await joined(); const path = `${root}/${owner}`; const previous = docs.get(path);