diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 263cbe1..5949f0b 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**RACE-SIM-001C1 / #732 — SOURCE / DEMO ONLY:** child of open #731. Exact race-prefixed native-context routing, explicit same-project SDK app, current disposable authority/metadata, closed twenty-race manifest and consistent private read-only status. No broad query/Auth roster, other UID/contact/waiver/history/count/capacity/financial total projection; exact incoming offer only and uniform missing/unrelated denial. Fresh post-read time; original credit/index/retry and lost-recipient owner recovery preserved. Deterministic archive includes all necessary relative imports. Officer impact: race screens remain NOT AVAILABLE YET. Officer documentation: staging guide, entrypoints, Events-Shop-Members, maps and root status. Deployment evidence: none from this child; source/double/native-wrapper tests with mocked verifiers and actual demo SDK proof are not genuine hosted identity/App Check/deployment/UI. No production/payment/service/IAM/dependency/workflow/Rules-allowance/migration change. #731 retains typed client/screen, exact backend-first release, genuine security/UI/mobile/failure proof and exact owned hosted cleanup; the full backend goal stays open. + **COMMUNITY-DUES-002 / #719 — STAGING VERIFIED 2026-10-12:** one immutable fictional receipt links deliberately to one owner-marked disposable pending account and activates its unchanged current test term. Separate v3 capability, recent native TOTP/App Check, authoritative verified/enabled Auth, exact guards, atomic uniqueness and permission-first replay; no real policy/data/money, Auth claim, IAM/service/schedule/dependency/production change. Officer impact: supervised screen-only linking and cancellation/original-expiry verification. Officer documentation: STAGING_COMMUNITY, entrypoints, ACCESS_CONTINUITY, EVENTS_SHOP_MEMBERS, glossary and maps. Deployment evidence: PR720 merged b6a8fd57d9add466da73df1d33b69e131e3fecbd after six exact PR/main jobs and every step passed. Native communityActions version 4 and staging Hosting match that source; the downloaded 91,793-byte archive matches SHA-256 47f11b8bec7f5ae58281c9029a015e9cc751a04e5ad3eafe3234441fa3a963db. Forty-four hosted cases (32 denial cases), atomic races, immutable retries, actual officer/member UI, offline retry, phone-size DOM layout, cancellation and natural expiry passed. Six disposable users and 47 exact owned documents were removed; native absence and late profiles were verified. Both capabilities were disabled first; browser sign-out, Apps-on-device OFF, normal networking/viewport, closed tab/receiver and memory disposal complete the test handoff. Profiles v2/v1/2843388, App Check/MFA and the six-permission min0/max2 runtime remain unchanged. The release command reported its existing generic post-deploy failure; independent native checks qualified the result without repeating deployment or bypassing a guard. Reporting cause remains unqualified. Production/runmprc.com and real payments were untouched. #716 remains historical recording-only proof; broad #115/#81 and real launch are separate. **COMMUNITY-DUES-001 / #716 — staging verified 2026-10-12:** owner-selected fictional external dues on the existing isolated staging service. Separate exact v2 capability, server-supplied fake amount/method/short term, confirmation UUID/global duplicate fence, immutable command/audit and unlinked test authority; twenty minimal receipts per page. No real policy, account association/access, claims, provider/payment, migration, IAM expansion, service/schedule/dependency or production change. Officer impact: supervised fictional recording only. Officer documentation: STAGING_COMMUNITY, entrypoints, Events-Shop-Members, glossary and map. Deployment evidence: PR717 merged d212fab; all six exact PR/main jobs and every step passed. Native Function v3/archive and staging Hosting matched; twenty-two hosted denials, immutable retry/race, twenty-plus-one pages, actual UI/offline/390×844/revocation and 51-document/two-account cleanup passed. Release-command reporting caveat and supervised fresh-fixture handoff are recorded in STAGING_COMMUNITY; production/runmprc.com and real payments were untouched. Broad #115/#81 and real club launch remain separate; native #114's closed status is not readiness proof. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a4b5f91..6b03c6a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,7 @@ # MPRC Secure Commerce Implementation Plan +**#731 review split / #732 — SOURCE / DEMO ONLY:** first review exact callable routing, explicit SDK realm, private bounded read-only status and deterministic archive independently. Current source/double, native-wrapper-with-mocked-verifiers and actual demo transaction/metadata tests qualify only that child. Then #731 adds strict client parsing, context fences and minimal fictional member screens; exact green-main backend archive must deploy/verify before staging Hosting. Genuine native Auth/App Check denials, actual UI/retry/expiry/recipient-loss/mobile proof and exact owned hosted cleanup are still required. No production, real payments, new service/dependency/workflow/IAM, backfill or broader goal completion is implied; earlier #727/#729 nonintegration notes are historical. + **#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. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 560a97f..c49ced8 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,5 +1,7 @@ # MPRC Website: Officer Start Here +**Fictional race boundary — #732, SOURCE / DEMO ONLY:** safe backend routing and private test status are prepared, but there is no new deployed race screen yet. Use the [source review](./docs/officers/STAGING_COMMUNITY.md#fictional-race-boundary--732-source-only) to ask for proof. #731 still needs actual staging release, disposable screens and cleanup. Do not use legacy refund/substitute controls or real member/payment details. + **Member status — #724, STAGING VERIFIED 2026-10-13:** the member page separates event availability from stored signup history and hides old status after a failed read. Actual screens, exact retry, natural test-term expiry and disposable cleanup passed. Use the [screen-only review](./docs/officers/STAGING_COMMUNITY.md#current-member-status--724-staging-verified) with fresh specialist-supplied fictional fixtures. Production and real payments remain unavailable. **Fictional account linking — #719, STAGING VERIFIED 2026-10-12:** a separate staging permission links one fictional receipt to a specialist-marked disposable pending account. Test access ends at the original receipt term, including after cancellation; no Auth role or money changes. Use the [screen-only guide](./docs/officers/STAGING_COMMUNITY.md#link-a-fictional-receipt--719) only with fresh specialist-supplied private fixtures. Native Function/Hosting, actual screens/offline/retry/race/cancel/expiry/revocation and exact cleanup passed. Old accounts/records are removed; production and real club rules remain unavailable. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index a81b7bd..6db9ba0 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**#732 source boundary — NOT DEPLOYED:** race routes/status are now connected in source and included in the prepared archive, not the existing staging v5 runtime or website. `communityConfig/fictionalRaces` must be an exact fictional manifest with no more than twenty IDs; missing/corrupt configuration denies rather than discovering entries. Current caller marker/own-entry permission and verified/enabled Auth are required for status too. Own history and privately supplied named offers only; no officer queries a roster, edits records or enters real details. Status uses consistent reads and fresh time, adds no write/poll or scheduled cleanup. #731's specialist must supply fixtures, exact green reviewed release, genuine security/UI proof and identity/update-time-fenced cleanup before the screen becomes available. Source rollback is a reviewed revert; there is no cloud change from this child to undo. Keep the existing exact staging/rollback proof and six-permission/min0/max2/256MB/30s scope. Historical #727/#729 archive-exclusion notes describe older milestones; production and real payments remain untouched. + **#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/SECURITY.md b/SECURITY.md index 5e03457..4b9551b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # MPRC Security, Privacy, and Risk Plan +**#732 race routing/status — SOURCE / DEMO ONLY:** clone bounded closed input before inspecting its action; only six exact race-prefixed routes enter the existing native callable wrapper. Platform-verified UID/claims and App Check are required, never browser flags. Status also requires current exact expiring marker/own-entry permission, default-tenant recent verified password context and enabled/verified native caller metadata. A consistent read-only transaction reads at most twenty configured races, caller indexes and necessary exact entry/index records, not a query/roster or broad Auth lookup. Only own state and an exactly named offer to the caller are projected; no other UID/contact/waiver/count/capacity/total is returned. Missing/unrelated incoming IDs share one fixed denial. Fresh post-read time prevents slow reads extending access/availability. SDK/config/state errors are redacted; status writes nothing. The archive now includes these source helpers, with no Rules allowance or runtime permission expansion. Historical #727/#729 integration limits are superseded in source only: native HTTP tests mock verifiers, demo caller context remains synthetic, no deployed native token/App Check/UI qualification exists yet, and Auth metadata is still not locked by a Firestore transaction. + **#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. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 82c1def..88270ee 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,5 +1,7 @@ # Stripe, Race Registration, and Merchandise Design +**#732 boundary — SOURCE / DEMO ONLY:** exact staging race RPC routing and minimal own-entry status now exist in source, without any Checkout, PaymentIntent, Refund, provider state or real-money action. Simulated credits are explicitly fictional integer cents. No client price/deadline/recipient profile or finance-role bypass; original credit/waiver/bib and immutable retry behavior remain #729's rules. Native HTTP tests use verifier doubles; real local Auth metadata/Firestore tests are not hosted identity/App Check proof. #731 still owns client and genuine hosted acceptance. This child does not deploy, establish real club rules, replace finance permission/recent MFA/provider reconciliation, or close real refund/transfer launch blockers. Earlier #727/#729 nonintegration statements are historical milestone boundaries. + **#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. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 8971e53..c9ca8a6 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**#732 fictional race boundary — SOURCE / DEMO ONLY:** #731 is split for review: this child connects exact race-prefixed routes to the existing callable source and adds read-only `race_status`. Firebase supplies verified caller/App Check context; recent default-tenant password claims, live expiring disposable marker/own-entry permission and current native caller metadata remain mandatory. The explicitly selected app/project has no production fallback. A closed `communityConfig/fictionalRaces` manifest lists at most twenty exact event IDs. One read-only transaction projects validated event policy, own enrollment/entry and only an exactly named incoming offer. It returns no other UID/contact/waiver/history, capacity/count or financial total; missing/unrelated offer IDs deny identically. Fresh time after reads fences access/window/offer expiry; owner status/revoke survives recipient loss. Additive configuration, unchanged browser denial and complete deterministic archive only; no migration, new service/dependency/workflow/IAM or cloud deployment. Historical #727/#729 paragraphs describe their source milestones, not the new connected source. Client screens, genuine hosted security, exact backend-first deployment and cleanup remain #731 gates; #724's deployed service/website are unchanged by this child. + **#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. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 39d43b5..ef29841 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1,5 +1,7 @@ # Events, Shop, Members, and Money +**#732 race boundary — SOURCE / DEMO ONLY:** [read the short handoff](./STAGING_COMMUNITY.md#fictional-race-boundary--732-source-only) before any review. Backend source now includes exact fictional race actions and own-entry status, but no new deployed screen or genuine hosted proof. #731 must finish that release and cleanup. No real refund, payment, policy, runner directory or record-edit procedure is available. Earlier #727/#729 paragraphs are historical milestone limits. + **#719 fictional linking — STAGING VERIFIED 2026-10-12:** the [supervised screen procedure](./STAGING_COMMUNITY.md#link-a-fictional-receipt--719) deliberately connects an immutable fictional receipt to one specialist-marked disposable pending account. Real screens verified activation, cancellation through the unchanged original term and inactive status after natural expiry. Old test fixtures are removed; ask the specialist for fresh private setup. No real policy/payment/claim/renewal/refund/production launch is supplied. Stop on an uncertain ID/revision or changed term and contact Dave privately. **Current status:** live commerce is **NOT AVAILABLE YET**. diff --git a/docs/officers/README.md b/docs/officers/README.md index 8f23221..72e5031 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,5 +1,7 @@ # Officer Website Handbook +[#732's fictional race backend review](./STAGING_COMMUNITY.md#fictional-race-boundary--732-source-only) is SOURCE / DEMO ONLY. No new race screen or cloud release is supplied by that child. #731 retains genuine staging security, screen, release and cleanup requirements; only the specialist supplies fixtures/provider proof. Production and real payments remain unavailable. + [#724 member status](./STAGING_COMMUNITY.md#current-member-status--724-staging-verified) passed native staging release, actual availability/cancellation/failure/retry screens, natural test-term expiry and exact owned cleanup on 2026-10-13. Fresh specialist-supplied fictional fixtures are required. No production or real payment behavior is included. [#719 fictional account linking](./STAGING_COMMUNITY.md#link-a-fictional-receipt--719) passed native staging release, actual officer/member screens, offline retry, races, cancellation/original expiry and exact cleanup on 2026-10-12. A separate permission and fresh specialist-marked disposable pending account are required; all old fixtures were removed. No real membership, payment or production release is available. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index d52c36c..c60f559 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -27,6 +27,20 @@ ## What the actions mean +## Fictional race boundary — #732 source only + +**Status:** SOURCE / DEMO ONLY, not deployed. #732 separates the backend review from #731's future screen and hosted acceptance. The source now routes exact fictional race actions through the existing authenticated service and prepares its complete release archive. A new private status read lists only configured fake races, the caller's own entry and an incoming offer named by its exact privately supplied test ID. It reveals no other account ID, contact detail, waiver, runner list, count, capacity or money total. It changes no record. Historical #727/#729 nonintegration statements below describe their earlier source milestones. + +**Purpose:** review source proof without mistaking it for a usable race feature. **Approver/escalation:** Dave, sole staging owner; no backup is appointed. **Prerequisites:** the specialist supplies #732's exact reviewed source/check evidence and the open #731 follow-on. A test account or payment detail is not needed for this source review. + +1. Ask Dave/platform specialist for the exact #732 source and test evidence. +2. Check that source/double tests, native-wrapper tests with mocked verifiers and real local demo tests are named separately. +3. Confirm that no new Function, website or production release is claimed from this child. +4. Ask for #731's dated native security, backend-first release, real screen and cleanup proof before trying any race control. +5. Until that proof exists, treat fictional race screens as **NOT AVAILABLE YET**. + +**Expected result/success proof:** exact source routing, private bounded read-only status, current disposable permission/identity checks, safe retries and real demo transaction/metadata behavior are checked. Missing/unrelated incoming IDs deny alike; revoked callers cannot read status. An owner can still withdraw an offer if the recipient loses access. Local tests do not prove native hosted token/App Check verification. **Stop:** production/real account/payment; claim that source or green CI alone means a screen is live; request for secrets or record edits; unexpected broader account access. **Undo:** stop the review; Dave/specialist requests a reviewed source revert if needed. No cloud state changed from this child. **Specialist-only work remaining:** exact native deployment/readback, disposable setup/permissions, genuine hosted tests, screen review, fenced cleanup and rollback. Officers use the evidence links, not a terminal or database editor. + ## 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. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 93afa8e..5edff19 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -2,6 +2,21 @@ These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. +## Fictional race boundary — #732 source and demo only + +```mermaid +flowchart LR + Wrapper["Existing native Auth/App Check wrapper; source connected"] --> Scope["Exact race routes and selected staging/demo app"] + Scope --> Caller["Current disposable caller permission, marker and Auth metadata"] + Caller --> Read["Read-only transaction; up to 20 configured races"] + Read --> Own["Own entry/history or exact named incoming offer; no other UID/count/roster"] + Read --> Time["Fresh time fences access, signup and offers"] + Scope --> Mutate["Existing atomic fictional transitions and immutable saved retries"] + Future["#731: actual staging security, screen, release and cleanup NOT AVAILABLE YET"] -.-> Wrapper +``` + +Text alternative: connected source uses the existing platform wrapper and current disposable caller authority to read only bounded own or exactly named offer status with fresh time, while atomic fictional mutations stay server-controlled and genuine hosted security, screen, deployment and cleanup remain #731's separate gates; no production or real payment changes. + ## Fictional race transactions — #729 source and demo only ```mermaid diff --git a/functions/communityActions.appCheck.test.js b/functions/communityActions.appCheck.test.js index 64f7277..a648a69 100644 --- a/functions/communityActions.appCheck.test.js +++ b/functions/communityActions.appCheck.test.js @@ -2,13 +2,17 @@ const { EventEmitter } = require('node:events'); jest.mock('firebase-admin/app', () => ({ getApp: () => ({ name: 'synthetic' }) })); jest.mock('firebase-admin/auth', () => ({ getAuth: () => ({ verifyIdToken: jest.fn().mockImplementation(async (token) => { if (token === 'synthetic-invalid-auth') throw new Error('synthetic denial'); + if (token.startsWith('synthetic-race-')) return { uid: token === 'synthetic-race-other' ? 'stage_race_other' : 'stage_race_native', + email_verified: token !== 'synthetic-race-unverified', auth_time: Math.floor(Date.now() / 1000), + firebase: { sign_in_provider: token === 'synthetic-race-custom' ? 'custom' : 'password', + ...(token === 'synthetic-race-tenant' ? { tenant: 'other' } : {}) } }; return { uid: 'synthetic-uid', email_verified: token !== 'synthetic-unverified-auth' }; }) }) })); jest.mock('firebase-admin/app-check', () => ({ getAppCheck: () => ({ verifyToken: jest.fn().mockImplementation(async (token) => { if (token !== 'synthetic-valid-app-token') throw new Error('synthetic denial'); return { appId: 'synthetic-app' }; }) }) })); -jest.mock('firebase-admin', () => ({ firestore: jest.fn() })); +jest.mock('firebase-admin', () => ({ firestore: jest.fn(), app: jest.fn() })); jest.mock('firebase-functions/logger', () => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() })); const admin = require('firebase-admin'); const { communityActions } = require('./communityActions'); @@ -79,4 +83,34 @@ describe('native authenticated boundary and context binding', () => { expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { ...link, role: 'admin' })).status).toBe(400); expect(admin.firestore).not.toHaveBeenCalled(); }); + test('fictional race native HTTP wrapper verifies mocked Auth/App Check and binds platform UID before SDK work', async () => { + // SDK verifier doubles prove wrapper wiring only, not genuine hosted tokens. + const data = { action: 'race_status', accountUid: 'stage_race_native', incomingEntryId: null }; + const authenticated = { authorization: 'Bearer synthetic-race-password' }; + expect((await request(authenticated, data)).status).toBe(401); + expect((await request({ ...authenticated, 'x-firebase-appcheck': 'synthetic-invalid-app-token' }, data)).status).toBe(401); + expect((await request(app, data)).status).toBe(401); + for (const token of ['synthetic-invalid-auth', 'synthetic-race-other', 'synthetic-race-unverified', 'synthetic-race-custom', 'synthetic-race-tenant']) { + expect((await request({ ...app, authorization: `Bearer ${token}` }, data)).status).toBe(token === 'synthetic-invalid-auth' ? 401 : 403); + } + expect((await request({ ...app, ...authenticated }, { ...data, accountUid: 'stage_race_other' })).status).toBe(403); + expect((await request({ ...app, ...authenticated }, { ...data, role: 'admin' })).status).toBe(400); + expect(admin.app).not.toHaveBeenCalled(); expect(admin.firestore).not.toHaveBeenCalled(); + }); + test('qualified native HTTP context enters bounded status with live authority and no write', async () => { + const { memoryDb } = require('./testSupport/communityMemoryDb'); + const f = require('./testSupport/fictionalRaceFixtures'); + const { POLICY } = require('./fictionalRaceTransitions'); + const { db, docs } = memoryDb(); const uid = 'stage_race_native'; const now = Date.now(); + docs.set('communityConfig/fictionalRaces', { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: [] }); + docs.set(`communityRaceTargets/${uid}`, f.marker(uid, now)); docs.set(`communityRacePermissions/${uid}`, f.permission(uid, now)); + const metadata = { getUser: jest.fn(async target => ({ uid: target, emailVerified: true, disabled: false })) }; + admin.app.mockReturnValue({ options: { projectId: process.env.GCLOUD_PROJECT }, firestore: () => db, auth: () => metadata }); + const before = JSON.stringify([...docs]); + const response = await request({ ...app, authorization: 'Bearer synthetic-race-password' }, { action: 'race_status', accountUid: uid, incomingEntryId: null }); + expect(response.status).toBe(200); expect(response.body.result).toMatchObject({ simulationOnly: true, events: [], incoming: null }); + expect(metadata.getUser.mock.calls).toEqual([[uid]]); expect(JSON.stringify([...docs])).toBe(before); + docs.get(`communityRacePermissions/${uid}`).enabled = false; + expect((await request({ ...app, authorization: 'Bearer synthetic-race-password' }, { action: 'race_status', accountUid: uid, incomingEntryId: null })).status).toBe(403); + }); }); diff --git a/functions/communityActions.js b/functions/communityActions.js index 36d711e..748462c 100644 --- a/functions/communityActions.js +++ b/functions/communityActions.js @@ -8,10 +8,13 @@ const { deriveMembershipEntitlement } = require('./membershipAuthority'); const policy = require('./membershipCancellationPolicy.json'); const limits = require('./profileRuntime'); const { parseAdminRequest, assertOfficerToken, createAdministrationService } = require('./communityAdministration'); +const { createFictionalRaceService, parseRaceCommand, parseRaceStatus, assertRaceActor } = require('./fictionalRaceService'); const ID = /^[A-Za-z0-9_-]{1,80}$/; const COMMAND = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; const ACTIONS = new Set(['membership_join', 'membership_cancel', 'event_join', 'event_cancel']); +const RACE_ACTIONS = Object.freeze({ race_join: 'join_simulation', race_cancel: 'simulate_refund', + race_offer: 'offer_transfer', race_accept: 'accept_transfer', race_revoke: 'revoke_transfer' }); const error = (code) => new functions.https.HttpsError(code, { 'invalid-argument': 'Request is invalid.', 'permission-denied': 'Verify your account before making this request.', @@ -24,6 +27,18 @@ const error = (code) => new functions.https.HttpsError(code, { const check = (value, code = 'unavailable') => { if (!value) throw error(code); }; const integer = (value, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= 0 && value <= max; +function communityEnvelope(input) { + try { + // Clone before inspecting any discriminant; getters/proxies never run. + return parseStrictObject(input, { requiredKeys: ['action', 'accountUid'], optionalKeys: [ + 'requestId', 'expectedRevision', 'eventId', 'waiverVersion', 'waiverAccepted', 'targetUid', 'event', + 'capacity', 'reason', 'cursor', 'duesCursor', 'confirmationId', 'externalConfirmed', 'policyRevision', + 'membershipId', 'associationConfirmed', 'entryId', 'expectedEventRevision', 'expectedEntryRevision', + 'recipientUid', 'incomingEntryId', + ] }); + } catch { throw error('invalid-argument'); } +} + function assertCommunityEnvironment(env = process.env) { const project = env.GCLOUD_PROJECT; const emulator = /^demo-[a-z0-9-]+$/.test(project || '') @@ -201,8 +216,23 @@ exports.communityActions = functions.runWith({ ...limits, enforceAppCheck: true assertCommunityEnvironment(); check(context.app, 'permission-denied'); check(context.auth?.uid && typeof context.auth.uid === 'string' && ID.test(context.auth.uid), 'unauthenticated'); - const administrative = typeof data?.action === 'string' && data.action.startsWith('admin_'); - const request = administrative ? parseAdminRequest(data) : parseRequest(data); + const envelope = communityEnvelope(data); + check(typeof envelope.action === 'string', 'invalid-argument'); + if (envelope.action === 'race_status' || Object.hasOwn(RACE_ACTIONS, envelope.action)) { + // UID and claims come exclusively from Firebase's verified callable context. + // A browser admin role cannot replace the live own-entry permission/marker. + const status = envelope.action === 'race_status'; + const request = status ? parseRaceStatus(envelope, context.auth.uid) + : parseRaceCommand({ ...envelope, action: RACE_ACTIONS[envelope.action] }, context.auth.uid); + assertRaceActor(context.auth.token, Date.now()); + let app; + try { app = admin.app(); } catch { throw error('unavailable'); } + const service = createFictionalRaceService(app); + return status ? service.status(context.auth.uid, context.auth.token, request) + : service.execute(context.auth.uid, context.auth.token, request); + } + const administrative = typeof envelope.action === 'string' && envelope.action.startsWith('admin_'); + const request = administrative ? parseAdminRequest(envelope) : parseRequest(envelope); check(request.accountUid === context.auth.uid, 'permission-denied'); if (request.action !== 'status') check(context.auth.token.email_verified === true, 'permission-denied'); if (administrative) assertOfficerToken(context.auth.token, Date.now()); @@ -223,3 +253,4 @@ exports.assertCommunityEnvironment = assertCommunityEnvironment; exports.parseRequest = parseRequest; exports.memberView = memberView; exports.eventView = eventView; +exports.communityEnvelope = communityEnvelope; diff --git a/functions/communityOnly.js b/functions/communityOnly.js index dba9f50..f38c961 100644 --- a/functions/communityOnly.js +++ b/functions/communityOnly.js @@ -2,5 +2,5 @@ const admin = require('firebase-admin'); const { assertCommunityEnvironment, communityActions } = require('./communityActions'); assertCommunityEnvironment(); -admin.initializeApp(); +admin.initializeApp({ projectId: process.env.GCLOUD_PROJECT }); exports.communityActions = communityActions; diff --git a/functions/fictionalRaceRpc.test.js b/functions/fictionalRaceRpc.test.js new file mode 100644 index 0000000..646486b --- /dev/null +++ b/functions/fictionalRaceRpc.test.js @@ -0,0 +1,102 @@ +// Direct handler + synthetic context doubles; genuine hosted verification is +// separate. The native HTTP wrapper tests also cover these exact routes. +jest.mock('firebase-admin', () => ({ app: jest.fn(), firestore: jest.fn(), auth: jest.fn() })); +const admin = require('firebase-admin'); +const { communityActions, communityEnvelope } = require('./communityActions'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const { POLICY } = require('./fictionalRaceTransitions'); +const f = require('./testSupport/fictionalRaceFixtures'); +const uid = 'stage_race_rpc_owner'; +describe('same community callable exact fictional race routing', () => { + let docs; let db; let metadata; let now; let context; + const keys = Object.keys(f.ENV); const saved = {}; + beforeAll(() => { keys.forEach(key => { saved[key] = process.env[key]; process.env[key] = f.ENV[key]; }); }); + afterAll(() => keys.forEach(key => { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; })); + beforeEach(() => { + jest.clearAllMocks(); ({ db, docs } = memoryDb()); now = Date.now(); + metadata = { getUser: jest.fn(async target => ({ uid: target, emailVerified: true, disabled: false })) }; + admin.app.mockReturnValue({ options: { projectId: f.ENV.GCLOUD_PROJECT }, firestore: () => db, auth: () => metadata }); + context = { auth: { uid, token: f.token(now) }, app: { appId: 'synthetic-only' } }; + docs.set('communityConfig/fictionalRaces', { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: [f.eventId] }); + docs.set(`communityRaceEvents/${f.eventId}`, f.event(now)); docs.set(`communityRacePolicies/${f.eventId}`, f.policy(now)); + docs.set(`communityRaceTargets/${uid}`, f.marker(uid, now)); docs.set(`communityRacePermissions/${uid}`, f.permission(uid, now)); + }); + const status = () => ({ action: 'race_status', accountUid: uid, incomingEntryId: null }); + const call = (data, ctx = context) => communityActions.run(data, ctx); + const join = () => ({ ...f.command(uid), action: 'race_join' }); + test('status and join/cancel exact retry share the existing native wrapper without legacy price/state', async () => { + const before = JSON.stringify([...docs]); expect(await call(status())).toMatchObject({ simulationOnly: true, events: [{ canJoin: true }] }); + expect(JSON.stringify([...docs])).toBe(before); + const command = join(); const result = await call(command); expect(result.action).toBe('join_simulation'); + const committed = JSON.stringify([...docs]); expect(await call(command)).toEqual(result); expect(JSON.stringify([...docs])).toBe(committed); + expect((await call(status())).events[0]).toMatchObject({ canJoin: false, entry: { canCancel: true } }); + await call({ ...f.command(uid, 'simulate_refund'), action: 'race_cancel', expectedEventRevision: 2, expectedEntryRevision: 1 }); + expect((await call(status())).events[0].entry).toMatchObject({ status: 'cancelled', fictionalReturnedCents: 1000 }); + expect(admin.firestore).not.toHaveBeenCalled(); expect(admin.auth).not.toHaveBeenCalled(); + }); + test('offer/revoke/offer/recipient acceptance routes preserve the bib/count/original fictional credit', async () => { + const recipient = 'stage_race_rpc_recipient'; + docs.set(`communityRaceTargets/${recipient}`, f.marker(recipient, now)); + docs.set(`communityRacePermissions/${recipient}`, f.permission(recipient, now)); + const joined = await call(join()); const entryId = joined.entryId; + const change = (action, revision, extra = {}) => ({ ...f.command(uid), action, entryId, + expectedEventRevision: 2, expectedEntryRevision: revision, ...extra }); + // Remove the join-only waiver from non-waiver routes; closed parsers reject it. + const withoutWaiver = data => { const request = { ...data }; delete request.waiverAccepted; delete request.waiverVersion; return request; }; + expect((await call(withoutWaiver(change('race_offer', 1, { recipientUid: recipient })))).action).toBe('offer_transfer'); + expect((await call(withoutWaiver(change('race_revoke', 2)))).action).toBe('revoke_transfer'); + await call(withoutWaiver(change('race_offer', 3, { recipientUid: recipient }))); + const nextContext = { ...context, auth: { uid: recipient, token: f.token(now) } }; + const incoming = await call({ action: 'race_status', accountUid: recipient, incomingEntryId: entryId }, nextContext); + expect(incoming.incoming).toMatchObject({ entryId, revision: 4, canAccept: true }); + const accepted = await call({ ...f.command(recipient, 'accept_transfer'), action: 'race_accept', entryId, + expectedEventRevision: 2, expectedEntryRevision: 4 }, nextContext); + expect(accepted.action).toBe('accept_transfer'); + const entry = docs.get(`communityRaceEntries/${entryId}`); + expect(entry).toMatchObject({ runnerUid: recipient, creditOwnerUid: uid, transferCount: 1, revision: 5 }); + expect(docs.get(`communityRaceEvents/${f.eventId}`).confirmedCount).toBe(1); + const view = await call({ action: 'race_status', accountUid: recipient, incomingEntryId: null }, nextContext); + expect(view.events[0].entry).toMatchObject({ bibId: entry.bibId, canCancel: true, canOffer: false, isOriginalCreditOwner: false }); + }); + test.each(['race_status_extra', 'race_refund', 'race_simulate_refund', 'join_simulation', 'simulate_refund', '', null, 123, {}, []])('only exact prefixed routes are admitted: %p', async action => { + await expect(call({ ...status(), action })).rejects.toHaveProperty('code', 'invalid-argument'); expect(admin.app).not.toHaveBeenCalled(); + }); + test('native context UID, App Check and verified recent default-tenant password identity cannot be replaced by body', async () => { + for (const ctx of [{ ...context, app: undefined }, { ...context, auth: undefined }, + { ...context, auth: { uid: 'stage_race_other', token: f.token(now) } }, + { ...context, auth: { uid, token: { ...f.token(now), email_verified: false } } }, + { ...context, auth: { uid, token: { ...f.token(now), auth_time: Math.floor(now / 1000) - 3601 } } }, + { ...context, auth: { uid, token: { ...f.token(now), auth_time: Math.floor(now / 1000) + 31 } } }, + { ...context, auth: { uid, token: { ...f.token(now), firebase: { sign_in_provider: 'custom' } } } }, + { ...context, auth: { uid, token: { ...f.token(now), firebase: { sign_in_provider: 'password', tenant: 'other' } } } }]) { + await expect(call(status(), ctx)).rejects.toBeDefined(); + } + expect(admin.app).not.toHaveBeenCalled(); + }); + test('browser admin cannot read without current own-entry permission/marker/native metadata', async () => { + docs.delete(`communityRacePermissions/${uid}`); + await expect(call(status(), { ...context, auth: { uid, token: { ...f.token(now), role: 'admin', admin: true } } })).rejects.toHaveProperty('code', 'permission-denied'); + expect(metadata.getUser).not.toHaveBeenCalled(); + }); + test.each(['amountCents', 'expiresAtMs', 'role', 'uid', 'price', 'currency', 'canJoin', 'email_verified', 'capability'])('unknown request field %s rejects before SDK', async key => { + await expect(call({ ...join(), [key]: 'client-authority' })).rejects.toHaveProperty('code', 'invalid-argument'); expect(admin.app).not.toHaveBeenCalled(); + }); + test('allowed envelope field from another action is still denied by exact child parser', async () => { + await expect(call({ ...status(), expectedRevision: 0 })).rejects.toHaveProperty('code', 'invalid-argument'); + await expect(call({ ...join(), targetUid: 'stage_race_other' })).rejects.toHaveProperty('code', 'invalid-argument'); + expect(admin.app).not.toHaveBeenCalled(); + }); + test('getters/proxies are rejected before discriminant evaluation or SDK calls', async () => { + const getter = jest.fn(); const data = status(); Object.defineProperty(data, 'action', { get: getter, enumerable: true }); + await expect(call(data)).rejects.toHaveProperty('code', 'invalid-argument'); + await expect(call(new Proxy(status(), { get: getter }))).rejects.toHaveProperty('code', 'invalid-argument'); + expect(getter).not.toHaveBeenCalled(); expect(admin.app).not.toHaveBeenCalled(); + expect(() => communityEnvelope(data)).toThrow('Request is invalid.'); + }); + test('SDK acquisition fails fixed and project mismatch never falls back to default production', async () => { + admin.app.mockImplementationOnce(() => { throw Error('private SDK detail'); }); + await expect(call(status())).rejects.toMatchObject({ code: 'unavailable', message: 'Community status is temporarily unavailable.' }); + admin.app.mockReturnValue({ options: { projectId: 'production' }, firestore: jest.fn(), auth: jest.fn() }); + await expect(call(status())).rejects.toThrow('Fictional race action unavailable.'); expect(metadata.getUser).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/fictionalRaceService.emulator.test.js b/functions/fictionalRaceService.emulator.test.js index 7d761ca..33ea368 100644 --- a/functions/fictionalRaceService.emulator.test.js +++ b/functions/fictionalRaceService.emulator.test.js @@ -1,11 +1,11 @@ const admin = require('firebase-admin'); const { createFictionalRaceService, raceEnvironment } = require('./fictionalRaceService'); -const { raceEnrollmentId } = require('./fictionalRaceTransitions'); +const { POLICY, 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; + let app; let db; let auth; let now; let service; let sequence = 0; let manifestOwned = false; const ownedUids = new Set(); const assertDemo = () => expect(['demo-mprc-local', 'demo-functions-test']).toContain(raceEnvironment(process.env)); beforeAll(() => { @@ -15,6 +15,10 @@ describeDemo('fictional race native Auth metadata and Firestore transactions — }); async function clean() { assertDemo(); + if (manifestOwned) { + await db.doc('communityConfig/fictionalRaces').delete(); + expect((await db.doc('communityConfig/fictionalRaces').get()).exists).toBe(false); manifestOwned = false; + } 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); @@ -27,6 +31,8 @@ describeDemo('fictional race native Auth metadata and Firestore transactions — 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)); + await db.doc('communityConfig/fictionalRaces').create({ schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: [f.eventId] }); + manifestOwned = true; }, 45000); afterAll(async () => { if (db) await clean(); if (app) await app.delete(); }, 45000); async function member() { @@ -48,6 +54,10 @@ describeDemo('fictional race native Auth metadata and Firestore transactions — }); async function snapshots() { const result = []; + if (manifestOwned) { + const doc = await db.doc('communityConfig/fictionalRaces').get(); + result.push([doc.ref.path, [doc.updateTime.seconds, doc.updateTime.nanoseconds], doc.data()]); + } 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()]); @@ -59,6 +69,45 @@ describeDemo('fictional race native Auth metadata and Firestore transactions — return result.sort(([a], [b]) => a.localeCompare(b)); } + const read = (uid, incomingEntryId = null) => service.status(uid, f.token(now), { action: 'race_status', accountUid: uid, incomingEntryId }); + test('native read-only status isolates own enrollment and exact named offer; transferred cancellation keeps original credit', async () => { + const owner = await member(); const recipient = await member(); const other = await member(); + const { entryId } = await joined(owner); + await run(owner, change(owner, entryId, 'offer_transfer', 1, { recipientUid: recipient })); + const before = await snapshots(); + expect((await read(owner)).events[0]).toMatchObject({ canJoin: false, entry: { canCancel: false, canRevoke: true } }); + expect((await read(other)).events[0]).toMatchObject({ enrollment: null, entry: null }); + expect((await read(recipient)).incoming).toBeNull(); + const offer = await read(recipient, entryId); + expect(offer.incoming).toMatchObject({ entryId, canAccept: true, isCurrentRunner: false, isOriginalCreditOwner: false }); + expect(JSON.stringify(offer)).not.toContain(owner); expect(JSON.stringify(offer)).not.toContain(recipient); + await expect(read(other, entryId)).rejects.toHaveProperty('code', 'permission-denied'); + await expect(read(other, 'stage_race_missing')).rejects.toHaveProperty('code', 'permission-denied'); + expect(await snapshots()).toEqual(before); // Exact native nanoseconds + all content, not merely milliseconds. + await run(recipient, change(recipient, entryId, 'accept_transfer', 2)); + expect((await read(owner)).events[0]).toMatchObject({ canJoin: false, enrollment: { status: 'transferred' }, entry: { canCancel: false } }); + expect((await read(recipient)).events[0].entry).toMatchObject({ canCancel: true, canOffer: false, isOriginalCreditOwner: false }); + await run(recipient, change(recipient, entryId, 'simulate_refund', 3)); + expect((await read(owner)).events[0].entry).toMatchObject({ fictionalReturnedCents: 1000, isOriginalCreditOwner: true, canCancel: false }); + }, 45000); + test('native disabled/expired/revoked status denies unchanged while lost-recipient owner recovery stays available', async () => { + const owner = await member(); const recipient = await member(); const { entryId } = await joined(owner); + await run(owner, change(owner, entryId, 'offer_transfer', 1, { recipientUid: recipient })); + await auth.updateUser(recipient, { disabled: true }); + const before = await snapshots(); await expect(read(recipient, entryId)).rejects.toHaveProperty('code', 'permission-denied'); + expect((await read(owner)).events[0].entry.canRevoke).toBe(true); expect(await snapshots()).toEqual(before); + await run(owner, change(owner, entryId, 'revoke_transfer', 2)); + expect((await read(owner)).events[0].entry.canCancel).toBe(true); + const permission = db.doc(`communityRacePermissions/${owner}`); + for (const invalid of [{ enabled: false }, { expiresAtMs: now }]) { + await permission.set({ ...f.permission(owner, now), ...invalid }); const denied = await snapshots(); + await expect(read(owner)).rejects.toHaveProperty('code', 'permission-denied'); expect(await snapshots()).toEqual(denied); + } + await permission.set(f.permission(owner, now)); + await auth.updateUser(owner, { emailVerified: false }); + const last = await snapshots(); await expect(read(owner)).rejects.toHaveProperty('code', 'permission-denied'); expect(await snapshots()).toEqual(last); + }, 45000); + 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)))); diff --git a/functions/fictionalRaceService.js b/functions/fictionalRaceService.js index d077917..6c96a1e 100644 --- a/functions/fictionalRaceService.js +++ b/functions/fictionalRaceService.js @@ -1,11 +1,12 @@ 'use strict'; -// Server-only source; no callable/client/runtime-bundle integration yet. +// Server-only fictional credits; never financial/provider evidence. // 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 { POLICY, WAIVER, reduceFictionalRace, readFictionalRaceRecords, readFictionalEvent, + readFictionalPolicy, 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}$/; @@ -55,6 +56,21 @@ function actor(token, now) { && integer(token.auth_time, Number.MAX_SAFE_INTEGER) && token.auth_time * 1000 <= now + 30000 && now - token.auth_time * 1000 <= 3600000, 'permission-denied'); } +function parseRaceStatus(input, uid) { + try { + const request = strict(input, ['action', 'accountUid', 'incomingEntryId']); + check(identifier(uid) && request.accountUid === uid, 'permission-denied'); + check(request.action === 'race_status' && (request.incomingEntryId === null || identifier(request.incomingEntryId)), 'invalid-argument'); + return request; + } catch (error) { if (safeErrors.has(error)) throw error; fail('invalid-argument'); } +} +function manifest(input) { + const value = strict(input, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventIds']); + check(value.schemaVersion === 1 && value.simulationOnly === true && value.policyVersion === POLICY + && Array.isArray(value.eventIds) && value.eventIds.length <= 20 + && value.eventIds.every(identifier) && new Set(value.eventIds).size === value.eventIds.length); + return value.eventIds; +} function grant(input, uid, now, permission = false) { try { const value = strict(input, ['schemaVersion', 'simulationOnly', 'enabled', 'uid', 'revision', 'expiresAtMs', ...(permission ? ['manageOwnEntry'] : [])]); @@ -104,6 +120,96 @@ function createFictionalRaceService(app, clock = Date.now, env = process.env) { 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 status(uid, token, input) { + try { + raceEnvironment(env); const request = parseRaceStatus(input, uid); + return await db.runTransaction(async tx => { + const began = clock(); check(integer(began)); actor(token, began); + const [permissionDoc, markerDoc, manifestDoc] = await tx.getAll(db.doc(`communityRacePermissions/${uid}`), + db.doc(`communityRaceTargets/${uid}`), db.doc('communityConfig/fictionalRaces')); + const permission = grant(permissionDoc.data(), uid, began, true); + const marker = grant(markerDoc.data(), uid, began); + const ids = manifest(manifestDoc.data()); + const user = await auth.getUser(uid); + check(user.uid === uid && user.emailVerified === true && user.disabled === false, 'permission-denied'); + // Exact configured IDs and caller indexes only; never enumerate entries, + // users, offers, profiles or command history, even for a browser admin. + const rows = ids.length ? await tx.getAll(...ids.flatMap(eventId => [db.doc(`communityRaceEvents/${eventId}`), + db.doc(`communityRacePolicies/${eventId}`), indexRef(eventId, uid)])) : []; + const indexes = ids.map((eventId, n) => { + const doc = rows[n * 3 + 2]; if (!doc.exists) return null; + const value = doc.data(); + check(identifier(value?.entryId) && ['confirmed', 'cancelled', 'transferred'].includes(value?.status)); + return enrollment(value, eventId, uid, value.entryId, value.status); + }); + const entryIds = [...new Set([...indexes.filter(Boolean).map(index => index.entryId), request.incomingEntryId].filter(Boolean))]; + const entryDocs = entryIds.length ? await tx.getAll(...entryIds.map(entryId => db.doc(`communityRaceEntries/${entryId}`))) : []; + const entries = new Map(entryIds.map((entryId, n) => [entryId, entryDocs[n].data()])); + if (request.incomingEntryId !== null) { + const incoming = entries.get(request.incomingEntryId); + // Missing and existing-but-unrelated IDs have the same denial. Do not + // read another entry's enrollment indexes to discover incoming offers. + check(incoming?.entryId === request.incomingEntryId && ids.includes(incoming?.eventId) + && incoming?.offer?.recipientUid === uid, 'permission-denied'); + } + const indexKeys = [...new Set(entryIds.flatMap(entryId => { + const entry = entries.get(entryId); check(entry?.entryId === entryId && ids.includes(entry?.eventId)); + check(identifier(entry.runnerUid) && identifier(entry.creditOwnerUid)); + return [entry.runnerUid, entry.creditOwnerUid].map(target => raceEnrollmentId(entry.eventId, target)); + }))]; + const relatedDocs = indexKeys.length ? await tx.getAll(...indexKeys.map(key => db.doc(`communityRaceEnrollment/${key}`))) : []; + const related = new Map(indexKeys.map((key, n) => [key, relatedDocs[n].data()])); + const now = clock(); check(integer(now)); actor(token, now); + grant(permission, uid, now, true); grant(marker, uid, now); + const configured = ids.map((eventId, n) => { + const event = readFictionalEvent(rows[n * 3].data()); check(event.eventId === eventId); + return { event, policy: readFictionalPolicy(rows[n * 3 + 1].data(), event) }; + }); + function entryView(entryId, event) { + const entry = readFictionalRaceRecords(event, entries.get(entryId), now).entry; + check(entry.entryId === entryId && entry.entryId === raceEnrollmentId(event.eventId, entry.creditOwnerUid)); + enrollment(related.get(raceEnrollmentId(event.eventId, entry.runnerUid)), event.eventId, entry.runnerUid, entryId, entry.status); + if (entry.transferCount === 1) enrollment(related.get(raceEnrollmentId(event.eventId, entry.creditOwnerUid)), event.eventId, entry.creditOwnerUid, entryId, 'transferred'); + return entry; + } + function project(entry, event) { + const own = entry.runnerUid === uid; + return { entryId: entry.entryId, revision: entry.revision, bibId: entry.bibId, status: entry.status, + fictionalAmountCents: entry.fictionalAmountCents, fictionalReturnedCents: entry.fictionalReturnedCents, + isCurrentRunner: own, isOriginalCreditOwner: entry.creditOwnerUid === uid, transferCount: entry.transferCount, + offerExpiresAtMs: entry.offer?.expiresAtMs ?? null, + canCancel: own && entry.status === 'confirmed' && entry.offer === null && now < event.startAtMs, + canOffer: own && entry.status === 'confirmed' && entry.offer === null && entry.transferCount === 0 && event.status === 'open' && now < event.startAtMs, + canRevoke: own && entry.offer !== null && now < event.startAtMs }; + } + const events = configured.map(({ event, policy }, n) => { + const index = indexes[n]; const entry = index ? entryView(index.entryId, event) : null; + if (entry) check((index.status === 'transferred' ? entry.creditOwnerUid : entry.runnerUid) === uid); + const availability = event.status === 'cancelled' ? 'cancelled' : now >= policy.closesAtMs ? 'ended' + : now < policy.opensAtMs ? 'not_open_yet' : event.confirmedCount >= event.capacity ? 'full' : 'open'; + return { eventId: event.eventId, revision: event.revision, startAtMs: event.startAtMs, + opensAtMs: policy.opensAtMs, closesAtMs: policy.closesAtMs, signupStatus: availability, + fictionalAmountCents: policy.fictionalAmountCents, currency: policy.currency, waiverVersion: policy.waiverVersion, + joinEntryId: index === null ? raceEnrollmentId(event.eventId, uid) : null, + canJoin: index === null && availability === 'open', + enrollment: index ? { status: index.status, revision: index.revision } : null, + entry: entry ? project(entry, event) : null }; + }); + let incoming = null; + if (request.incomingEntryId !== null) { + const entry = entries.get(request.incomingEntryId); + const { event } = configured.find(value => value.event.eventId === entry.eventId); + const checked = entryView(request.incomingEntryId, event); + check(checked.offer?.recipientUid === uid && checked.status === 'confirmed' && checked.transferCount === 0 + && event.status === 'open' && now < event.startAtMs && now < checked.offer.expiresAtMs + && indexes[ids.indexOf(event.eventId)] === null, 'permission-denied'); + incoming = { eventId: event.eventId, eventRevision: event.revision, ...project(checked, event), canAccept: true }; + } + return { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, observedAtMs: now, + accessExpiresAtMs: Math.min(permission.expiresAtMs, marker.expiresAtMs, token.auth_time * 1000 + 3600000), events, incoming }; + }, { readOnly: true }); + } catch (error) { if (safeErrors.has(error)) throw error; fail('unavailable'); } + } async function execute(uid, token, input) { try { raceEnvironment(env); const request = parseRaceCommand(input, uid); @@ -204,7 +310,7 @@ function createFictionalRaceService(app, clock = Date.now, env = process.env) { }); } catch (error) { if (safeErrors.has(error)) throw error; fail('unavailable'); } } - return { execute }; + return { execute, status }; } -module.exports = { createFictionalRaceService, raceEnvironment, parseRaceCommand }; +module.exports = { createFictionalRaceService, raceEnvironment, parseRaceCommand, parseRaceStatus, assertRaceActor: actor }; diff --git a/functions/fictionalRaceStatus.test.js b/functions/fictionalRaceStatus.test.js new file mode 100644 index 0000000..2a19814 --- /dev/null +++ b/functions/fictionalRaceStatus.test.js @@ -0,0 +1,173 @@ +const { createFictionalRaceService, parseRaceStatus } = require('./fictionalRaceService'); +const { POLICY, raceEnrollmentId } = require('./fictionalRaceTransitions'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const f = require('./testSupport/fictionalRaceFixtures'); +const owner = 'stage_race_status_owner'; const recipient = 'stage_race_status_recipient'; +const outsider = 'stage_race_status_other'; +describe('fictional race bounded read-only status (source double, not hosted proof)', () => { + let db; let docs; let service; let auth; let now; + const request = (uid, incomingEntryId = null) => ({ action: 'race_status', accountUid: uid, incomingEntryId }); + const snapshot = () => JSON.stringify([...docs]); + const read = (uid = owner, incoming = null) => service.status(uid, f.token(now), request(uid, incoming)); + beforeEach(() => { + ({ db, docs } = memoryDb()); now = 1700000000000; + auth = { getUser: jest.fn(async uid => ({ uid, emailVerified: true, disabled: false })) }; + service = createFictionalRaceService({ options: { projectId: f.ENV.GCLOUD_PROJECT }, firestore: () => db, auth: () => auth }, () => now, f.ENV); + docs.set('communityConfig/fictionalRaces', { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: [f.eventId] }); + docs.set(`communityRaceEvents/${f.eventId}`, f.event(now)); + docs.set(`communityRacePolicies/${f.eventId}`, f.policy(now)); + for (const uid of [owner, recipient, outsider]) { + docs.set(`communityRaceTargets/${uid}`, f.marker(uid, now)); + docs.set(`communityRacePermissions/${uid}`, f.permission(uid, now)); + } + }); + const execute = (uid, command) => service.execute(uid, f.token(now), command); + async function joined() { + const result = await execute(owner, f.command(owner)); return result.entryId; + } + const change = (entryId, action, revision = 1, extra = {}) => f.command(owner, action, + { entryId, expectedEntryRevision: revision, expectedEventRevision: 2, ...extra }); + async function denied(uid = owner, incoming = null) { + const before = snapshot(); await expect(read(uid, incoming)).rejects.toThrow('Fictional race action unavailable.'); + expect(snapshot()).toBe(before); + } + test('empty own status has exact minimal shape and no writes, roster, totals or SDK enumeration', async () => { + const transaction = jest.spyOn(db, 'runTransaction'); + const before = snapshot(); const view = await read(); + expect(view).toEqual({ schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, + observedAtMs: now, accessExpiresAtMs: now + 3600000, incoming: null, + events: [{ eventId: f.eventId, revision: 1, startAtMs: now + 3600000, + opensAtMs: now - 1000, closesAtMs: now + 1800000, signupStatus: 'open', + fictionalAmountCents: 1000, currency: 'USD', waiverVersion: f.policy(now).waiverVersion, + joinEntryId: raceEnrollmentId(f.eventId, owner), + canJoin: true, enrollment: null, entry: null }] }); + expect(snapshot()).toBe(before); expect(auth.getUser.mock.calls).toEqual([[owner]]); + expect(transaction).toHaveBeenCalledWith(expect.any(Function), { readOnly: true }); + expect(JSON.stringify(view)).not.toMatch(/runnerUid|creditOwnerUid|recipientUid|confirmedCount|capacity|email|originalWaiver|recipientWaiver/); + }); + test.each([ + [{ status: 'cancelled' }, {}, 'cancelled'], [{}, { opensAtMs: 1700000000001 }, 'not_open_yet'], + [{}, { closesAtMs: 1700000000000 }, 'ended'], [{ confirmedCount: 1 }, {}, 'full'], + ])('server signup availability closes new joins without changing history %#', async (event, policy, expected) => { + docs.set(`communityRaceEvents/${f.eventId}`, { ...f.event(now), ...event }); + docs.set(`communityRacePolicies/${f.eventId}`, { ...f.policy(now), ...policy }); + const before = snapshot(); expect((await read()).events[0]).toMatchObject({ signupStatus: expected, canJoin: false }); + expect(snapshot()).toBe(before); + }); + test('only own signup projects; cancellation exposes fictional credits and never restores enrollment', async () => { + const entryId = await joined(); const before = snapshot(); + expect((await read()).events[0]).toMatchObject({ canJoin: false, enrollment: { status: 'confirmed', revision: 1 }, + entry: { entryId, isCurrentRunner: true, isOriginalCreditOwner: true, canCancel: true, canOffer: true, canRevoke: false } }); + expect((await read(outsider)).events[0]).toMatchObject({ enrollment: null, entry: null }); + expect(snapshot()).toBe(before); + await execute(owner, change(entryId, 'simulate_refund')); + expect((await read()).events[0]).toMatchObject({ canJoin: false, signupStatus: 'open', enrollment: { status: 'cancelled' }, + entry: { status: 'cancelled', fictionalReturnedCents: 1000, canCancel: false, canOffer: false, canRevoke: false } }); + }); + test('incoming is exact named offer only, original owner sees no recipient details; transferred history remains private', async () => { + const entryId = await joined(); await execute(owner, change(entryId, 'offer_transfer', 1, { recipientUid: recipient })); + const before = snapshot(); const own = await read(); const incoming = await read(recipient, entryId); + expect(own.events[0].entry).toMatchObject({ canCancel: false, canOffer: false, canRevoke: true, offerExpiresAtMs: now + 300000 }); + expect((await read(recipient)).incoming).toBeNull(); + expect(incoming.events[0].entry).toBeNull(); + expect(incoming.incoming).toMatchObject({ eventId: f.eventId, eventRevision: 2, entryId, revision: 2, canAccept: true, + isCurrentRunner: false, isOriginalCreditOwner: false, canCancel: false, canOffer: false, canRevoke: false }); + expect(JSON.stringify([own, incoming])).not.toContain(owner); expect(JSON.stringify([own, incoming])).not.toContain(recipient); + expect(snapshot()).toBe(before); + await execute(recipient, f.command(recipient, 'accept_transfer', { entryId, expectedEventRevision: 2, expectedEntryRevision: 2 })); + expect((await read()).events[0]).toMatchObject({ canJoin: false, enrollment: { status: 'transferred' }, + entry: { isCurrentRunner: false, isOriginalCreditOwner: true, canCancel: false, canOffer: false } }); + expect((await read(recipient)).events[0]).toMatchObject({ canJoin: false, enrollment: { status: 'confirmed' }, + entry: { isCurrentRunner: true, isOriginalCreditOwner: false, transferCount: 1, canCancel: true, canOffer: false } }); + await denied(recipient, entryId); + }); + test('missing and existing unrelated incoming IDs have indistinguishable fixed denial', async () => { + const entryId = await joined(); await execute(owner, change(entryId, 'offer_transfer', 1, { recipientUid: recipient })); + const missing = await read(outsider, 'stage_race_missing').catch(error => error); + const unrelated = await read(outsider, entryId).catch(error => error); + expect([missing.code, missing.message]).toEqual([unrelated.code, unrelated.message]); + expect(missing.code).toBe('permission-denied'); + }); + test('expired offer cannot be accepted; owner status/revoke survives removed recipient', async () => { + const entryId = await joined(); await execute(owner, change(entryId, 'offer_transfer', 1, { recipientUid: recipient })); + now += 300000; await denied(recipient, entryId); + docs.delete(`communityRaceTargets/${recipient}`); auth.getUser.mockClear(); + const before = snapshot(); expect((await read()).events[0].entry.canRevoke).toBe(true); + expect(auth.getUser.mock.calls).toEqual([[owner]]); expect(snapshot()).toBe(before); + await execute(owner, change(entryId, 'revoke_transfer', 2)); + expect((await read()).events[0].entry.canCancel).toBe(true); + }); + test('cancelled event still permits own simulated cancellation, not transfer or joining', async () => { + const entryId = await joined(); docs.get(`communityRaceEvents/${f.eventId}`).status = 'cancelled'; + expect((await read()).events[0]).toMatchObject({ signupStatus: 'cancelled', canJoin: false, + entry: { canCancel: true, canOffer: false } }); + await execute(owner, change(entryId, 'simulate_refund')); + }); + test.each(['communityRacePermissions', 'communityRaceTargets'])('revoked/expired authority fails before lookup and leaves everything unchanged: %s', async root => { + for (const invalid of [{ enabled: false }, { expiresAtMs: now }, { manageOwnEntry: false }, { extra: true }]) { + docs.set(`${root}/${owner}`, { ...(root === 'communityRacePermissions' ? f.permission(owner, now) : f.marker(owner, now)), ...invalid }); + await denied(); expect(auth.getUser).not.toHaveBeenCalled(); + } + }); + test.each(['unverified', 'disabled', 'missing', 'mismatched'])('current native caller metadata denies status: %s', async state => { + auth.getUser.mockImplementation(async uid => { + if (state === 'missing') throw Error('private synthetic provider response'); + return { uid: state === 'mismatched' ? recipient : uid, emailVerified: state !== 'unverified', disabled: state === 'disabled' }; + }); await denied(); + }); + test('slow final read fences exact permission expiry and uses fresh availability time', async () => { + const original = db.runTransaction; let delay = true; + db.runTransaction = fn => original(tx => fn({ ...tx, getAll: async (...refs) => { + const value = await tx.getAll(...refs); if (delay && refs.some(ref => ref.path.startsWith('communityRaceEvents/'))) now += 1000; + return value; + } })); + docs.get(`communityRacePermissions/${owner}`).expiresAtMs = now + 1000; await denied(); + docs.set(`communityRacePermissions/${owner}`, f.permission(owner, now)); + docs.get(`communityRacePolicies/${f.eventId}`).closesAtMs = now + 1000; + expect((await read()).events[0]).toMatchObject({ signupStatus: 'ended', canJoin: false }); delay = false; + }); + test.each([null, [], {}, { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: [f.eventId, f.eventId] }, + { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: Array.from({ length: 21 }, (_, n) => `stage_race_${n}`) }, + { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventIds: ['production-race'] }])('malformed/missing/oversized manifest denies without enumeration %#', async config => { + docs.set('communityConfig/fictionalRaces', config); await denied(); expect(auth.getUser).not.toHaveBeenCalled(); + }); + test('empty configured list returns empty, never discovers existing hidden races', async () => { + docs.get('communityConfig/fictionalRaces').eventIds = []; const before = snapshot(); + expect(await read()).toMatchObject({ events: [], incoming: null }); expect(snapshot()).toBe(before); + }); + test('twenty exact configured events are bounded point reads and one Auth lookup, never a query', async () => { + const ids = Array.from({ length: 20 }, (_, n) => `stage_race_catalog_${n}`); + docs.get('communityConfig/fictionalRaces').eventIds = ids; + for (const eventId of ids) { + docs.set(`communityRaceEvents/${eventId}`, { ...f.event(now), eventId }); + docs.set(`communityRacePolicies/${eventId}`, { ...f.policy(now), eventId }); + } + db.collection = jest.fn(() => { throw Error('unexpected enumeration'); }); + const before = snapshot(); const view = await read(); + expect(view.events.map(event => event.eventId)).toEqual(ids); expect(view.events).toHaveLength(20); + expect(new Set(view.events.map(event => event.joinEntryId)).size).toBe(20); + expect(db.collection).not.toHaveBeenCalled(); expect(auth.getUser.mock.calls).toEqual([[owner]]); + expect(snapshot()).toBe(before); + }); + test.each(['event', 'policy', 'entry', 'index', 'orphan'])('bad stored binding/state fails closed: %s', async state => { + const entryId = await joined(); + if (state === 'event') docs.get(`communityRaceEvents/${f.eventId}`).eventId = 'stage_race_other'; + if (state === 'policy') docs.get(`communityRacePolicies/${f.eventId}`).currency = 'EUR'; + if (state === 'entry') docs.get(`communityRaceEntries/${entryId}`).runnerUid = recipient; + if (state === 'index') docs.get(`communityRaceEnrollment/${raceEnrollmentId(f.eventId, owner)}`).status = 'transferred'; + if (state === 'orphan') docs.delete(`communityRaceEntries/${entryId}`); + await denied(); + }); + test('server read rejection is fixed and never echoes record/SDK details', async () => { + db.runTransaction = async () => { throw Error('private record and token'); }; + await denied(); + }); + test('status parser rejects spoof/getter/extra/injection/coercion without evaluating a getter', () => { + const getter = jest.fn(); const hostile = request(owner); Object.defineProperty(hostile, 'action', { get: getter, enumerable: true }); + for (const invalid of [hostile, request(recipient), { ...request(owner), incomingEntryId: 1 }, { ...request(owner), role: 'admin' }, + { ...request(owner), eventIds: [f.eventId] }, { ...request(owner), incomingEntryId: 'member-id' }]) { + expect(() => parseRaceStatus(invalid, owner)).toThrow('Fictional race action unavailable.'); + } + expect(getter).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/fictionalRaceTransitions.js b/functions/fictionalRaceTransitions.js index 020087d..0e1f087 100644 --- a/functions/fictionalRaceTransitions.js +++ b/functions/fictionalRaceTransitions.js @@ -1,8 +1,8 @@ 'use strict'; -// Inert pure foundation. Not imported by any callable, client or release bundle. +// Pure fictional transition helpers for the isolated staging service. // This models fictional credits, never payment/refund/provider evidence. -// A future trusted boundary must supply native identity/time, disposable markers, +// The trusted service supplies native identity/time, disposable markers, // capabilities and atomic record + immutable command-receipt persistence. const { parseStrictObject } = require('./requestValidation'); const { createHash } = require('node:crypto'); @@ -22,10 +22,31 @@ function waiver(value, uid, now) { return parsed; } +function readFictionalEvent(input) { + const event = strict(input, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', + 'revision', 'startAtMs', 'status', 'capacity', 'confirmedCount']); + check(event.schemaVersion === 1 && event.simulationOnly === true && event.policyVersion === POLICY + && id(event.eventId) && integer(event.revision, 999999) && event.revision > 0 + && time(event.startAtMs) && ['open', 'cancelled'].includes(event.status) + && integer(event.capacity, 10000) && event.capacity > 0 && integer(event.confirmedCount, event.capacity)); + return event; +} + +function readFictionalPolicy(input, event) { + const policy = strict(input, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', + 'revision', 'fictionalAmountCents', 'currency', 'waiverVersion', 'opensAtMs', 'closesAtMs']); + check(policy.schemaVersion === 1 && policy.simulationOnly === true && policy.policyVersion === POLICY + && policy.eventId === event.eventId && integer(policy.revision, 999999) && policy.revision > 0 + && integer(policy.fictionalAmountCents, 10000) && policy.fictionalAmountCents > 0 + && policy.currency === 'USD' && policy.waiverVersion === WAIVER + && time(policy.opensAtMs) && time(policy.closesAtMs) && policy.opensAtMs < policy.closesAtMs + && policy.closesAtMs - policy.opensAtMs <= 86400000 && policy.closesAtMs <= event.startAtMs); + return policy; +} + function records(eventInput, entryInput, now) { check(time(now)); - const event = strict(eventInput, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', - 'revision', 'startAtMs', 'status', 'capacity', 'confirmedCount']); + const event = readFictionalEvent(eventInput); const entry = strict(entryInput, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', 'entryId', 'revision', 'bibId', 'runnerUid', 'creditOwnerUid', 'fictionalAmountCents', 'fictionalReturnedCents', 'currency', 'status', 'transferCount', 'originalWaiver', 'recipientWaiver', 'offer']); @@ -124,15 +145,9 @@ function raceEnrollmentId(eventId, uid) { 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 configuredEvent = readFictionalEvent(eventInput); + const policy = readFictionalPolicy(policyInput, configuredEvent); + check(id(uid) && waiverVersion === WAIVER && time(now) && 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')}`, @@ -146,4 +161,5 @@ function createFictionalEntry(eventInput, policyInput, uid, waiverVersion, now) } catch { throw new Error(MESSAGE); } } -module.exports = { POLICY, WAIVER, reduceFictionalRace, readFictionalRaceRecords, raceEnrollmentId, createFictionalEntry }; +module.exports = { POLICY, WAIVER, reduceFictionalRace, readFictionalRaceRecords, readFictionalEvent, + readFictionalPolicy, raceEnrollmentId, createFictionalEntry }; diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index 92d7b20..0fb3dfc 100644 --- a/scripts/staging-community-release.js +++ b/scripts/staging-community-release.js @@ -16,7 +16,7 @@ const REGION = 'us-central1'; const BASE = `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/${REGION}`; const ACCOUNT = 'runmprc@gmail.com'; const RUNTIME = `mprc-community-runtime@${PROJECT}.iam.gserviceaccount.com`; -const FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'communityDuesAssociation.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; +const FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'communityDuesAssociation.js', 'fictionalRaceService.js', 'fictionalRaceTransitions.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; const check = (value) => { if (!value) throw new Error('staging_community_release_failed'); }; const PHASES = new Set(['arguments', 'source', 'runtime_authority', 'owner_identity', 'mfa', 'inventory', 'bundle', 'source_upload', 'function_update', 'operation_poll', 'function_readback', 'source_provenance', 'invoker_policy', 'source_archive', 'final_inventory', 'report']); const failures = new WeakMap(); diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index ff89262..cb52b51 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -366,6 +366,7 @@ describe('server-only operational collections', () => { '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', + 'communityConfig/fictionalRaces', ])('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 }); diff --git a/tests/staging-community-release.test.js b/tests/staging-community-release.test.js index a792126..4c3ca46 100644 --- a/tests/staging-community-release.test.js +++ b/tests/staging-community-release.test.js @@ -28,9 +28,32 @@ test('bundle is deterministic, bounded and excludes all prototype commerce/PII s const root = path.resolve(__dirname, '..'); const a = createBundle(root); const b = createBundle(root); assert.equal(a.digest, b.digest); assert.ok(a.zip.length < 1_000_000); - assert.deepEqual(FILES, ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'communityDuesAssociation.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']); + assert.deepEqual(FILES, ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'communityDuesAssociation.js', 'fictionalRaceService.js', 'fictionalRaceTransitions.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']); assert.ok(!FILES.includes('index.js') && !FILES.includes('stripeHelpers.js')); }); +test('isolated entrypoint selects the explicit project and exports only the existing community callable', () => { + const fs = require('node:fs'); const vm = require('node:vm'); const selected = []; + const callable = {}; const exports = {}; + vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../functions/communityOnly.js'), 'utf8'), { + process: { env: { GCLOUD_PROJECT: 'run-mprc-staging' } }, exports, + require: name => { + if (name === 'firebase-admin') return { initializeApp: options => selected.push(JSON.parse(JSON.stringify(options))) }; + assert.equal(name, './communityActions'); return { assertCommunityEnvironment: () => selected.push('guard'), communityActions: callable }; + }, + }); + assert.deepEqual(selected, ['guard', { projectId: 'run-mprc-staging' }]); assert.deepEqual(exports, { communityActions: callable }); +}); +test('deterministic archive contains every relative runtime import including race helpers, never tests', () => { + const fs = require('node:fs'); + for (const name of FILES.filter(file => file.endsWith('.js'))) { + const source = fs.readFileSync(path.resolve(__dirname, '../functions', name), 'utf8'); + for (const match of source.matchAll(/require\(['"]\.\/([^'"]+)['"]\)/g)) { + const dependency = path.extname(match[1]) ? match[1] : match[1] + '.js'; + assert.ok(FILES.includes(dependency), `${name} omitted ${dependency}`); + } + } + assert.ok(FILES.every(name => !name.includes('test') && !name.includes('index.js'))); +}); test('fixed staging specification and provider readback reject drift', () => { const spec = functionSpec('a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'); assert.ok(spec.name.endsWith('/communityActions') && spec.name.includes('/run-mprc-staging/'));