From 92ffdd4a67ee1ba14cfbba9d6547bf340ae23303 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 13 Oct 2026 12:23:37 -0700 Subject: [PATCH 1/2] feat(staging): prepare private fictional race member screen --- .github/lint-baseline.json | 13 +- GITHUB_ISSUES.md | 2 + IMPLEMENTATION_PLAN.md | 2 + OFFICER_START_HERE.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/README.md | 2 + docs/officers/STAGING_COMMUNITY.md | 31 ++ docs/officers/SYSTEM_MAPS.md | 16 + src/App.jsx | 2 + src/pages/community/Community.tsx | 2 + src/pages/community/FictionalRaces.test.tsx | 466 ++++++++++++++++++ src/pages/community/FictionalRaces.tsx | 426 ++++++++++++++++ .../community/fictionalRaceService.test.ts | 312 ++++++++++++ .../community/fictionalRaceService.ts | 251 ++++++++++ .../testSupport/fictionalRaceFixtures.ts | 98 ++++ 19 files changed, 1631 insertions(+), 4 deletions(-) create mode 100644 src/pages/community/FictionalRaces.test.tsx create mode 100644 src/pages/community/FictionalRaces.tsx create mode 100644 src/services/community/fictionalRaceService.test.ts create mode 100644 src/services/community/fictionalRaceService.ts create mode 100644 src/services/community/testSupport/fictionalRaceFixtures.ts diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index f11a093..56e4ff3 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -10,12 +10,12 @@ "targets": [ "src" ], - "scannedFileCount": 126, + "scannedFileCount": 131, "scannedFilesByExtension": { ".js": 12, ".jsx": 35, - ".ts": 45, - ".tsx": 34 + ".ts": 48, + ".tsx": 36 }, "errorCount": 113, "warningCount": 6, @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:4edc2ab0b2f1376f47775011308236333cdd8e676b4cc325a1f55691fe000be6", + "fingerprint": "sha256:5ba90c83db63c37a2a3cf738e5d923ba77d172937d79153896e5a2eea3133878", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -83,6 +83,8 @@ "src/pages/community/Community.tsx", "src/pages/community/CommunityAdmin.test.tsx", "src/pages/community/CommunityAdmin.tsx", + "src/pages/community/FictionalRaces.test.tsx", + "src/pages/community/FictionalRaces.tsx", "src/pages/contact/Contact.jsx", "src/pages/events/EventCalendar.tsx", "src/pages/events/EventDetail.tsx", @@ -128,6 +130,9 @@ "src/services/analytics/analytics.ts", "src/services/community/communityService.test.ts", "src/services/community/communityService.ts", + "src/services/community/fictionalRaceService.test.ts", + "src/services/community/fictionalRaceService.ts", + "src/services/community/testSupport/fictionalRaceFixtures.ts", "src/services/events/adminService.ts", "src/services/events/eventsService.ts", "src/services/firebase/FirebaseResources.test.ts", diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 5949f0b..1bc539d 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**RACE-SIM-001C / #731 — CLIENT SOURCE PREPARED, OPEN:** closed staging request/status/ack parsing and `/community/races` screen cover synthetic signup, cancellation/full credit return, private offer/recipient waiver acceptance/revoke, exact retry and context/stale-state clearing. No migration, money/provider action, dependency/service/workflow/IAM/Rules allowance. Officer impact: planned supervised fictional screen, NOT AVAILABLE YET. Officer documentation: STAGING_COMMUNITY, entrypoints, EVENTS_SHOP_MEMBERS and SYSTEM_MAPS. Deployment evidence: local doubles/full checks only at this milestone; genuine native security, exact backend-first Function/Hosting release, actual screens and fenced cleanup remain required. #732's backend source is merged; neither source milestone closes #731 or the full backend goal. + **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. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 6b03c6a..f81ab79 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,7 @@ # MPRC Secure Commerce Implementation Plan +**#731 client milestone — SOURCE PREPARED:** strict staging client and minimal member screen now have local positive/negative/context/failure/retry/expiry tests. Review and merge this source independently of deployment. Next verify all six exact current-main jobs/every step, deploy/read back the changed backend archive first, then publish staging Hosting and finish genuine security/actual UI/mobile/failure/expiry/owned cleanup. No new service/dependency/workflow/IAM or production/payment work; #731 and the broader backend goal remain open until native acceptance. + **#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. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index c49ced8..443d229 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,5 +1,7 @@ # MPRC Website: Officer Start Here +**#731 fictional race screen — SOURCE PREPARED, NOT AVAILABLE YET:** the [planned review](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) covers signup, fictional credit return and private recipient-accepted bib transfer. Local screen tests are not hosted proof. Dave/platform specialist must verify staging release, security, real screens and cleanup first; production and real payments remain untouched. + **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. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 6db9ba0..661522a 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**#731 member screen — SOURCE PREPARED, NOT AVAILABLE YET:** use the [planned no-terminal review](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) only after the specialist supplies native release/security/UI/cleanup proof and fresh private fixtures. Keep the same fixed staging target and resource limits; no new polling, scheduler or financial provider. Account/permission/config supply, backend-first deployment/readback and fenced cleanup/rollback remain specialist-only. Existing v5/Hosting485be670 are historical deployed proof, not race-screen proof; production and real payments stay untouched. + **#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. diff --git a/SECURITY.md b/SECURITY.md index 4b9551b..cf5c83b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # MPRC Security, Privacy, and Risk Plan +**#731 client — SOURCE PREPARED, NOT DEPLOYED:** closed plain-object/array parsing rejects extra fields, accessors, coercions, malformed bounds and mismatched request acknowledgements before display. UI/transport bind the same verified disposable SDK account/app/default tenant; observed session epochs fence even same-UID renewals. No token extraction/logging, direct database access or runner directory. Failed/uncertain reads and known deadlines remove old controls; exact saved retries never cross account/session/provider changes. These local tests use SDK/service doubles, not genuine hosted Auth/App Check proof; server authority, native release and hosted acceptance remain required. + **#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. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 88270ee..ba28b8d 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,5 +1,7 @@ # Stripe, Race Registration, and Merchandise Design +**#731 member screen — SOURCE PREPARED, NOT DEPLOYED:** UI labels all amounts and returns fictional credit, never paid/refunded money. It accepts no client price, deadline, finance role or runner profile; transfer keeps the original credit owner. No provider SDK, Checkout, payment/refund object or real rule is added. Hosted staging release/UI/security/cleanup remain open; finance/MFA/provider reconciliation and production gates are unchanged. + **#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. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index c9ca8a6..ddd0357 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**#731 member screen — SOURCE PREPARED, NOT DEPLOYED:** a separate `/community/races` route/link uses the same staging service, exact bounded DTOs and server-supplied entry IDs/revisions. Same Auth/Functions app, verified disposable caller, default tenant and session/provider generation fences prevent late cross-context display/retry. Failed reads hide old controls; saved commands retry exactly; acceptance rereads own status without the consumed offer. Conservative deadline timers clear status without polling. No migration, runtime/Rules/IAM/service/dependency change or money action; genuine native security, backend-first release, actual screens and fenced cleanup remain open. + **#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. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index ef29841..a1aa5ac 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1,5 +1,7 @@ # Events, Shop, Members, and Money +**#731 member screen — SOURCE PREPARED, NOT AVAILABLE YET:** the [planned screen review](./STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) covers fictional signup, credit return and privately named bib offers/acceptance/revocation. Native staging release/security/screens/cleanup still require the specialist; local service doubles do not prove hosted behavior. No real refund, payment, club policy or production change is available. + **#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. diff --git a/docs/officers/README.md b/docs/officers/README.md index 72e5031..5674434 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,5 +1,7 @@ # Officer Website Handbook +[#731's fictional race screen](./STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) is source prepared, **NOT AVAILABLE YET**. A separate dated native release/security/screen/cleanup receipt is required before the planned review. No real payment, policy or production change is supplied. + [#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. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index c60f559..62b2b18 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -27,6 +27,37 @@ ## What the actions mean +## Fictional race member screen — #731 source prepared + +**Status:** SOURCE PREPARED, **NOT AVAILABLE YET**. The staging-only `/community/races` screen has signup, simulated credit return, private bib offers, acceptance and revocation in source. Local screen tests use service doubles, not genuine hosted identities or attestation. The #732 section describes the earlier backend milestone. No race release or hosted cleanup proof is claimed here. + +**Purpose:** review the intended test flow without mistaking fictional credit for money. **Approver/escalation:** Dave, sole staging owner; no backup is appointed. **Prerequisites:** Dave/platform specialist must first supply dated native security, exact backend-first Function/Hosting release, disposable screen and fenced cleanup evidence. Fresh verified disposable accounts, expiring permissions and private test IDs are separate prerequisites. Until then, do not try these steps on a website. + +Planned supervised screen review — **NOT AVAILABLE YET**: + +1. Open only the supplied staging `/community/races` link. +2. Confirm **Staging test only** and fictional credit, not money. +3. Sign in as the supplied verified disposable account. +4. Accept the synthetic test waiver and select **Join fictional race** once. +5. Confirm your enrollment and private test entry ID. +6. For cancellation, select **Cancel and simulate full credit return**. +7. Confirm a cancelled entry and fictional credit return, not a bank refund. +8. For a separate transfer test, enter the supplied **Private disposable recipient ID**. +9. Select **Offer fictional bib transfer**. +10. Supply the exact test entry ID privately to the test recipient. +11. Sign out, then sign in as that disposable recipient. +12. Enter the **Private incoming test entry ID** and select **Check named offer**. +13. Accept the recipient's synthetic waiver and select **Accept fictional bib transfer** once. +14. Confirm your own entry appears and the incoming offer disappears. +15. For a separate withdrawal test, the original runner selects **Revoke fictional offer** before cancellation. +16. If uncertain, use **Retry saved action** or refresh; do not start another action. +17. If a named offer is unavailable, select **Show only my entries**, not another guessed ID. +18. Sign out and ask for the exact disposable cleanup receipt. + +**Expected result:** only your own entry or an exactly named offer to you is shown. Transfer preserves the bib and original credit owner; it creates no second place. One enrollment ever and one transfer are test limits, not club policy. Old controls disappear after a failed read or known deadline. An exact saved action may be retried only within the same account/session; fresh successful status restores controls. No polling, email, directory, card or provider payment is added. + +**Stop:** production, real person/payment information, missing permission, uncertain private ID, stale controls, real-money claims, or requests for secrets/record edits. **Success proof:** #731 must separately record source/full checks, merge, native Auth/App Check/security/archive/resource/unchanged profiles, Hosting marker, actual failure/retry/expiry/privacy/mobile screens, and fenced cleanup. This source review was read as an officer without a terminal; hosted steps are not qualified yet. **Undo:** sign out; Dave/specialist handles reviewed staging rollback or fixture cleanup. Setup, provider verification, permission revocation, cleanup and rollback remain specialist-only. Never post passwords, tokens, codes or member records to an issue or AI tool. + ## 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. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 5edff19..f1adf4c 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 member screen — #731 source prepared, not deployed + +```mermaid +flowchart LR + Account["Same verified disposable staging session"] --> Screen["Private race screen; no runner directory"] + Screen --> Read["Strict bounded status or exact saved command"] + Read --> Server["Existing native Auth/App Check and server authority"] + Server --> Fresh["Fresh own status; acceptance consumes incoming offer"] + Fresh --> Screen + Change["Account/session change, failed read or known deadline"] --> Hide["Clear old controls; never reuse another context's action"] + Hide --> Screen + Gate["Exact native release, hosted UI/security and cleanup still required"] -.-> Screen +``` + +Text alternative: the source-only staging screen submits bounded private reads and exact saved commands through the existing server boundary, rereads own status after acceptance, and clears old controls on uncertainty or context changes; native release, hosted behavior and cleanup remain unverified. + ## Fictional race boundary — #732 source and demo only ```mermaid diff --git a/src/App.jsx b/src/App.jsx index a1b4cdc..94dd1dd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -31,6 +31,7 @@ import Terms from './pages/legal/Terms'; import Privacy from './pages/legal/Privacy'; import Community from './pages/community/Community'; import CommunityAdmin from './pages/community/CommunityAdmin'; +import FictionalRaces from './pages/community/FictionalRaces'; import { COMMUNITY_ENABLED } from './services/community/communityService'; // Admin pages are lazy-loaded so public visitors don't download the admin JS bundle. @@ -90,6 +91,7 @@ function App() { } /> {COMMUNITY_ENABLED && } />} {COMMUNITY_ENABLED && } />} + {COMMUNITY_ENABLED && } />} } /> } /> } /> diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 78fe06b..80fd7b3 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -123,6 +123,8 @@ export default function Community() { Events {' · '} Officer tools (staging) + {' · '} + Fictional races (staging)

{' '} diff --git a/src/pages/community/FictionalRaces.test.tsx b/src/pages/community/FictionalRaces.test.tsx new file mode 100644 index 0000000..3eba3a6 --- /dev/null +++ b/src/pages/community/FictionalRaces.test.tsx @@ -0,0 +1,466 @@ +import React from 'react'; +import { + act, fireEvent, render, screen, waitFor +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { HelmetProvider } from 'react-helmet-async'; +import FictionalRaces from './FictionalRaces'; +import { readFictionalStatus, sendFictionalCommand } from '../../services/community/fictionalRaceService'; +import { + state, uid, recipient, entryId +} from '../../services/community/testSupport/fictionalRaceFixtures'; + +let mockUser: any; +let mockServices: any; +let mockSessionCallback: (user: any) => void; +let mockSession: any; +const mockSignOut = jest.fn(); +let mockCommunityEnabled = true; +jest.mock('firebase/auth', () => ({ + onIdTokenChanged: (auth: any, callback: (user: any) => void) => { + mockSessionCallback = callback; + callback(auth.currentUser); + return jest.fn(); + } +})); +jest.mock('../../services/hooks/useAuth', () => ({ + useAuth: () => ({ + user: mockUser, + isLoading: false, + signOut: mockSignOut + }) +})); +jest.mock('../../services/ServiceLocatorContext', () => ({ + useServiceLocator: () => ({ + services: mockServices + }) +})); +jest.mock('../../services/community/communityService', () => ({ + get COMMUNITY_ENABLED() { return mockCommunityEnabled; } +})); +jest.mock('../../services/community/fictionalRaceService', () => ({ + ...jest.requireActual('../../services/community/fictionalRaceService'), + readFictionalStatus: jest.fn(), + sendFictionalCommand: jest.fn() +})); +const read = readFictionalStatus as jest.Mock; +const send = sendFictionalCommand as jest.Mock; +const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); +const show = () => ( + + + + + +); +beforeEach(() => { + jest.clearAllMocks(); + mockCommunityEnabled = true; + mockUser = { + uid + }; + mockSession = { + uid, + emailVerified: true + }; + const app = { + options: { + projectId: 'run-mprc-staging' + } + }; + mockServices = { + firebaseResources: { + auth: { + app, + tenantId: null, + currentUser: mockSession + }, + functions: { + app + } + } + }; + read.mockResolvedValue(state()); + send.mockResolvedValue({}); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + randomUUID: () => '123e4567-e89b-42d3-a456-426614174000' + } + }); +}); +afterEach(() => jest.useRealTimers()); +afterAll(() => { + if (originalCrypto) Object.defineProperty(globalThis, 'crypto', originalCrypto); else Reflect.deleteProperty(globalThis, 'crypto'); +}); +async function ready(kind: Parameters[0] = 'none') { + read.mockResolvedValue(state(kind)); + render(show()); + await screen.findByText(/Your enrollment:/); +} +test('signed-out and production screens never call backend', () => { + mockUser = null; + mockServices.firebaseResources.auth.currentUser = null; + const view = render(show()); + expect(screen.getByRole('link', { + name: 'Sign in to continue' + })).toBeInTheDocument(); + expect(read).not.toHaveBeenCalled(); + mockUser = { + uid + }; + mockServices.firebaseResources.functions.app.options.projectId = 'production'; + view.rerender(show()); + expect(screen.getByText('Fictional races are unavailable here')).toBeInTheDocument(); + expect(read).not.toHaveBeenCalled(); +}); +test('unverified or mismatched native session hides mutations and makes no read', () => { + mockSession.emailVerified = false; + const view = render(show()); + expect(read).not.toHaveBeenCalled(); + mockSession.emailVerified = true; + mockUser = { + uid: recipient + }; + view.rerender(show()); + expect(read).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { + name: 'Join fictional race' + })).not.toBeInTheDocument(); +}); +test.each(['split-app', 'tenant', 'feature-off'])('disabled %s screen never calls backend', (kind) => { + if (kind === 'split-app') { + mockServices.firebaseResources.auth.app = { ...mockServices.firebaseResources.functions.app }; + } else if (kind === 'tenant') mockServices.firebaseResources.auth.tenantId = 'other-tenant'; + else mockCommunityEnabled = false; + render(show()); + expect(screen.getByText('Fictional races are unavailable here')).toBeInTheDocument(); + expect(read).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); +}); +test('UUID generation failure sends no command and preserves the current screen', async () => { + await ready('confirmed'); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: () => { throw new Error('synthetic unsupported operation'); } } + }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel and simulate full credit return' })); + await screen.findByText(/up-to-date browser/); + expect(send).not.toHaveBeenCalled(); + expect(screen.getByText('Your enrollment: confirmed.')).toBeInTheDocument(); +}); +test('account switch hides old private entry and rejects its late read', async () => { + let resolve!: (value: unknown) => void; + read.mockReturnValueOnce(new Promise((value) => { resolve = value; })); + const view = render(show()); + await waitFor(() => expect(read).toHaveBeenCalledTimes(1)); + mockUser = { uid: recipient }; + mockSession = { uid: recipient, emailVerified: true }; + mockServices.firebaseResources.auth.currentUser = mockSession; + read.mockResolvedValue(state()); + act(() => mockSessionCallback(mockSession)); + view.rerender(show()); + await screen.findByRole('button', { name: 'Join fictional race' }); + await act(async () => resolve(state('confirmed'))); + expect(screen.queryByText(entryId)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel and simulate full credit return' })).not.toBeInTheDocument(); + expect(read).toHaveBeenLastCalledWith( + mockServices.firebaseResources.functions, + mockServices.firebaseResources.auth, + recipient, + null + ); +}); +test('read failure after permission loss removes all previous private entry controls', async () => { + await ready('confirmed'); + read.mockRejectedValueOnce(Error('synthetic revoked permission')); + fireEvent.click(screen.getByRole('button', { name: 'Refresh test status' })); + await screen.findByText(/Fictional race status is unavailable/); + expect(screen.queryByText(entryId)).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Private disposable recipient ID')).not.toBeInTheDocument(); + expect(send).not.toHaveBeenCalled(); +}); +test('join requires fictional waiver, uses only server IDs/revisions and rereads current status', async () => { + await ready(); + const join = screen.getByRole('button', { + name: 'Join fictional race' + }); + expect(join).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox', { + name: 'I accept the fictional test waiver.' + })); + read.mockResolvedValue(state('confirmed')); + fireEvent.click(join); + await screen.findByText('Your enrollment: confirmed.'); + expect(send).toHaveBeenCalledWith( + mockServices.firebaseResources.functions, + mockServices.firebaseResources.auth, + { + action: 'race_join', + accountUid: uid, + requestId: '123e4567-e89b-42d3-a456-426614174000', + eventId: 'stage_race_test_race', + entryId: 'stage_race_new_entry', + expectedEventRevision: 1, + expectedEntryRevision: 0, + waiverAccepted: true, + waiverVersion: 'fictional-race-waiver-v1' + } + ); + expect(read).toHaveBeenLastCalledWith( + mockServices.firebaseResources.functions, + mockServices.firebaseResources.auth, + uid, + null + ); +}); +test('cancel simulates full credit return, then hides terminal/rejoin controls', async () => { + await ready('confirmed'); + read.mockResolvedValue(state('cancelled')); + fireEvent.click(screen.getByRole('button', { + name: 'Cancel and simulate full credit return' + })); + await screen.findByText('Your enrollment: cancelled.'); + expect(send.mock.calls[0][2]).toMatchObject({ + action: 'race_cancel', + expectedEntryRevision: 1 + }); + expect(screen.getByText(/Returned: 10.00 fictional USD credit/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { + name: 'Join fictional race' + })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { + name: 'Cancel and simulate full credit return' + })).not.toBeInTheDocument(); +}); +test('private offer and projected owner revoke need no directory or recipient profile', async () => { + await ready('confirmed'); + const input = screen.getByLabelText('Private disposable recipient ID'); + fireEvent.change(input, { + target: { + value: 'real@example.com' + } + }); + expect(screen.getByRole('button', { + name: 'Offer fictional bib transfer' + })).toBeDisabled(); + fireEvent.change(input, { + target: { + value: recipient + } + }); + read.mockResolvedValue(state('offered')); + fireEvent.click(screen.getByRole('button', { + name: 'Offer fictional bib transfer' + })); + await screen.findByRole('button', { + name: 'Revoke fictional offer' + }); + expect(send.mock.calls[0][2]).toMatchObject({ + action: 'race_offer', + recipientUid: recipient, + expectedEntryRevision: 1 + }); + expect(send.mock.calls[0][2]).not.toHaveProperty('expiresAtMs'); + expect(screen.queryByLabelText('Private disposable recipient ID')).not.toBeInTheDocument(); + read.mockResolvedValue(state('confirmed')); + fireEvent.click(screen.getByRole('button', { + name: 'Revoke fictional offer' + })); + await screen.findByRole('button', { + name: 'Cancel and simulate full credit return' + }); + expect(send.mock.calls[1][2]).toMatchObject({ + action: 'race_revoke', + expectedEntryRevision: 2 + }); +}); +test('exact incoming offer + recipient waiver; acceptance rereads own status without consumed offer ID', async () => { + mockUser = { + uid: recipient + }; + mockSession.uid = recipient; + await ready(); + fireEvent.change(screen.getByLabelText('Private incoming test entry ID'), { + target: { + value: entryId + } + }); + read.mockResolvedValue(state('incoming')); + fireEvent.click(screen.getByRole('button', { + name: 'Check named offer' + })); + const accept = await screen.findByRole('button', { + name: 'Accept fictional bib transfer' + }); + expect(accept).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox', { + name: "I accept the recipient's fictional test waiver." + })); + const accepted = state('confirmed'); + accepted.events[0].entry!.isOriginalCreditOwner = false; + accepted.events[0].entry!.transferCount = 1; + accepted.events[0].entry!.canOffer = false; + read.mockResolvedValue(accepted); + fireEvent.click(accept); + await screen.findByText(/Your enrollment: confirmed/); + expect(send.mock.calls[0][2]).toMatchObject({ + action: 'race_accept', + accountUid: recipient, + entryId, + expectedEntryRevision: 2, + waiverAccepted: true + }); + expect(read).toHaveBeenLastCalledWith( + mockServices.firebaseResources.functions, + mockServices.firebaseResources.auth, + recipient, + null + ); + expect(screen.queryByRole('button', { + name: 'Accept fictional bib transfer' + })).not.toBeInTheDocument(); + expect(screen.getByText(/credit return stays with the original owner/)).toBeInTheDocument(); +}); +test('lost acknowledgement hides old status and retries identical immutable command before fresh read', async () => { + await ready('confirmed'); + send.mockRejectedValueOnce(Error('synthetic lost response')); + fireEvent.click(screen.getByRole('button', { + name: 'Cancel and simulate full credit return' + })); + const retry = await screen.findByRole('button', { + name: 'Retry saved action' + }); + await waitFor(() => expect(retry).toBeEnabled()); + expect(screen.queryByText(/Your enrollment:/)).not.toBeInTheDocument(); + const saved = send.mock.calls[0][2]; + send.mockResolvedValue({}); + read.mockResolvedValue(state('cancelled')); + fireEvent.click(retry); + await screen.findByText(/Your enrollment: cancelled/); + expect(send.mock.calls[1][2]).toEqual(saved); + expect(screen.queryByRole('button', { + name: 'Retry saved action' + })).not.toBeInTheDocument(); +}); +test('successful mutation followed by failed status keeps exact retry; manual refresh clears it only on success', async () => { + await ready('confirmed'); + read.mockRejectedValueOnce(Error('synthetic malformed/failed response')); + fireEvent.click(screen.getByRole('button', { + name: 'Cancel and simulate full credit return' + })); + await screen.findByRole('button', { + name: 'Retry saved action' + }); + expect(screen.queryByText(/Your enrollment:/)).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { + name: 'Refresh test status' + })).toBeEnabled()); + read.mockResolvedValue(state('cancelled')); + fireEvent.click(screen.getByRole('button', { + name: 'Refresh test status' + })); + await screen.findByText(/Your enrollment: cancelled/); + expect(send).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { + name: 'Retry saved action' + })).not.toBeInTheDocument(); +}); +test('failed incoming lookup clears old controls, preserves typed focus and allows explicit own-only recovery', async () => { + await ready('confirmed'); + fireEvent.change(screen.getByLabelText('Private incoming test entry ID'), { + target: { + value: entryId + } + }); + read.mockRejectedValueOnce(Error('synthetic denial')); + fireEvent.click(screen.getByRole('button', { + name: 'Check named offer' + })); + await screen.findByText(/Fictional race status is unavailable/); + expect(screen.getByLabelText('Private incoming test entry ID')).toHaveValue(entryId); + expect(screen.queryByText(/Your enrollment:/)).not.toBeInTheDocument(); + read.mockResolvedValue(state('confirmed')); + fireEvent.click(screen.getByRole('button', { + name: 'Show only my entries' + })); + await screen.findByText(/Your enrollment: confirmed/); + expect(screen.getByLabelText('Private incoming test entry ID')).toHaveValue(''); +}); +test('native same-UID token/session renewal fences late action and never schedules an old-context reread', async () => { + await ready('confirmed'); + let resolve!: (value: unknown) => void; + send.mockReturnValueOnce(new Promise((value) => { + resolve = value; + })); + fireEvent.click(screen.getByRole('button', { + name: 'Cancel and simulate full credit return' + })); + read.mockResolvedValue(state()); + act(() => mockSessionCallback(mockSession)); + await screen.findByRole('button', { + name: 'Join fictional race' + }); + const count = read.mock.calls.length; + await act(async () => resolve({})); + expect(read).toHaveBeenCalledTimes(count); + expect(screen.queryByRole('button', { + name: 'Retry saved action' + })).not.toBeInTheDocument(); +}); +test('provider swap cannot display late previous read or reuse saved action', async () => { + let resolve!: (value: unknown) => void; + read.mockReturnValueOnce(new Promise((value) => { + resolve = value; + })); + const view = render(show()); + await waitFor(() => expect(read).toHaveBeenCalledTimes(1)); + mockServices = { + ...mockServices + }; + read.mockResolvedValue(state()); + view.rerender(show()); + await screen.findByRole('button', { + name: 'Join fictional race' + }); + await act(async () => resolve(state('confirmed'))); + expect(screen.queryByRole('button', { + name: 'Cancel and simulate full credit return' + })).not.toBeInTheDocument(); +}); +test('known server deadline clears status without polling; no UUID support sends nothing', async () => { + jest.useFakeTimers(); + await ready(); + fireEvent.click(screen.getByRole('checkbox', { + name: 'I accept the fictional test waiver.' + })); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: {} + }); + fireEvent.click(screen.getByRole('button', { + name: 'Join fictional race' + })); + await screen.findByText(/up-to-date browser/); + expect(send).not.toHaveBeenCalled(); + const count = read.mock.calls.length; + act(() => jest.advanceTimersByTime(10001)); + expect(screen.queryByText(/Your enrollment:/)).not.toBeInTheDocument(); + expect(read).toHaveBeenCalledTimes(count); +}); +test('cancelled event preserves own confirmed history and cancellation, not new signup or transfer', async () => { + const cancelledEvent = state('confirmed'); + cancelledEvent.events[0].signupStatus = 'cancelled'; + cancelledEvent.events[0].entry!.canOffer = false; + read.mockResolvedValue(cancelledEvent); + render(show()); + await screen.findByText('Your enrollment: confirmed.'); + expect(screen.getByText(/Signup: cancelled/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel and simulate full credit return' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Join fictional race' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offer fictional bib transfer' })).not.toBeInTheDocument(); +}); diff --git a/src/pages/community/FictionalRaces.tsx b/src/pages/community/FictionalRaces.tsx new file mode 100644 index 0000000..dbf7b7d --- /dev/null +++ b/src/pages/community/FictionalRaces.tsx @@ -0,0 +1,426 @@ +import React, { + useEffect, useMemo, useRef, useState +} from 'react'; +import { onIdTokenChanged, User } from 'firebase/auth'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../../services/hooks/useAuth'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import { COMMUNITY_ENABLED } from '../../services/community/communityService'; +import { + RaceCommand, RaceEvent, RaceStatus, RACE_WAIVER, raceId, readFictionalStatus, sendFictionalCommand +} from '../../services/community/fictionalRaceService'; +import SEO from '../../components/SEO'; +import './Community.css'; + +type Draft = Omit; +const clock = () => performance.now(); +const credits = (cents: number) => `${(cents / 100).toFixed(2)} fictional USD credit`; +export default function FictionalRaces() { + const { + services + } = useServiceLocator(); + const { + user, + isLoading, + signOut + } = useAuth(); + const auth = services?.firebaseResources.auth; + const functions = services?.firebaseResources.functions; + const [session, setSession] = useState<{ + user: User; + epoch: symbol; + } | null>(null); + const enabled = COMMUNITY_ENABLED && functions?.app.options.projectId === 'run-mprc-staging' && auth?.app === functions?.app && auth?.tenantId === null; + const identity = useMemo(() => Symbol('fictional-race-context'), [services, auth, functions, auth?.app, functions?.app, session, user?.uid, enabled]); + const latestIdentity = useRef(identity); + latestIdentity.current = identity; + const generation = useRef(0); + const inFlight = useRef(false); + const pending = useRef(null); + const focus = useRef(null); + const [snapshot, setSnapshot] = useState<{ + identity: symbol; + value: RaceStatus; + began: number; + } | null>(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [incomingId, setIncomingId] = useState(''); + const [recipientIds, setRecipientIds] = useState>({}); + const [waivers, setWaivers] = useState>({}); + const current = () => enabled && auth && functions && session + && auth.currentUser === session.user && session.user.uid === user?.uid + && session.user.emailVerified && raceId(session.user.uid); + const status = snapshot?.identity === identity && current() ? snapshot.value : null; + function hide() { + setSnapshot(null); + setWaivers({}); + setRecipientIds({}); + } + async function perform(draft?: Draft, readOnly = false, incoming: string | null = focus.current) { + if (!current() || !auth || !functions || !session || inFlight.current) return; + if (draft && typeof globalThis.crypto?.randomUUID !== 'function') { + setMessage('Use an up-to-date browser to prepare a safe request.'); + return; + } + const captured = identity; + generation.current += 1; + const serial = generation.current; + const stillCurrent = () => latestIdentity.current === captured + && generation.current === serial && current(); + let readFocus = incoming; + let command = readOnly ? null : pending.current; + if (draft) { + try { + command = { ...draft, accountUid: session.user.uid, requestId: crypto.randomUUID() }; + } catch { + setMessage('Use an up-to-date browser to prepare a safe request.'); + return; + } + } + if (command) pending.current = command; + inFlight.current = true; + setBusy(true); + setMessage(''); + hide(); + try { + if (command) { + await sendFictionalCommand(functions, auth, command); + if (!stillCurrent()) return; + // The accepted offer is consumed. Reread own entry, never that old offer. + if (command.action === 'race_accept') readFocus = null; + } + const began = clock(); + const value = await readFictionalStatus(functions, auth, session.user.uid, readFocus); + if (!stillCurrent()) return; + focus.current = readFocus; + if (readFocus === null) setIncomingId(''); + setSnapshot({ + identity: captured, + value, + began + }); + pending.current = null; + setMessage(command ? 'Fictional action confirmed. Current test status is shown below.' : 'Test status refreshed.'); + } catch { + if (stillCurrent()) { + hide(); + setMessage(command ? 'We could not confirm this action. Retry saved action, or refresh current status. No real money moves.' : 'Fictional race status is unavailable. Check the test ID or ask the staging owner; refresh or sign in again.'); + } + } finally { + if (stillCurrent()) { + inFlight.current = false; + setBusy(false); + } + } + } + useEffect(() => { + if (!enabled || !auth) { + setSession(null); + return undefined; + } + return onIdTokenChanged(auth, (next) => { + // Observe session changes without reading/storing a token. An epoch also + // fences renewed credentials for the same UID / same SDK User object. + generation.current += 1; + pending.current = null; + inFlight.current = false; + hide(); + setBusy(false); + setIncomingId(''); + focus.current = null; + setSession(next ? { + user: next, + epoch: Symbol('native-auth-session') + } : null); + }); + }, [enabled, auth]); + useEffect(() => { + generation.current += 1; + pending.current = null; + inFlight.current = false; + hide(); + setBusy(false); + setMessage(''); + setIncomingId(''); + focus.current = null; + if (current()) perform(undefined, true, null); + return () => { + generation.current += 1; + }; + // Captured identity covers actor, native session, resources and provider. + }, [identity]); + useEffect(() => { + if (!status || !snapshot) return undefined; + const deadlines = [status.accessExpiresAtMs, ...status.events.flatMap((event) => [ + event.opensAtMs, event.closesAtMs, event.startAtMs, event.entry?.offerExpiresAtMs || 0 + ]), status.incoming?.offerExpiresAtMs || 0]; + const next = Math.min(...deadlines.filter((value) => value > status.observedAtMs)); + // Subtract the entire request elapsed time conservatively; no poll or extra + // read occurs. Server state/clock still decide each submitted action. + const delay = Math.max(0, next - status.observedAtMs - (clock() - snapshot.began)); + const timer = setTimeout(() => { + hide(); + setMessage('Test status has expired. Refresh before choosing an action.'); + }, delay); + return () => clearTimeout(timer); + }, [status, snapshot]); + function performEvent(event: RaceEvent, action: Draft['action'], extra: Partial = {}) { + const entryId = action === 'race_join' ? event.joinEntryId : event.entry?.entryId; + if (!entryId) return; + perform({ + action, + eventId: event.eventId, + entryId, + expectedEventRevision: event.revision, + expectedEntryRevision: action === 'race_join' ? 0 : event.entry!.revision, + ...extra + }); + } + if (!enabled) { + return ( +
+

Fictional races are unavailable here

+ My Account +
+ ); + } + const note =

Staging test only. Made-up accounts and fictional credits, not money. No real race entry, payment or refund. Test rules: one enrollment ever and one transfer.

; + if (isLoading) return

Loading account…

; + if (!user) { + return ( +
+ +

Fictional races

+ {note} + + Sign in to continue + +
+ ); + } + const blocked = busy || pending.current !== null || !current(); + return ( +
+ +

Fictional races

+ {note} +

+ Membership and free events + {' · '} + My Account +

+ + + {pending.current && ( + +)} + + {!current() && ( +

+ A verified disposable staging account and current permission are + required. Sign in again or ask the staging owner. +

+)} + {message &&

{message}

} +
{ + event.preventDefault(); + perform(undefined, true, incomingId); + }} + > + + +

+ No runner search or offer directory. Use only an ID supplied privately + for this test. +

+
+ {status && ( + <> + {status.events.length === 0 &&

No fictional races are configured.

} + {status.events.map((event) => ( +
+

+ Test race + {' '} + {event.eventId} +

+

{new Date(event.startAtMs).toLocaleString()}

+

+ Signup: + {' '} + {event.signupStatus.replace(/_/g, ' ')} + . Availability is checked again by the server. +

+

+ Fictional signup credit: + {' '} + {credits(event.fictionalAmountCents)} + . +

+

+ Your enrollment: + {' '} + {event.enrollment?.status || 'none'} + . +

+ {event.canJoin && ( + <> +

SYNTHETIC TEST WAIVER ONLY. Not an approved club waiver or real race.

+ + + +)} + {event.entry && ( + <> +

+ Private test entry ID: + {' '} + {event.entry.entryId} + . Bib: + {' '} + {event.entry.bibId} + . +

+

+ Entry: + {' '} + {event.entry.status} + . Returned: + {' '} + {credits(event.entry.fictionalReturnedCents)} + . +

+

+ {event.entry.isOriginalCreditOwner ? 'The original fictional credit belongs to you.' : 'Any fictional credit return stays with the original owner, not the new runner.'} +

+ {event.entry.offerExpiresAtMs !== null && ( +

+ Offer deadline: + {' '} + {new Date(event.entry.offerExpiresAtMs).toLocaleString()} + . Revoke the offer before cancelling. +

+)} + {event.entry.canCancel && ( + +)} + {event.entry.canRevoke && ( + +)} + {event.entry.canOffer && ( +
{ + submit.preventDefault(); + performEvent(event, 'race_offer', { + recipientUid: recipientIds[event.eventId] + }); + }} + > + + +
+)} + +)} +
+))} + {status.incoming && ( +
+

Named incoming test offer

+

+ Entry: + {status.incoming.entryId} + . Same bib, no new seat or real money. + Original credit remains with its original owner. +

+

SYNTHETIC TEST WAIVER ONLY. Not an approved club waiver or real race.

+ + +
+)} + +)} +
+ ); +} diff --git a/src/services/community/fictionalRaceService.test.ts b/src/services/community/fictionalRaceService.test.ts new file mode 100644 index 0000000..b6e09bb --- /dev/null +++ b/src/services/community/fictionalRaceService.test.ts @@ -0,0 +1,312 @@ +import { httpsCallable } from 'firebase/functions'; +import { + parseRaceCommand, parseRaceResult, parseRaceStatus, readFictionalStatus, sendFictionalCommand +} from './fictionalRaceService'; +import { + command, result, state, uid, entryId +} from './testSupport/fictionalRaceFixtures'; + +let mockCommunityEnabled = true; +jest.mock('./communityService', () => ({ + get COMMUNITY_ENABLED() { return mockCommunityEnabled; } +})); +jest.mock('firebase/functions', () => ({ + httpsCallable: jest.fn() +})); +const callable = httpsCallable as jest.Mock; +let rpc: jest.Mock; +let app: any; +let auth: any; +let functions: any; +beforeEach(() => { + jest.clearAllMocks(); + mockCommunityEnabled = true; + app = { + options: { + projectId: 'run-mprc-staging' + } + }; + auth = { + app, + tenantId: null, + currentUser: { + uid, + emailVerified: true + } + }; + functions = { + app + }; + rpc = jest.fn(async () => ({ + data: state() + })); + callable.mockReturnValue(rpc); +}); +test.each(['none', 'confirmed', 'offered', 'cancelled', 'incoming', 'transferred'] as const)('valid minimal projection %s is copied, frozen and private', (kind) => { + const raw = state(kind); + const parsed = parseRaceStatus(raw, kind === 'incoming' ? entryId : null); + expect(parsed).toEqual(raw); + expect(parsed).not.toBe(raw); + expect(parsed.events).not.toBe(raw.events); + expect(Object.isFrozen(parsed.events)).toBe(true); + expect(Object.isFrozen(parsed.events[0])).toBe(true); +}); +test.each([{ + schemaVersion: 2 +}, { + simulationOnly: false +}, { + policyVersion: 'paid' +}, { + observedAtMs: '1800000000000' +}, { + accessExpiresAtMs: 1800000000000 +}, { + incoming: {} +}, { + events: null +}, { + uid: 'other' +}, { + totals: [] +}])('malformed top-level response fails fixed %#', (extra) => { + expect(() => parseRaceStatus({ + ...state(), + ...extra + }, null)).toThrow('Fictional race status is unavailable.'); +}); +test.each([{ + eventId: 'real-race' +}, { + revision: 0 +}, { + currency: 'EUR' +}, { + fictionalAmountCents: 1.5 +}, { + fictionalAmountCents: 10001 +}, { + canJoin: 'true' +}, { + signupStatus: 'maybe' +}, { + signupStatus: 'ended' +}, { + waiverVersion: 'actual-waiver' +}, { + closesAtMs: 1800000030000 +}, { + joinEntryId: null +}, { + confirmedCount: 1 +}, { + enrollment: { + status: 'confirmed', + revision: 1 + } +}])('bad event projection fails closed %#', (extra) => { + const raw = state(); + raw.events[0] = { + ...raw.events[0], + ...extra + } as any; + expect(() => parseRaceStatus(raw, null)).toThrow('Fictional race status is unavailable.'); +}); +test.each([{ + fictionalReturnedCents: 1000 +}, { + canCancel: false +}, { + transferCount: '0' +}, { + isOriginalCreditOwner: false +}, { + runnerUid: 'other' +}, { + offerExpiresAtMs: 1800000030000 +}])('impossible entry or private field rejects %#', (extra) => { + const raw = state('confirmed'); + raw.events[0].entry = { + ...raw.events[0].entry, + ...extra + } as any; + expect(() => parseRaceStatus(raw, null)).toThrow(); +}); +test('exact requested incoming ID/event/revision/recipient state required, not any offer', () => { + const raw = state('incoming'); + expect(() => parseRaceStatus(raw, 'stage_race_other')).toThrow(); + expect(() => parseRaceStatus(raw, null)).toThrow(); + [{ + eventRevision: 3 + }, { + canAccept: false + }, { + isCurrentRunner: true + }, { + offerExpiresAtMs: raw.observedAtMs + }, { + eventId: 'stage_race_other' + }].forEach((extra) => { + expect(() => parseRaceStatus({ + ...raw, + incoming: { + ...raw.incoming, + ...extra + } + }, entryId)).toThrow(); + }); +}); +test('twenty unique events only; sparse/extra/accessor arrays reject without reading getters', () => { + const raw = state(); + raw.events = Array.from({ + length: 20 + }, (_, n) => ({ + ...state().events[0], + eventId: `stage_race_${n}` + })); + expect(parseRaceStatus(raw, null).events).toHaveLength(20); + expect(() => parseRaceStatus({ + ...raw, + events: [...raw.events, state().events[0]] + }, null)).toThrow(); + expect(() => parseRaceStatus({ + ...raw, + events: [raw.events[0], raw.events[0]] + }, null)).toThrow(); + const getter = jest.fn(); + const hostile = [state().events[0]]; + Object.defineProperty(hostile, '0', { + enumerable: true, + get: getter + }); + expect(() => parseRaceStatus({ + ...state(), + events: hostile + }, null)).toThrow(); + expect(getter).not.toHaveBeenCalled(); + expect(() => parseRaceStatus({ + ...state(), + events: new Array(1) + }, null)).toThrow(); +}); +test('getters/coercion hooks/prototypes never become display or request authority', () => { + const getter = jest.fn(); + const raw = state(); + Object.defineProperty(raw, 'policyVersion', { + get: getter, + enumerable: true + }); + expect(() => parseRaceStatus(raw, null)).toThrow(); + const req = command(); + Object.defineProperty(req, 'action', { + get: getter, + enumerable: true + }); + expect(() => parseRaceCommand(req)).toThrow(); + const coercion = { + toString: getter + }; + const event = { + ...state().events[0], + signupStatus: coercion + }; + expect(() => parseRaceStatus({ + ...state(), + events: [event] + }, null)).toThrow(); + expect(getter).not.toHaveBeenCalled(); + expect(() => parseRaceStatus(Object.assign(Object.create({ + private: true + }), state()), null)).toThrow(); +}); +test.each(['race_join', 'race_cancel', 'race_offer', 'race_accept', 'race_revoke'] as const)('exact request/ack contract %s', (action) => { + const req = command(action); + expect(parseRaceCommand(req)).toEqual(req); + expect(parseRaceResult(result(req), req)).toEqual(result(req)); + [{ + action: 'paid' + }, { + entryId: 'stage_race_other' + }, { + entryRevision: 99 + }, { + eventRevision: 99 + }, { + paid: true + }].forEach((extra) => { + expect(() => parseRaceResult({ + ...result(req), + ...extra + }, req)).toThrow(); + }); +}); +test.each(['amountCents', 'expiresAtMs', 'role', 'uid', 'currency', 'email_verified'])('client authority field %s denies before SDK', async (key) => { + await expect(sendFictionalCommand(functions, auth, { + ...command(), + [key]: 'forged' + } as any)).rejects.toThrow(); + expect(callable).not.toHaveBeenCalled(); +}); +test('verified current app/account only, without token/profile/roster extraction', async () => { + expect(await readFictionalStatus(functions, auth, uid)).toEqual(state()); + expect(callable).toHaveBeenCalledWith(functions, 'communityActions'); + expect(rpc).toHaveBeenCalledWith({ + action: 'race_status', + accountUid: uid, + incomingEntryId: null + }); + rpc.mockResolvedValueOnce({ + data: result(command()) + }); + expect(await sendFictionalCommand(functions, auth, command())).toEqual(result(command())); +}); +test.each(['production', 'other-app', 'signed-out', 'unverified', 'wrong-uid', 'tenant', 'feature-off'])('disabled context %s never calls native RPC', async (kind) => { + if (kind === 'production') app.options.projectId = 'production'; + if (kind === 'other-app') { auth.app = { + ...app + }; } + if (kind === 'signed-out') auth.currentUser = null; + if (kind === 'unverified') auth.currentUser.emailVerified = false; + if (kind === 'wrong-uid') auth.currentUser.uid = 'stage_race_other'; + if (kind === 'tenant') auth.tenantId = 'other-tenant'; + if (kind === 'feature-off') mockCommunityEnabled = false; + await expect(readFictionalStatus(functions, auth, uid)).rejects.toThrow(); + expect(callable).not.toHaveBeenCalled(); +}); +test('late response after same-UID renewed SDK session or app switch is refused', async () => { + let resolve!: (value: unknown) => void; + rpc.mockReturnValue(new Promise((value) => { + resolve = value; + })); + const original = auth.currentUser; + const first = readFictionalStatus(functions, auth, uid); + auth.currentUser = { + ...original + }; + resolve({ + data: state() + }); + await expect(first).rejects.toThrow(); + auth.currentUser = original; + rpc.mockImplementation(async () => { + functions.app = { + ...app + }; + return { + data: state() + }; + }); + await expect(readFictionalStatus(functions, auth, uid)).rejects.toThrow(); +}); +test('transport/provider error stays fixed with no raw response/body/token logging', async () => { + rpc.mockRejectedValue(Error('private provider response')); + await expect(readFictionalStatus(functions, auth, uid)).rejects.toThrow('Fictional race status is unavailable.'); +}); +test('expired incoming offer denies while owner can still withdraw the projected offer', () => { + const offered = state('offered'); + offered.observedAtMs += 6000; + expect(parseRaceStatus(offered, null).events[0].entry!.canRevoke).toBe(true); + const incoming = state('incoming'); + incoming.observedAtMs += 6000; + expect(() => parseRaceStatus(incoming, entryId)).toThrow('Fictional race status is unavailable.'); +}); diff --git a/src/services/community/fictionalRaceService.ts b/src/services/community/fictionalRaceService.ts new file mode 100644 index 0000000..ec810bb --- /dev/null +++ b/src/services/community/fictionalRaceService.ts @@ -0,0 +1,251 @@ +import { Auth } from 'firebase/auth'; +import { Functions, httpsCallable } from 'firebase/functions'; +import { COMMUNITY_ENABLED } from './communityService'; + +export const RACE_POLICY = 'fictional-race-v1-not-money'; +export const RACE_WAIVER = 'fictional-race-waiver-v1'; +export const raceId = (value: unknown): value is string => typeof value === 'string' && /^stage_race_[A-Za-z0-9_-]{1,64}$/.test(value); +const MESSAGE = 'Fictional race status is unavailable. Refresh or sign in again.'; +const check = (value: unknown) => { + if (!value) throw new Error(MESSAGE); +}; +const integer = (value: unknown, max = 8640000000000000): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max; +const revision = (value: unknown) => integer(value, 999999) && value > 0; +type RecordValue = Record; +function object(value: unknown, keys: string[]): RecordValue { + check(value !== null && typeof value === 'object' && !Array.isArray(value)); + const prototype = Object.getPrototypeOf(value); + check(prototype === null || prototype === Object.prototype); + const descriptors = Object.getOwnPropertyDescriptors(value); + check(Reflect.ownKeys(descriptors).length === keys.length); + check(keys.every((key) => Object.prototype.hasOwnProperty.call(descriptors, key))); + check(keys.every((key) => Object.prototype.hasOwnProperty.call(descriptors[key], 'value') && descriptors[key].enumerable)); + return Object.freeze(Object.fromEntries(keys.map((key) => [key, descriptors[key].value]))); +} +function array(value: unknown): unknown[] { + check(Array.isArray(value)); + const fields = Object.getOwnPropertyDescriptors(value); + const length = fields.length?.value; + check(integer(length, 20)); + check(Reflect.ownKeys(fields).length === length + 1); + return Array.from({ + length + }, (_, n) => { + const field = fields[String(n)]; + check(field && Object.prototype.hasOwnProperty.call(field, 'value') && field.enumerable); + return field.value; + }); +} +export interface RaceEntry { + entryId: string; + revision: number; + bibId: string; + status: 'confirmed' | 'cancelled'; + fictionalAmountCents: number; + fictionalReturnedCents: number; + isCurrentRunner: boolean; + isOriginalCreditOwner: boolean; + transferCount: 0 | 1; + offerExpiresAtMs: number | null; + canCancel: boolean; + canOffer: boolean; + canRevoke: boolean; +} +export interface RaceEvent { + eventId: string; + revision: number; + startAtMs: number; + opensAtMs: number; + closesAtMs: number; + signupStatus: 'open' | 'not_open_yet' | 'ended' | 'full' | 'cancelled'; + fictionalAmountCents: number; + currency: 'USD'; + waiverVersion: string; + joinEntryId: string | null; + canJoin: boolean; + enrollment: { + status: 'confirmed' | 'cancelled' | 'transferred'; + revision: number; + } | null; + entry: RaceEntry | null; +} +export interface RaceStatus { + schemaVersion: 1; + simulationOnly: true; + policyVersion: typeof RACE_POLICY; + observedAtMs: number; + accessExpiresAtMs: number; + events: RaceEvent[]; + incoming: (RaceEntry & { + eventId: string; + eventRevision: number; + canAccept: true; + }) | null; +} +export interface RaceCommand { + action: 'race_join' | 'race_cancel' | 'race_offer' | 'race_accept' | 'race_revoke'; + accountUid: string; + requestId: string; + eventId: string; + entryId: string; + expectedEventRevision: number; + expectedEntryRevision: number; + recipientUid?: string; + waiverAccepted?: true; + waiverVersion?: string; +} +const mapping = { + race_join: 'join_simulation', + race_cancel: 'simulate_refund', + race_offer: 'offer_transfer', + race_accept: 'accept_transfer', + race_revoke: 'revoke_transfer' +} as const; +export function parseRaceCommand(input: unknown): RaceCommand { + try { + // Safely obtain the discriminator without invoking request accessors. + const descriptor = input && typeof input === 'object' ? Object.getOwnPropertyDescriptor(input, 'action') : null; + const action = descriptor?.value; + check(typeof action === 'string' && Object.prototype.hasOwnProperty.call(mapping, action)); + let extra: string[] = []; + if (action === 'race_offer') extra = ['recipientUid']; else if (action === 'race_join' || action === 'race_accept') extra = ['waiverAccepted', 'waiverVersion']; + const request = object(input, ['action', 'accountUid', 'requestId', 'eventId', 'entryId', 'expectedEventRevision', 'expectedEntryRevision', ...extra]); + check(raceId(request.accountUid) && raceId(request.eventId) && raceId(request.entryId) + && typeof request.requestId === 'string' + && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(request.requestId) + && revision(request.expectedEventRevision) && integer(request.expectedEntryRevision, 999999) + && (action === 'race_join' + ? request.expectedEntryRevision === 0 : request.expectedEntryRevision > 0) + && (!extra.includes('recipientUid') + || (raceId(request.recipientUid) && request.recipientUid !== request.accountUid)) + && (!extra.includes('waiverAccepted') + || (request.waiverAccepted === true && request.waiverVersion === RACE_WAIVER))); + return request as unknown as RaceCommand; + } catch { + throw new Error(MESSAGE); + } +} +const entryKeys = ['entryId', 'revision', 'bibId', 'status', 'fictionalAmountCents', 'fictionalReturnedCents', 'isCurrentRunner', 'isOriginalCreditOwner', 'transferCount', 'offerExpiresAtMs', 'canCancel', 'canOffer', 'canRevoke']; +function entry(value: unknown, event: RecordValue, now: number, incoming = false): RaceEntry { + const data = object(value, [...entryKeys, ...(incoming ? ['eventId', 'eventRevision', 'canAccept'] : [])]); + check(raceId(data.entryId) && raceId(data.bibId) && revision(data.revision) + && typeof data.status === 'string' && ['confirmed', 'cancelled'].includes(data.status) + && integer(data.fictionalAmountCents, 10000) && data.fictionalAmountCents > 0 + && data.fictionalReturnedCents === (data.status === 'cancelled' ? data.fictionalAmountCents : 0) + && (data.transferCount === 0 || data.transferCount === 1) + && ['isCurrentRunner', 'isOriginalCreditOwner', 'canCancel', 'canOffer', 'canRevoke'] + .every((key) => typeof data[key] === 'boolean') + && (data.offerExpiresAtMs === null + || (integer(data.offerExpiresAtMs) && data.offerExpiresAtMs <= Number(event.startAtMs))) + && (data.status !== 'cancelled' + || (data.offerExpiresAtMs === null && !data.canCancel && !data.canOffer && !data.canRevoke)) + && (data.transferCount !== 1 || data.offerExpiresAtMs === null)); + const beforeStart = now < Number(event.startAtMs); + const confirmed = data.status === 'confirmed'; + if (!incoming) { + check(data.transferCount === 0 + ? data.isCurrentRunner === true && data.isOriginalCreditOwner === true + : data.isCurrentRunner !== data.isOriginalCreditOwner); + } + check(data.canCancel === (data.isCurrentRunner && confirmed && data.offerExpiresAtMs === null && beforeStart) && data.canOffer === (data.isCurrentRunner && confirmed && data.offerExpiresAtMs === null && data.transferCount === 0 && event.signupStatus !== 'cancelled' && beforeStart) && data.canRevoke === (data.isCurrentRunner && data.offerExpiresAtMs !== null && beforeStart)); + if (incoming) { + check(data.eventId === event.eventId && data.eventRevision === event.revision && data.canAccept === true && !data.isCurrentRunner && !data.isOriginalCreditOwner && data.transferCount === 0 && confirmed && integer(data.offerExpiresAtMs) && data.offerExpiresAtMs > now && beforeStart && event.signupStatus !== 'cancelled'); + } + return data as unknown as RaceEntry; +} +export function parseRaceStatus(input: unknown, incomingEntryId: string | null): RaceStatus { + try { + const data = object(input, ['schemaVersion', 'simulationOnly', 'policyVersion', 'observedAtMs', 'accessExpiresAtMs', 'events', 'incoming']); + check(data.schemaVersion === 1 && data.simulationOnly === true + && data.policyVersion === RACE_POLICY + && integer(data.observedAtMs) && integer(data.accessExpiresAtMs) + && data.accessExpiresAtMs > data.observedAtMs + && data.accessExpiresAtMs - data.observedAtMs <= 86400000); + const now = Number(data.observedAtMs); + const events = array(data.events).map((raw) => { + const event = object(raw, ['eventId', 'revision', 'startAtMs', 'opensAtMs', 'closesAtMs', 'signupStatus', 'fictionalAmountCents', 'currency', 'waiverVersion', 'joinEntryId', 'canJoin', 'enrollment', 'entry']); + check(raceId(event.eventId) && revision(event.revision) && [event.startAtMs, event.opensAtMs, event.closesAtMs].every((value) => integer(value)) && Number(event.opensAtMs) < Number(event.closesAtMs) && Number(event.closesAtMs) <= Number(event.startAtMs) && Number(event.closesAtMs) - Number(event.opensAtMs) <= 86400000 && typeof event.signupStatus === 'string' && ['open', 'not_open_yet', 'ended', 'full', 'cancelled'].includes(event.signupStatus) && integer(event.fictionalAmountCents, 10000) && event.fictionalAmountCents > 0 && event.currency === 'USD' && event.waiverVersion === RACE_WAIVER && typeof event.canJoin === 'boolean'); + if (event.signupStatus !== 'cancelled') { + if (now >= Number(event.closesAtMs)) check(event.signupStatus === 'ended'); else if (now < Number(event.opensAtMs)) { + check(event.signupStatus === 'not_open_yet'); + } else check(event.signupStatus === 'open' || event.signupStatus === 'full'); + } + const enrollment = event.enrollment === null ? null : object(event.enrollment, ['status', 'revision']); + if (enrollment) { + check(typeof enrollment.status === 'string' && ['confirmed', 'cancelled', 'transferred'].includes(enrollment.status) && revision(enrollment.revision)); + } + check(event.canJoin === (enrollment === null && event.signupStatus === 'open') && (enrollment === null ? raceId(event.joinEntryId) && event.entry === null : event.joinEntryId === null && event.entry !== null)); + const parsedEntry = event.entry === null ? null : entry(event.entry, event, now); + if (parsedEntry && enrollment) { + check(enrollment.status === 'transferred' ? !parsedEntry.isCurrentRunner && parsedEntry.isOriginalCreditOwner && parsedEntry.transferCount === 1 : parsedEntry.isCurrentRunner && parsedEntry.status === enrollment.status); + } + return Object.freeze({ + ...event, + enrollment, + entry: parsedEntry + }) as unknown as RaceEvent; + }); + check(new Set(events.map((event) => event.eventId)).size === events.length); + let incoming = null; + if (incomingEntryId !== null) { + check(raceId(incomingEntryId)); + const named = object(data.incoming, [...entryKeys, 'eventId', 'eventRevision', 'canAccept']); + const event = events.find((value) => value.eventId === named.eventId); + check(event && event.enrollment === null); + incoming = entry(named, event as unknown as RecordValue, now, true); + check(incoming.entryId === incomingEntryId); + } else check(data.incoming === null); + return Object.freeze({ + ...data, + events: Object.freeze(events), + incoming + }) as unknown as RaceStatus; + } catch { + throw new Error(MESSAGE); + } +} +export function parseRaceResult(input: unknown, request: RaceCommand) { + try { + const command = parseRaceCommand(request); + const data = object(input, ['schemaVersion', 'simulationOnly', 'action', 'eventId', 'entryId', 'entryRevision', 'eventRevision', 'offerExpiresAtMs']); + check(data.schemaVersion === 1 && data.simulationOnly === true && data.action === mapping[command.action] && data.eventId === command.eventId && data.entryId === command.entryId && revision(data.entryRevision) && data.entryRevision === command.expectedEntryRevision + 1 && revision(data.eventRevision) && data.eventRevision === command.expectedEventRevision + (['race_join', 'race_cancel'].includes(command.action) ? 1 : 0) && (command.action === 'race_offer' ? integer(data.offerExpiresAtMs) : data.offerExpiresAtMs === null)); + return data; + } catch { + throw new Error(MESSAGE); + } +} +async function invoke(functions: Functions, auth: Auth, request: RecordValue) { + try { + const session = auth.currentUser; + const { + app + } = functions; + const current = () => COMMUNITY_ENABLED && app.options.projectId === 'run-mprc-staging' + && functions.app === app && auth.app === app && auth.tenantId === null + && session !== null && auth.currentUser === session && session.uid === request.accountUid + && session.emailVerified === true; + check(current() && raceId(request.accountUid)); + const response = await httpsCallable(functions, 'communityActions')(request); + check(current()); + return response.data; + } catch { + throw new Error(MESSAGE); + } +} +export async function readFictionalStatus( + functions: Functions, + auth: Auth, + accountUid: string, + incomingEntryId: string | null = null +) { + check(incomingEntryId === null || raceId(incomingEntryId)); + return parseRaceStatus(await invoke(functions, auth, { + action: 'race_status', + accountUid, + incomingEntryId + }), incomingEntryId); +} +export async function sendFictionalCommand(functions: Functions, auth: Auth, input: RaceCommand) { + const request = parseRaceCommand(input); + return parseRaceResult(await invoke(functions, auth, request as unknown as RecordValue), request); +} diff --git a/src/services/community/testSupport/fictionalRaceFixtures.ts b/src/services/community/testSupport/fictionalRaceFixtures.ts new file mode 100644 index 0000000..c6e8f57 --- /dev/null +++ b/src/services/community/testSupport/fictionalRaceFixtures.ts @@ -0,0 +1,98 @@ +import { + RaceCommand, RaceStatus, RACE_POLICY, RACE_WAIVER +} from '../fictionalRaceService'; + +export const uid = 'stage_race_test_owner'; +export const recipient = 'stage_race_test_recipient'; +export const entryId = 'stage_race_test_entry'; +export const now = 1800000000000; +export function state(kind: 'none' | 'confirmed' | 'offered' | 'cancelled' | 'incoming' | 'transferred' = 'none'): RaceStatus { + const own = kind !== 'none' && kind !== 'incoming'; + const offered = kind === 'offered' || kind === 'incoming'; + const cancelled = kind === 'cancelled'; + let enrollmentStatus: 'transferred' | 'cancelled' | 'confirmed' = 'confirmed'; + if (kind === 'transferred') enrollmentStatus = 'transferred'; else if (cancelled) enrollmentStatus = 'cancelled'; + const entry = { + entryId, + revision: offered || cancelled ? 2 : 1, + bibId: 'stage_race_test_bib', + status: cancelled ? 'cancelled' as const : 'confirmed' as const, + fictionalAmountCents: 1000, + fictionalReturnedCents: cancelled ? 1000 : 0, + isCurrentRunner: kind !== 'incoming' && kind !== 'transferred', + isOriginalCreditOwner: kind !== 'incoming', + transferCount: kind === 'transferred' ? 1 as const : 0 as const, + offerExpiresAtMs: offered ? now + 5000 : null, + canCancel: !offered && !cancelled && kind !== 'transferred', + canOffer: !offered && !cancelled && kind !== 'transferred', + canRevoke: kind === 'offered' + }; + return { + schemaVersion: 1, + simulationOnly: true, + policyVersion: RACE_POLICY, + observedAtMs: now, + accessExpiresAtMs: now + 10000, + events: [{ + eventId: 'stage_race_test_race', + revision: own || offered ? 2 : 1, + startAtMs: now + 20000, + opensAtMs: now - 1000, + closesAtMs: now + 10000, + signupStatus: kind === 'incoming' ? 'full' : 'open', + fictionalAmountCents: 1000, + currency: 'USD', + waiverVersion: RACE_WAIVER, + joinEntryId: own ? null : 'stage_race_new_entry', + canJoin: kind === 'none', + enrollment: own ? { + status: enrollmentStatus, + revision: 1 + } : null, + entry: own ? entry : null + }], + incoming: kind === 'incoming' ? { + ...entry, + eventId: 'stage_race_test_race', + eventRevision: 2, + canAccept: true + } : null + }; +} +export function command(action: RaceCommand['action'] = 'race_join'): RaceCommand { + return { + action, + accountUid: uid, + eventId: 'stage_race_test_race', + entryId, + requestId: '123e4567-e89b-42d3-a456-426614174000', + expectedEventRevision: 1, + expectedEntryRevision: action === 'race_join' ? 0 : 1, + ...(action === 'race_join' || action === 'race_accept' ? { + waiverAccepted: true, + waiverVersion: RACE_WAIVER + } : {}), + ...(action === 'race_offer' ? { + recipientUid: recipient + } : {}) + }; +} +export function result(request: RaceCommand) { + const mapping = { + race_join: 'join_simulation', + race_cancel: 'simulate_refund', + race_offer: 'offer_transfer', + race_accept: 'accept_transfer', + race_revoke: 'revoke_transfer' + }; + return { + schemaVersion: 1, + simulationOnly: true, + action: mapping[request.action], + eventId: request.eventId, + entryId: request.entryId, + entryRevision: request.expectedEntryRevision + 1, + eventRevision: request.expectedEventRevision + (['race_join', 'race_cancel'].includes(request.action) ? 1 : 0), + offerExpiresAtMs: request.action === 'race_offer' ? now + 5000 : null + }; +} From f1bb18eefc980a910dc1591b92622e3ffcbdf371 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 13 Oct 2026 12:28:57 -0700 Subject: [PATCH 2/2] test(staging): bind race additions to zero lint debt --- tests/ci-workflow.test.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index e843216..b3a7bac 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -43,7 +43,7 @@ const CLEAN_COMMANDS = Object.freeze([ 'test -z "$frontend_git_status"', ]); const LINT_SCRIPT = 'node .github/scripts/check-frontend-lint.cjs'; -const EXPECTED_LINT_FILES = 126; +const EXPECTED_LINT_FILES = 131; const EXPECTED_LINT_ERRORS = 113; const EXPECTED_LINT_WARNINGS = 6; const YAML_TO_JSON = [ @@ -578,6 +578,20 @@ test('frontend lint covers every source extension without mutating or swallowing assert.equal(verified.warningCount, EXPECTED_LINT_WARNINGS); }); +test('fictional race client and test additions have no baseline lint debt', () => { + const added = [ + 'src/pages/community/FictionalRaces.test.tsx', + 'src/pages/community/FictionalRaces.tsx', + 'src/services/community/fictionalRaceService.test.ts', + 'src/services/community/fictionalRaceService.ts', + 'src/services/community/testSupport/fictionalRaceFixtures.ts', + ]; + for (const file of added) { + assert.ok(lintBaseline.scannedFiles.includes(file)); + assert.equal(lintBaseline.findings.filter(finding => finding.file === file).length, 0); + } +}); + test('frontend lint workflow guard rejects unsafe wiring mutations', () => { const workflowMutations = [ ciWorkflow.replace(LINT_STEP_NAME, 'Run frontend formatting'),