From e5ac3d8ffc60caa9fd5a5217a65d80ebbcbeb5e6 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Fri, 16 Oct 2026 17:25:49 -0700 Subject: [PATCH 1/2] Polish member event screen and fence stale status sessions --- .github/lint-baseline.json | 8 +- GITHUB_ISSUES.md | 2 + IMPLEMENTATION_PLAN.md | 2 + OFFICER_START_HERE.md | 2 + OPERATIONS_RUNBOOK.md | 2 + SECURITY.md | 2 + SYSTEM_DESIGN.md | 2 + docs/MEMBER_LAUNCH_SCOPE.md | 8 + docs/officers/MEMBER_SCREEN.md | 38 ++++ docs/officers/README.md | 2 + docs/officers/SYSTEM_MAPS.md | 17 ++ functions/communityActions.js | 3 +- functions/communityActions.test.js | 1 + src/pages/community/Community.css | 31 +++ src/pages/community/Community.test.tsx | 158 ++++++++++++-- src/pages/community/Community.tsx | 202 ++++++++++++------ .../community/communityMemberStatus.test.ts | 84 ++++++++ .../community/communityMemberStatus.ts | 115 ++++++++++ .../community/communityService.test.ts | 27 ++- src/services/community/communityService.ts | 19 +- 20 files changed, 632 insertions(+), 93 deletions(-) create mode 100644 docs/officers/MEMBER_SCREEN.md create mode 100644 src/services/community/communityMemberStatus.test.ts create mode 100644 src/services/community/communityMemberStatus.ts diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index 56e4ff3..34e9aa4 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -10,11 +10,11 @@ "targets": [ "src" ], - "scannedFileCount": 131, + "scannedFileCount": 133, "scannedFilesByExtension": { ".js": 12, ".jsx": 35, - ".ts": 48, + ".ts": 50, ".tsx": 36 }, "errorCount": 113, @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:5ba90c83db63c37a2a3cf738e5d923ba77d172937d79153896e5a2eea3133878", + "fingerprint": "sha256:f2e9add8612f9e8be9e77ae321ee247b21439f5f5999cbeca43dfe2a5cb46c31", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -128,6 +128,8 @@ "src/services/accountEmail/accountEmailSender.ts", "src/services/analytics/analytics.test.ts", "src/services/analytics/analytics.ts", + "src/services/community/communityMemberStatus.test.ts", + "src/services/community/communityMemberStatus.ts", "src/services/community/communityService.test.ts", "src/services/community/communityService.ts", "src/services/community/fictionalRaceService.test.ts", diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index d6d41b6..2b62356 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**Member screen preparation — #739, October 16, 2026, SOURCE ONLY:** the membership/free-event screen now validates bounded server status and action acknowledgements, binds the native account/app/default tenant and observed session, and hides stale controls at the earliest known membership/window deadline or after at most 60 seconds from read start. No polling is added. Exact uncertain retries stay within their original observed session. The responsive layout uses Pacific event dates and explicit loading, empty, unavailable and retry states. Backend-first release is required: older status responses without schema/time fields fail closed. No policy, payment, family enrollment, permission or production change. [Officer review](./docs/officers/MEMBER_SCREEN.md). + **COMMUNITY-004 / [#737](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/737) — SOURCE IN REVIEW:** October 15 owner rule requires current paid membership plus existing verified email and waiver for new free-event entries. The transaction checks current authority after reads; cancellation after expiry/suspension and immutable retry remain safe. Unit/UI and emulator regressions cover nonmembers, exact expiry, suspension, wrong UID, forgery, final seats and no-write denials. $25 individual/$30 family calendar-year policy is recorded, not activated; online dues and separate family accounts under a paid Family ID are selected for first launch; minor/privacy/retention and actual payment integration remain separate. Requested [policy drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) are unapproved and unused. No deployed/prod/real-payment claim; #731 stays closed with final6186640177. **RACE-SIM-001C / #731 — NATIVE TESTS VERIFIED, HANDOFF GATED:** actual fictional lifecycle/privacy/expiry/mobile and 38 native evaluations/27 distinct checks passed on 2026-10-14, including real Auth/App Check/caller/current-authority boundaries and actual UI committed/lost-response retry. Exact retry changed none of six protected record update times; recipient-loss replay/revoke and terminal/current-revocation guards passed. Two fresh accounts/26 owned docs cleaned with fences/late absence; private browser SDK state/cache cleared, unused receiver closed/zero captures/port refused. Human permission-OFF confirmation and reviewed/current-CI handoff remain before closure or supervised use with fresh fixtures. Officer impact: verified fictional flow only; no real policy/payment/production readiness. Officer documentation: STAGING_COMMUNITY, entrypoints, EVENTS_SHOP_MEMBERS, SYSTEM_MAPS and root checkpoints. Deployment evidence: existing v6/669e2fb/archive94380430 and Hosting e21be35 unchanged, [native receipt6186206758](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186206758); no new deploy or full backend completion. Earlier #731/#732 source/anonymous-only entries below are historical. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index c7af2f1..7591e4d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,7 @@ # MPRC Secure Commerce Implementation Plan +**Member screen preparation — #739, October 16, 2026, SOURCE ONLY:** the membership/free-event screen now validates bounded server status and action acknowledgements, binds the native account/app/default tenant and observed session, and hides stale controls at the earliest known membership/window deadline or after at most 60 seconds from read start. No polling is added. Exact uncertain retries stay within their original observed session. The responsive layout uses Pacific event dates and explicit loading, empty, unavailable and retry states. Backend-first release is required: older status responses without schema/time fields fail closed. No policy, payment, family enrollment, permission or production change. [Officer review](./docs/officers/MEMBER_SCREEN.md). + **#731 native tests complete — 2026-10-14, handoff gated:** actual fictional lifecycle/privacy/expiry/mobile plus 38 native evaluations/27 distinct security/replay checks passed. UI response discarded only after real server commit recovered through the exact saved action with all six protected update times unchanged. Recipient-loss replay/revoke and terminal-state/caller-revocation protection passed. Two fresh accounts/26 owned records removed with fences/immediate+late absence; browser test credentials/cache cleared and unused receiver closed. Final browser-permission confirmation and reviewed documentation/current-CI receipt remain before issue closure or supervised use with fresh fixtures. No new deploy/build/service/IAM/Rules/provider/production/payment work. [Dated native receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186206758); earlier #731/#732 source/anonymous-only milestones below are historical. Whole-backend completion is not claimed. **#731 staging deployment checkpoint — 2026-10-13:** exact PR/main checks passed; backend v6/archive was verified before Hosting `669e2fb`. Anonymous phone-width UI/public assets pass, not signed-in acceptance. Continue genuine disposable security/lifecycle/privacy/failure/retry/expiry/mobile and fenced cleanup before closing #731. [Current handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). Earlier source-only/not-deployed notes are historical; full backend goal/production/real payments remain separate. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 947bc36..03cfef9 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,5 +1,7 @@ # MPRC Website: Officer Start Here +**Member screen preparation — #739, October 16, 2026, SOURCE ONLY:** the membership/free-event screen now validates bounded server status and action acknowledgements, binds the native account/app/default tenant and observed session, and hides stale controls at the earliest known membership/window deadline or after at most 60 seconds from read start. No polling is added. Exact uncertain retries stay within their original observed session. The responsive layout uses Pacific event dates and explicit loading, empty, unavailable and retry states. Backend-first release is required: older status responses without schema/time fields fail closed. No policy, payment, family enrollment, permission or production change. [Officer review](./docs/officers/MEMBER_SCREEN.md). + **Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./docs/officers/STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. **#731 final checkpoint — CLOSED October 15:** [native acceptance and cleanup](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186640177) are complete, including the human-confirmed local-access Block and final tab/kernel cleanup. No deleted fixture or resolved permission question is reopened. Production and real payments remain **NOT AVAILABLE YET**. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index b08d4a2..ea85da5 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**Member screen preparation — #739, October 16, 2026, SOURCE ONLY:** the membership/free-event screen now validates bounded server status and action acknowledgements, binds the native account/app/default tenant and observed session, and hides stale controls at the earliest known membership/window deadline or after at most 60 seconds from read start. No polling is added. Exact uncertain retries stay within their original observed session. The responsive layout uses Pacific event dates and explicit loading, empty, unavailable and retry states. Backend-first release is required: older status responses without schema/time fields fail closed. No policy, payment, family enrollment, permission or production change. [Officer review](./docs/officers/MEMBER_SCREEN.md). + **#731 native acceptance/handoff — 2026-10-14:** [current officer procedure](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) separates actual native/UI proof from final acceptance and cleanup gates. Existing v6/669e2fb/101386-byte94380430 archive and Hosting e21be35 remain unchanged; no redeploy is needed for these documentation facts. Actual lost-response retry, current authority/privacy, natural expiry and recipient-loss recovery passed. Two fresh fake accounts/26 exact owned records were removed with identity/create-time/update-time fences and immediate/late absence; private browser helper/Auth/matching staging SDK cache were cleared. The unused receiver closed with zero captures and a refused port. The specialist must record the human's temporary browser-permission OFF confirmation and owned-tab closure before final handoff. No workaround for blocked browser settings; no accounts/server left waiting for that input. Fresh fixtures, final receipt and Dave's supervision are required; production/payments stay untouched. Earlier #731/#732 source/anonymous-only notes below are historical. **#731 staging deployment checkpoint — 2026-10-13:** existing community service v6 and Hosting `669e2fb` are verified; no additional Function build is needed for this documentation. Signed-in tests/fixture cleanup and qualified officer use remain pending. Follow the [current handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared), not old source-only/not-deployed checkpoints below. Preserve exact current/rollback evidence; fixture supply, native tests/cleanup and rollback remain specialist-only. Production/real payments untouched. diff --git a/SECURITY.md b/SECURITY.md index f3c105d..c5c02c8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # MPRC Security, Privacy, and Risk Plan +**Member screen preparation — #739, October 16, 2026, SOURCE ONLY:** the membership/free-event screen now validates bounded server status and action acknowledgements, binds the native account/app/default tenant and observed session, and hides stale controls at the earliest known membership/window deadline or after at most 60 seconds from read start. No polling is added. Exact uncertain retries stay within their original observed session. The responsive layout uses Pacific event dates and explicit loading, empty, unavailable and retry states. Backend-first release is required: older status responses without schema/time fields fail closed. No policy, payment, family enrollment, permission or production change. [Officer review](./docs/officers/MEMBER_SCREEN.md). + **#731 native staging security — 2026-10-14:** [native receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186206758) records real verified password Auth and normal App Check: isolated missing/invalid Auth or attestation401, caller substitution403, extra authority/malformed input400, current/expired marker/capability and current native account denials403. Populated status omits other UIDs/contact/waiver/count/capacity. Firebase refuses unsupported tenant sign-in with INVALID_TENANT_ID; no tenant or valid nondefault-tenant token was created. Actual UI response loss after commit, exact retry without writes, terminal-state protection, recipient-loss replay/revoke and revoked-caller replay passed. No debug attestation/custom claims, new permission, Rule allowance, production or money action. Supervised use still requires the complete final cleanup/acceptance receipt and fresh fixtures. Earlier #731/#732 source/anonymous-only notes below are historical. **#731 staging deployment checkpoint — 2026-10-13:** native v6/archive/resources/owner/MFA/invoker/unchanged profiles and website assets are verified at `669e2fb`. Two requests without Auth and with missing/invalid attestation denied; this does not isolate App Check or qualify signed-in caller/grant/privacy tests. Those and disposable cleanup remain open. [Current handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). Earlier source-only/not-deployed notes are historical; no production/payment/IAM authority expansion. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index d9629d0..e0369f0 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**Member screen preparation — #739, October 16, 2026, SOURCE ONLY:** the membership/free-event screen now validates bounded server status and action acknowledgements, binds the native account/app/default tenant and observed session, and hides stale controls at the earliest known membership/window deadline or after at most 60 seconds from read start. No polling is added. Exact uncertain retries stay within their original observed session. The responsive layout uses Pacific event dates and explicit loading, empty, unavailable and retry states. Backend-first release is required: older status responses without schema/time fields fail closed. No policy, payment, family enrollment, permission or production change. [Officer review](./docs/officers/MEMBER_SCREEN.md). + **#731 native staging acceptance — 2026-10-14:** actual lifecycle/privacy/expiry/mobile and 38 native evaluations/27 distinct checks passed, including isolated Auth/App Check/caller/current-authority denials and actual UI committed/lost-response saved retry. Same existing v6/669e2fb archive and Hosting e21be35; no architecture/runtime/Rules/IAM/dependency/provider/production change. Exact owned accounts/records and browser SDK state were cleaned. Use remains gated on the complete dated acceptance/cleanup receipt and fresh fake fixtures; #731 stays open until final handoff gates are recorded. [Current officer handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). Earlier #731/#732 source/anonymous-only checkpoints below are historical, not current test status. The full backend/real-payment/public-launch objective remains incomplete. **#731 staging deployment checkpoint — 2026-10-13:** Function v6 and the staging website use `669e2fb`; exact archive, resource/identity/security settings, unchanged profiles and public assets passed verification. Signed-in race security/lifecycle/cleanup remain unqualified. [Current handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). Earlier source-only/not-deployed notes below are historical checkpoints, not current deployment status. Production and real payments remain untouched. diff --git a/docs/MEMBER_LAUNCH_SCOPE.md b/docs/MEMBER_LAUNCH_SCOPE.md index 7418602..44f0f44 100644 --- a/docs/MEMBER_LAUNCH_SCOPE.md +++ b/docs/MEMBER_LAUNCH_SCOPE.md @@ -56,3 +56,11 @@ Check current Firebase trial/usage and the club's actual Stripe pricing privatel Next, complete the real account-recovery qualification and one coherent test-mode membership purchase/activation/family-enrollment workflow. Keep generic commerce/race/shop work out unless it is an actual dependency. Qualify current Rules/App Check, narrow runtime identities, exact backend-first publication, compatible rollback/isolated restoration and an officer screen-only walkthrough for the selected release. The [privacy, retention and waiver drafts](./legal/MEMBER_LAUNCH_DRAFTS.md) remain for club review. Source changed, tests passed, merge, staging deployment, production deployment, provider configuration and real behavior must be evidenced separately. #731's accepted fictional race evidence remains closed; no deleted fixture or sealed harness is reused. + +## Screen preparation — October 16, #739 + +The current staging-only membership/free-event screen gains native-session fencing, closed response parsing, a bounded display lifetime and responsive layout. Backend status adds `schemaVersion: 1` and server `observedAtMs`; no persisted record changes. Release the compatible backend before its frontend. An old backend or malformed response displays an unavailable state instead of retaining old membership/signup controls. The client freshness timer never extends membership, chooses capacity or contacts a provider. Automatic token-session observation can trigger one status reread; freshness expiry itself requires explicit Refresh status and does not poll. + +The lint inventory adds the two new clean TypeScript files only. Existing reviewed findings remain byte-for-byte unchanged: 113 errors and six warnings elsewhere. No lint rule or allowance is weakened. + +Online annual dues, private family enrollment, account-recovery/provider qualification, approved policy and the final protected production release remain incomplete. The prototype request screen explicitly says online dues and family invitations are unavailable; it does not imply that a manual approval queue is the approved final membership flow. diff --git a/docs/officers/MEMBER_SCREEN.md b/docs/officers/MEMBER_SCREEN.md new file mode 100644 index 0000000..eccae8c --- /dev/null +++ b/docs/officers/MEMBER_SCREEN.md @@ -0,0 +1,38 @@ +# Review membership and free events + +**Status, October 16, 2026:** #739 is source preparation. The changed screen is **NOT AVAILABLE YET** on the hosted site. Local screenshots and tests use fictional accounts and a replacement test service. They do not prove Firebase, email delivery, Stripe or production behavior. + +**Purpose:** check that members understand their status and can safely sign up, cancel or recover after an uncertain request. + +**Approver:** Dave Liu, the sole backend release owner. A verified Opus 5.5 medium/max approval and green checks on the exact commit count as Dave's delegated code approval. Club policy and real-money activation remain separate. + +**Prerequisites:** a reviewed exact commit; the specialist's compatible backend-first staging release receipt; fresh disposable staging fixtures; verified fictional account; normal App Check. Do not reuse the closed #731 accounts, IDs or harness. Do not enter real member details or payment information. + +## Screen-only review after staging release + +1. Open the specialist's verified staging link on a phone or narrow window. +2. Confirm the test-only notice is visible. +3. Sign in with the supplied fictional account. +4. Confirm the loading message gives way to a membership card and free-event cards. +5. Check that event dates say Pacific time. +6. Open the test waiver and deliberately tick its acceptance box. +7. Choose the named free-event signup. +8. Wait for the confirmed result and your current signup. +9. Cancel that signup. +10. Wait for the cancelled result before starting another action. +11. Ask the specialist to demonstrate the unavailable and lost-response cases with fictional fixtures. +12. Use Retry request for an uncertain action, or Refresh status to read current state without resending it. +13. Wait for the supplied short term or signup window to end. +14. Confirm old status and controls disappear with a refresh message. +15. Refresh status and confirm cancellation of an existing signup remains possible after membership lapses. +16. Sign out, then ask the specialist to remove the new disposable fixtures. + +**Expected result:** only the current account's validated status appears. Account/session changes discard old requests and consent. A status display lasts at most 60 seconds from read start, often less at a known deadline; refresh is manual and does not reserve a place. A cancellation keeps the approved paid membership term and issues no refund. This screen still offers only a test membership request; online dues and family invitations are unavailable. + +**Stop conditions:** real information or payment is requested; an old account's status appears; active membership remains after a supplied deadline; a failed read leaves old controls; an uncertain retry books twice; phone content overflows; or the compatible backend release is missing. Stop before more actions and tell Dave privately using only a made-up example. + +**Success proof:** ask for the exact reviewed commit, green test run, dated phone/desktop screenshots, backend deployment and Hosting asset receipts, genuine staging Auth/App Check behavior and disposable cleanup. Local screenshots are layout/behavior proof only. An engineering walkthrough from a backup officer's perspective is not a human officer signoff. + +**Undo:** stop the review and request the specialist's compatible backend/website rollback. Do not edit records, roles, dates or provider settings. A frontend-only source revert is not proof of rollback. + +**Escalation:** Dave Liu for release, access, recovery and incident handling; club officers for waiver, retention and guardian/family policy; treasurer and provider owner for real-money activation. The specialist still owns release, fixtures, native security proof and cleanup. No officer terminal, source edit, secret handling or direct database change is part of this procedure. diff --git a/docs/officers/README.md b/docs/officers/README.md index a12338b..975c3b4 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,5 +1,7 @@ # Officer Website Handbook +**October 16 member-screen preparation:** [Review membership and free events](./MEMBER_SCREEN.md). Source only; no hosted release or payments yet. + **Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](../legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. **#731 final checkpoint — CLOSED October 15:** [native acceptance and cleanup](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186640177) are complete, including the human-confirmed local-access Block and final tab/kernel cleanup. No deleted fixture or resolved permission question is reopened. Production and real payments remain **NOT AVAILABLE YET**. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 8de0f2d..717e6a1 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -1,5 +1,22 @@ # Simple System Maps +## Member/free-event screen — #739 source preparation + +```mermaid +flowchart LR + A["Observed native account and session"] --> B["Read bounded server status"] + B --> C{"Valid status and known deadline still current?"} + C -- Yes --> D["Show membership and free-event actions"] + C -- No --> E["Hide old state; ask for refresh"] + D --> F["Server rechecks membership, waiver and capacity"] + F --> G["Validate result then reread current status"] + F -- Uncertain --> H["Save exact request in this session only"] + H --> F + A -- Account or session changes --> E +``` + +Text alternative: the current native account reads bounded server status; only a valid status inside its known display lifetime shows controls. Every action still uses server checks. Uncertain requests retain one exact retry within that observed session. Account/session changes or deadlines remove old state. This is source-only; deployment and provider proof remain separate. + These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. ## Fictional race member screen — #731 native staging tests verified diff --git a/functions/communityActions.js b/functions/communityActions.js index f164f1d..11cec22 100644 --- a/functions/communityActions.js +++ b/functions/communityActions.js @@ -131,7 +131,8 @@ function createCommunityService(db, clock = Date.now) { : event.confirmedCount >= event.capacity ? 'full' : 'open'; return { ...event, signupStatus, registration: registrationView(docs[index * 2 + 1].data()) }; }); - return { membership: memberView(member.exists ? member.data() : emptyMember, uid, now), events }; + return { schemaVersion: 1, observedAtMs: now, + membership: memberView(member.exists ? member.data() : emptyMember, uid, now), events }; } async function mutate(uid, request) { diff --git a/functions/communityActions.test.js b/functions/communityActions.test.js index 939ea5b..f6aebf7 100644 --- a/functions/communityActions.test.js +++ b/functions/communityActions.test.js @@ -168,6 +168,7 @@ describe('transactional membership and free-event lifecycle', () => { const read = createCommunityService(memory.db, () => current); const before = JSON.stringify([...memory.docs]); const result = await read.status('runner-a'); + expect(result).toMatchObject({ schemaVersion: 1, observedAtMs: current }); expect(result.membership.entitlement).toBe('not_entitled'); expect(result.events[0].signupStatus).toBe('ended'); expect(JSON.stringify([...memory.docs])).toBe(before); diff --git a/src/pages/community/Community.css b/src/pages/community/Community.css index bc7ad4b..5b062ab 100644 --- a/src/pages/community/Community.css +++ b/src/pages/community/Community.css @@ -56,3 +56,34 @@ .community-staging section { padding: 1rem; } .community-staging button { width: 100%; margin-inline: 0; } } + +/* Member/free-event layout. Officer and fictional-race screens retain their layout. */ +.member-community { max-width: 1080px; padding-block: clamp(1.5rem, 5vw, 3.5rem); color: #f3f3fa; } +.member-community h1 { font-size: clamp(2rem, 4.5vw, 3rem); line-height: 1.15; letter-spacing: -0.025em; } +.member-community__eyebrow { color: #ffca63; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.12em; } +.member-community__intro { max-width: 44rem; color: #dedde9; font-size: 1.1rem; } +.member-community [role="note"] { margin-block: 1.5rem; border-radius: 0.5rem; background: #282743; color: #f3f3fa; } +.member-community__nav { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-block: 1rem; } +.member-community a { color: #d5e1ff; text-underline-offset: 0.2em; } +.member-community__nav a { display: inline-flex; min-height: 44px; align-items: center; } +.member-community__content { display: grid; grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.4fr); gap: 1.5rem; align-items: start; } +.member-community section { background: #201f38; border-color: #5e5b7c; padding: clamp(1rem, 2vw, 1.5rem); } +.member-community section h2 { font-size: 1.4rem; } +.member-community__muted { color: #d0cedf; } +.member-community__badge { display: inline-block; border: 1px solid #b9a5e2; border-radius: 2rem; padding: 0.35rem 0.75rem; color: #e3d5ff; font-weight: 600; } +.member-community__badge--active { border-color: #77d5ac; background: #163e32; color: #bdffe3; } +.member-community__event { border-top: 1px solid #5e5b7c; padding-top: 1.25rem; margin-top: 1.5rem; } +.member-community__event h3 { font-size: 1.15rem; line-height: 1.5; } +.member-community__empty { padding: 1rem; background: #2c2a47; border-radius: 0.5rem; } +.member-community__waiver { padding: 0.75rem; margin-block: 1rem; border: 1px solid #5e5b7c; border-radius: 0.5rem; } +.member-community__waiver summary { cursor: pointer; min-height: 32px; } +.member-community [role="status"] { margin-block: 1rem; padding: 0.75rem; background: #2c2a47; border-radius: 0.5rem; } +.member-community button:not(:disabled):hover { background: #1d56d2; } +.member-community :is(a, button, input, summary):focus-visible { outline: 3px solid #ffca63; outline-offset: 4px; } +.member-community input[type="checkbox"] { accent-color: #1d56d2; } +@media (max-width: 760px) { + .member-community__content { grid-template-columns: minmax(0, 1fr); gap: 0; } +} +@media (prefers-reduced-motion: reduce) { + .member-community * { scroll-behavior: auto; transition: none; } +} diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx index 994a35d..3165c6e 100644 --- a/src/pages/community/Community.test.tsx +++ b/src/pages/community/Community.test.tsx @@ -8,10 +8,18 @@ import Community from './Community'; import { callCommunity } from '../../services/community/communityService'; let mockUser: any = { uid: 'runner-a' }; -const mockFunctions = { app: { options: { projectId: 'run-mprc-staging' } } }; -const mockAuth = { currentUser: { emailVerified: true } }; +const mockApp = { options: { projectId: 'run-mprc-staging' } }; +let mockFunctions: any = { app: mockApp }; +let mockAuth: any; +let mockServices: any; +let mockSessionCallback: (user: any) => void; +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 }) })); -jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: () => ({ services: { firebaseResources: { functions: mockFunctions, auth: mockAuth } } }) })); +jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: () => ({ services: mockServices }) })); jest.mock('../../services/community/communityService', () => ({ COMMUNITY_ENABLED: true, callCommunity: jest.fn() })); const call = callCommunity as jest.Mock; const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); @@ -20,6 +28,8 @@ afterAll(() => { else Reflect.deleteProperty(globalThis, 'crypto'); }); const state = (applicationStatus = 'none', revision = 0) => ({ + schemaVersion: 1, +observedAtMs: 1800000000000, membership: { applicationStatus, revision, entitlement: 'not_entitled', accessThroughMs: null }, @@ -27,6 +37,9 @@ events: [], }); const testEvent = (extra = {}) => ({ id: 'fixture-event', +schemaVersion: 1, +amountCents: 0, +waiverHash: 'a'.repeat(64), title: 'Synthetic event', location: 'Synthetic place', startAtMs: 1800003600000, @@ -49,7 +62,10 @@ const show = () => ( ); beforeEach(() => { - jest.clearAllMocks(); mockUser = { uid: 'runner-a' }; mockAuth.currentUser.emailVerified = true; + jest.clearAllMocks(); mockUser = { uid: 'runner-a' }; + mockFunctions = { app: mockApp }; + mockAuth = { app: mockApp, tenantId: null, currentUser: { uid: 'runner-a', emailVerified: true } }; + mockServices = { firebaseResources: { functions: mockFunctions, auth: mockAuth } }; Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { randomUUID: () => '123e4567-e89b-42d3-a456-426614174000' } }); call.mockResolvedValue(state()); }); @@ -84,8 +100,8 @@ test('pending membership request uses the signed-in account and rereads status', await screen.findByText('Request status: pending.'); expect(call).toHaveBeenNthCalledWith(2, mockFunctions, { action: 'membership_join', accountUid: 'runner-a', expectedRevision: 0, requestId: '123e4567-e89b-42d3-a456-426614174000' -}); - expect(call).toHaveBeenNthCalledWith(3, mockFunctions, { action: 'status', accountUid: 'runner-a' }); +}, mockAuth); + expect(call).toHaveBeenNthCalledWith(3, mockFunctions, { action: 'status', accountUid: 'runner-a' }, mockAuth); }); test('an uncertain request retries exactly once with the original ID, then rereads', async () => { render(show()); await screen.findByRole('button', { name: 'Request membership' }); @@ -120,6 +136,8 @@ test('unverified accounts cannot start a mutation', async () => { test('linked test access is visible, cancellation preserves the term and refreshed expiry removes active state', async () => { const termEnd = 1800000060000; const active = { + schemaVersion: 1, +observedAtMs: 1800000000000, membership: { applicationStatus: 'approved', revision: 2, entitlement: 'current_member', accessThroughMs: termEnd }, @@ -147,19 +165,23 @@ test('an obsolete account status completion is ignored', async () => { let finish!: (value: unknown) => void; call.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); const view = render(show()); - mockUser = { uid: 'runner-b' }; view.rerender(show()); - await waitFor(() => expect(call).toHaveBeenCalledWith(mockFunctions, { action: 'status', accountUid: 'runner-b' })); + mockUser = { uid: 'runner-b' }; mockAuth.currentUser = { uid: 'runner-b', emailVerified: true }; + act(() => mockSessionCallback(mockAuth.currentUser)); view.rerender(show()); + await waitFor(() => expect(call).toHaveBeenCalledWith(mockFunctions, { action: 'status', accountUid: 'runner-b' }, mockAuth)); await act(async () => { finish(state('pending', 99)); }); expect(screen.queryByText('Request status: pending.')).not.toBeInTheDocument(); }); test('free-event signup and cancellation use server versions and refresh counts', async () => { const event = { id: 'fixture-event', +schemaVersion: 1, +amountCents: 0, +waiverHash: 'a'.repeat(64), title: 'Synthetic event', location: 'Synthetic place', - startAtMs: 1800000000000, + startAtMs: 1800003600000, opensAtMs: 1, -closesAtMs: 1800000000000, +closesAtMs: 1800001800000, status: 'open', signupStatus: 'open', capacity: 1, @@ -175,7 +197,12 @@ waiverVersion: 'synthetic-v1', expect(screen.getByRole('button', { name: 'Sign up for Synthetic event' })).toBeDisabled(); fireEvent.click(screen.getByRole('checkbox')); call.mockResolvedValueOnce({ registration: { status: 'confirmed', revision: 1 } }); - call.mockResolvedValueOnce({ ...active, events: [{ ...event, confirmedCount: 1, registration: { status: 'confirmed', revision: 1 } }] }); + call.mockResolvedValueOnce({ + ...active, +events: [{ + ...event, confirmedCount: 1, signupStatus: 'full', registration: { status: 'confirmed', revision: 1 } +}] +}); fireEvent.click(screen.getByRole('button', { name: 'Sign up for Synthetic event' })); await screen.findByRole('button', { name: 'Cancel signup for Synthetic event' }); expect(call.mock.calls[1][1]).toMatchObject({ @@ -195,10 +222,13 @@ test.each([ ['closed', 'Signup is closed.'], ['ended', 'The signup window has ended.'], ['full', 'No places are available for new signups.'], - [undefined, 'Signup availability could not be confirmed. Refresh status.'], - ['forged', 'Signup availability could not be confirmed. Refresh status.'], ])('unavailable or unknown server signup state %s cannot prepare a new signup', async (signupStatus, text) => { - call.mockResolvedValue({ ...state(), events: [testEvent({ signupStatus })] }); + const extra: any = { signupStatus }; + if (signupStatus === 'not_open_yet') extra.opensAtMs = 1800000010000; + if (signupStatus === 'closed') extra.status = 'closed'; + if (signupStatus === 'ended') extra.closesAtMs = 1800000000000; + if (signupStatus === 'full') extra.confirmedCount = 1; + call.mockResolvedValue({ ...state(), events: [testEvent(extra)] }); render(show()); await screen.findByText(text); expect(screen.getByRole('checkbox')).toBeDisabled(); expect(screen.getByRole('button', { name: 'Sign up for Synthetic event' })).toBeDisabled(); @@ -234,7 +264,7 @@ test('failed refresh hides old active membership/events and only a successful re expect(screen.queryByText('Test membership active.')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Cancel membership' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Sign up for Synthetic event' })).not.toBeInTheDocument(); - call.mockResolvedValueOnce({ ...state('approved', 2), events: [testEvent({ signupStatus: 'ended' })] }); + call.mockResolvedValueOnce({ ...state('approved', 2), events: [testEvent({ signupStatus: 'ended', closesAtMs: 1800000000000 })] }); fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); await screen.findByText('The signup window has ended.'); expect(screen.getByText('Test membership is not active.')).toBeInTheDocument(); @@ -255,7 +285,7 @@ test('successful action followed by failed status read hides stale state but ret await screen.findByText('Request status: pending.'); expect(call.mock.calls[3][1]).toEqual(command); }); -test.each(['not_entitled', 'decision_pending', 'forged'])('noncurrent membership %s cannot prepare signup even for an open event', async (entitlement) => { +test.each(['not_entitled', 'decision_pending'])('noncurrent membership %s cannot prepare signup even for an open event', async (entitlement) => { call.mockResolvedValue({ ...state(), membership: { ...state().membership, entitlement }, events: [testEvent()] }); @@ -266,3 +296,99 @@ test.each(['not_entitled', 'decision_pending', 'forged'])('noncurrent membership expect(signup).toBeDisabled(); fireEvent.click(signup); expect(call).toHaveBeenCalledTimes(1); }); + +test('unknown status hides all actionable state', async () => { + call.mockResolvedValue({ ...state(), events: [testEvent({ signupStatus: 'forged' })] }); + render(show()); await screen.findByText('Status could not be loaded. Try Refresh status.'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); +}); +test('same-UID credential renewal discards a saved request and its late reply', async () => { + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + let finish!: (value: unknown) => void; + call.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); + call.mockResolvedValueOnce(state()); + act(() => mockSessionCallback(mockAuth.currentUser)); + await screen.findByRole('button', { name: 'Request membership' }); + await act(async () => { finish({ membership: { revision: 1, applicationStatus: 'pending' } }); }); + expect(screen.queryByRole('button', { name: 'Retry request' })).not.toBeInTheDocument(); + expect(screen.queryByText('Request status: pending.')).not.toBeInTheDocument(); + expect(call.mock.calls.filter(([, request]) => request.action === 'membership_join')).toHaveLength(1); +}); +test('provider replacement cannot display a late old status', async () => { + let finish!: (value: unknown) => void; + call.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const view = render(show()); + mockFunctions = { app: mockApp }; + mockServices = { firebaseResources: { functions: mockFunctions, auth: mockAuth } }; + view.rerender(show()); + await screen.findByText('Request status: none.'); + await act(async () => { finish(state('pending', 99)); }); + expect(screen.queryByText('Request status: pending.')).not.toBeInTheDocument(); +}); +test('expiry hides active membership and consent without a backend poll', async () => { + jest.useFakeTimers(); + try { + const active = { ...state('approved', 2), membership: { ...state('approved', 2).membership, entitlement: 'current_member', accessThroughMs: 1800000001000 }, events: [testEvent()] }; + call.mockResolvedValue(active); render(show()); + await screen.findByText('Test membership active.'); + fireEvent.click(screen.getByRole('checkbox')); const calls = call.mock.calls.length; + act(() => jest.advanceTimersByTime(1001)); + expect(screen.queryByText('Test membership active.')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.getByText('Status needs refreshing. Refresh status before continuing.')).toBeInTheDocument(); + expect(call).toHaveBeenCalledTimes(calls); + } finally { jest.useRealTimers(); } +}); +test('slow status read cannot publish membership beyond its deadline', async () => { + jest.useFakeTimers(); + try { + let finish!: (value: unknown) => void; + call.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + render(show()); act(() => jest.advanceTimersByTime(2000)); + await act(async () => { finish({ ...state('approved', 2), membership: { ...state('approved', 2).membership, entitlement: 'current_member', accessThroughMs: 1800000001000 } }); }); + expect(screen.queryByText('Test membership active.')).not.toBeInTheDocument(); + expect(screen.getByText('Status could not be loaded. Try Refresh status.')).toBeInTheDocument(); + } finally { jest.useRealTimers(); } +}); +test('opening and closing deadlines hide controls until an explicit reread', async () => { + jest.useFakeTimers(); + try { + call.mockResolvedValue({ ...state(), events: [testEvent({ closesAtMs: 1800000001000 })] }); + render(show()); await screen.findByRole('button', { name: 'Sign up for Synthetic event' }); + act(() => jest.advanceTimersByTime(1001)); + expect(screen.queryByRole('button', { name: 'Sign up for Synthetic event' })).not.toBeInTheDocument(); + expect(call).toHaveBeenCalledTimes(1); + } finally { jest.useRealTimers(); } +}); +test('malformed mutation acknowledgement keeps an exact saved retry and does not read status', async () => { + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + call.mockResolvedValueOnce({ membership: { revision: 99, applicationStatus: 'approved' } }); + fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); + await screen.findByRole('button', { name: 'Retry request' }); + expect(call).toHaveBeenCalledTimes(2); +}); + +test('freshness expires without polling, even if the wall clock moves backwards', async () => { + jest.useFakeTimers(); + try { + call.mockResolvedValue(state()); render(show()); + await screen.findByRole('button', { name: 'Request membership' }); + jest.setSystemTime(Date.now() - 3600000); + act(() => jest.advanceTimersByTime(60001)); + expect(screen.queryByRole('button', { name: 'Request membership' })).not.toBeInTheDocument(); + expect(screen.getByText('Status needs refreshing. Refresh status before continuing.')).toBeInTheDocument(); + expect(call).toHaveBeenCalledTimes(1); + } finally { jest.useRealTimers(); } +}); +test('returning from a suspended tab invalidates status before another action', async () => { + jest.useFakeTimers(); + try { + call.mockResolvedValue(state()); render(show()); + await screen.findByRole('button', { name: 'Request membership' }); + jest.setSystemTime(Date.now() + 60001); + act(() => window.dispatchEvent(new Event('focus'))); + expect(screen.queryByRole('button', { name: 'Request membership' })).not.toBeInTheDocument(); + expect(call).toHaveBeenCalledTimes(1); + } finally { jest.useRealTimers(); } +}); diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index bc11033..643d234 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -1,4 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; +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'; @@ -6,6 +9,7 @@ import { callCommunity, COMMUNITY_ENABLED, CommunityRequest, CommunityStatus } from '../../services/community/communityService'; import SEO from '../../components/SEO'; +import { communityFreshFor, parseCommunityResult, parseCommunityStatus } from '../../services/community/communityMemberStatus'; import './Community.css'; function signupMessage(event: CommunityStatus['events'][number]) { @@ -22,101 +26,154 @@ function signupMessage(event: CommunityStatus['events'][number]) { } } +const pacificDate = (value: number) => new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', dateStyle: 'medium', timeStyle: 'short' +}).format(value); +type Snapshot = { + identity: symbol; value: CommunityStatus; began: number; wallBegan: number; budget: number; +}; +const fresh = (value: Snapshot) => Math.max( + performance.now() - value.began, + Date.now() - value.wallBegan, +) < value.budget; + export default function Community() { const { services } = useServiceLocator(); const { user, isLoading } = useAuth(); - const uid = user?.uid; + const auth = services?.firebaseResources.auth; const functions = services?.firebaseResources.functions; - const [snapshot, setSnapshot] = useState<{ - uid: string; functions: unknown; value: CommunityStatus; - } | null>(null); - const status = snapshot && snapshot.uid === uid && snapshot.functions === functions - ? snapshot.value : null; + 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('community-session'), [services, auth, functions, session, user?.uid, isLoading, enabled]); + const latestIdentity = useRef(identity); latestIdentity.current = identity; + const [snapshot, setSnapshot] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(''); const [waivers, setWaivers] = useState>({}); const generation = useRef(0); const inFlight = useRef(false); - const pending = useRef(null); - const enabled = COMMUNITY_ENABLED && functions?.app.options.projectId === 'run-mprc-staging'; - - useEffect(() => { - generation.current += 1; - const {current} = generation; - pending.current = null; inFlight.current = false; - setSnapshot(null); setBusy(false); setMessage(''); setWaivers({}); - if (enabled && uid && functions) { - callCommunity(functions, { action: 'status', accountUid: uid }).then((value) => { - if (generation.current === current) setSnapshot({ uid, functions, value }); - }).catch(() => { - if (generation.current === current) setMessage('Status could not be loaded. Try Refresh status.'); - }); - } - return () => { generation.current += 1; }; - }, [enabled, uid, functions]); + const pending = useRef<{ identity: symbol; request: CommunityRequest } | null>(null); + const current = () => enabled && !isLoading && session && auth?.currentUser === session.user + && session.user.uid === user?.uid; + const status = snapshot?.identity === identity && current() && fresh(snapshot) + ? snapshot.value : null; + const saved = pending.current?.identity === identity ? pending.current.request : null; + const verified = current() && session?.user.emailVerified === true; async function perform(request?: Omit, statusOnly = false) { - if (!enabled || !uid || !functions || inFlight.current) return; - if (request && typeof globalThis.crypto?.randomUUID !== 'function') { - setMessage('This browser cannot prepare a safe request. Use an up-to-date browser.'); - return; + if (!current() || !auth || !functions || !session + || inFlight.current || (request && !verified)) return; + // Recheck at click time too, including after a suspended background tab. + if (request && (!snapshot || snapshot.identity !== identity || !fresh(snapshot))) { + setSnapshot(null); setWaivers({}); setMessage('Status needs refreshing. Refresh status before continuing.'); return; } + const captured = identity; generation.current += 1; - const {current} = generation; + const serial = generation.current; + const stillCurrent = () => latestIdentity.current === captured + && generation.current === serial && current(); + let command = statusOnly ? null : saved; + if (request) { + try { + command = { ...request, accountUid: session.user.uid, requestId: crypto.randomUUID() }; + } catch { setMessage('This browser cannot prepare a safe request. Use an up-to-date browser.'); return; } + } + if (command) pending.current = { identity, request: command }; inFlight.current = true; setBusy(true); setMessage(''); - // Hide old status and new actions while current state is uncertain. setSnapshot(null); setWaivers({}); - let command = statusOnly ? null : pending.current; - if (!statusOnly && request) { - command = { ...request, accountUid: uid, requestId: crypto.randomUUID() }; - } - if (command) pending.current = command; try { - if (command) await callCommunity(functions, command); - if (generation.current !== current) return; - // Always reread current status: a saved retry result may predate rejoining - // or term expiry. It must never overwrite authoritative current state. - const value = await callCommunity(functions, { action: 'status', accountUid: uid }); - if (generation.current === current) { - setSnapshot({ uid, functions, value }); pending.current = null; setWaivers({}); - setMessage(command ? 'Request confirmed. Your status is shown below.' : 'Status refreshed.'); + if (command) { + parseCommunityResult(await callCommunity(functions, command, auth), command); + if (!stillCurrent()) return; } + const began = performance.now(); const wallBegan = Date.now(); + const value = parseCommunityStatus(await callCommunity(functions, { action: 'status', accountUid: session.user.uid }, auth)); + if (!stillCurrent()) return; + const next = { + identity, value, began, wallBegan, budget: communityFreshFor(value) +}; + if (!fresh(next)) throw new Error('Expired status'); + setSnapshot(next); pending.current = null; setWaivers({}); + setMessage(command ? 'Request confirmed. Your status is shown below.' : 'Status refreshed.'); } catch { - if (generation.current === current) { setSnapshot(null); setWaivers({}); setMessage(command + if (stillCurrent()) { setSnapshot(null); setWaivers({}); setMessage(command ? 'We could not confirm this request. Use Retry request; it will not create a second signup.' : 'Status could not be loaded. Try Refresh status.'); } } finally { - if (generation.current === current) { inFlight.current = false; setBusy(false); } + if (stillCurrent()) { inFlight.current = false; setBusy(false); } } } + useEffect(() => { + if (!enabled || !auth) { setSession(null); return undefined; } + return onIdTokenChanged(auth, (next) => { + generation.current += 1; pending.current = null; inFlight.current = false; + setSnapshot(null); setWaivers({}); setBusy(false); setMessage(''); + setSession(next ? { user: next, epoch: Symbol('native-community-session') } : null); + }); + }, [enabled, auth]); + useEffect(() => { + generation.current += 1; pending.current = null; inFlight.current = false; + setSnapshot(null); setBusy(false); setMessage(''); setWaivers({}); + if (current()) perform(undefined, true); + return () => { generation.current += 1; }; + // The identity represents all observed account, provider and session changes. + }, [identity]); + useEffect(() => { + if (!snapshot || snapshot.identity !== identity) return undefined; + let timer: number; + function invalidate() { + window.clearTimeout(timer); + if (snapshot && !fresh(snapshot)) { + setSnapshot(null); setWaivers({}); + setMessage('Status needs refreshing. Refresh status before continuing.'); + } else if (snapshot) { + const elapsed = Math.max( + performance.now() - snapshot.began, + Date.now() - snapshot.wallBegan, + ); + timer = window.setTimeout(invalidate, Math.max(1, Math.ceil(snapshot.budget - elapsed))); + } + } + invalidate(); + document.addEventListener('visibilitychange', invalidate); + window.addEventListener('focus', invalidate); + return () => { + window.clearTimeout(timer); document.removeEventListener('visibilitychange', invalidate); + window.removeEventListener('focus', invalidate); + }; + }, [snapshot, identity]); if (!enabled) { return ( -
+

Community signup is not available here

My Account
); } - if (isLoading) return

Loading account…

; - if (!uid) { return ( -
+ if (isLoading) return

Loading account…

; + if (!user?.uid) { return ( +
+

MID-PENINSULA RUNNING CLUB

Membership and free events

+

Your membership, upcoming runs and signups in one place.

Staging test only. Use made-up accounts. No real membership, race entry or payment is created.

Sign in to continue
); } - const verified = services?.firebaseResources.auth.currentUser?.emailVerified === true; - const blocked = busy || pending.current !== null || !verified; + const blocked = busy || saved !== null || !verified; const currentMember = status?.membership.entitlement === 'current_member'; return ( -
+
+

MID-PENINSULA RUNNING CLUB

Membership and free events

+

Your membership, upcoming runs and signups in one place.

Staging test only. Use made-up accounts. No real membership, race entry or payment is created.

-

+

{' '} - {pending.current && } - {!verified && ( + {saved && } + {busy &&

Loading your current status…

} + {!current() && !isLoading &&

Confirming your signed-in account. If this continues, return to My Account and sign in again.

} + {current() && !verified && (

Verify your email in My Account before requesting membership or signing up.

)} {message &&

{message}

} {status && ( - <> -
+
+

Club membership

Request status: @@ -144,16 +203,15 @@ export default function Community() { {status.membership.applicationStatus.replace(/_/g, ' ')} .

-

- Signup requests await officer confirmation of dues. They do not grant paid membership. -

-

{status.membership.entitlement === 'current_member' ? 'Test membership active.' : 'Test membership is not active.'}

+

This test screen records a membership request only. Online dues and family invitations are not available here yet.

+

{status.membership.entitlement === 'current_member' ? 'Test membership active.' : 'Test membership is not active.'}

{status.membership.entitlement === 'current_member' && status.membership.accessThroughMs && (

Access continues through the paid term ending {' '} - {new Date(status.membership.accessThroughMs).toLocaleString()} + {pacificDate(status.membership.accessThroughMs)} + {' Pacific time'} .

)} @@ -174,12 +232,17 @@ export default function Community() { You can still cancel an existing signup.

)} - {status.events.length === 0 &&

No free events are available.

} +

Event times are shown in Pacific time. A place is confirmed only after the server accepts your signup.

+ {status.events.length === 0 &&

No free events are available. Check back for the next club run.

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

{event.title}

- {new Date(event.startAtMs).toLocaleString()} + {' '} · {' '} @@ -200,7 +263,10 @@ export default function Community() {

{event.registration.status !== 'confirmed' && ( <> -

{event.waiverText}

+
+ Read the test event waiver +

{event.waiverText}

+
))}
- +
)}
); diff --git a/src/services/community/communityMemberStatus.test.ts b/src/services/community/communityMemberStatus.test.ts new file mode 100644 index 0000000..5c299fe --- /dev/null +++ b/src/services/community/communityMemberStatus.test.ts @@ -0,0 +1,84 @@ +import { communityFreshFor, parseCommunityResult, parseCommunityStatus } from './communityMemberStatus'; + +const now = 1_800_000_000_000; +const event = () => ({ + id: 'fixture-event', +schemaVersion: 1, +title: 'Synthetic event', +location: 'Synthetic place', + startAtMs: now + 3600000, +opensAtMs: now - 1000, +closesAtMs: now + 1800000, + status: 'open', +amountCents: 0, +capacity: 2, +confirmedCount: 0, +waiverVersion: 'synthetic-v1', + waiverText: 'Synthetic waiver only', +waiverHash: 'a'.repeat(64), +signupStatus: 'open', +registration: { revision: 0, status: 'none' } +}); +const state = () => ({ + schemaVersion: 1, +observedAtMs: now, + membership: { + revision: 2, applicationStatus: 'approved', entitlement: 'current_member', accessThroughMs: now + 120000 +}, +events: [event()] +}); +test('valid server status is copied/frozen and freshness is bounded without choosing eligibility', () => { + const input = state(); const parsed = parseCommunityStatus(input); + expect(parsed).toEqual(input); expect(Object.isFrozen(parsed.events[0].registration)).toBe(true); + input.membership.accessThroughMs = now + 1; + expect(parsed.membership.accessThroughMs).toBe(now + 120000); + expect(communityFreshFor(parsed)).toBe(60000); +}); +test.each(['membership', 'opening', 'closing'])('earlier %s deadline bounds display', (deadline) => { + const input = state(); + if (deadline === 'membership') input.membership.accessThroughMs = now + 1000; + if (deadline === 'opening') { input.events[0].opensAtMs = now + 1000; input.events[0].signupStatus = 'not_open_yet'; } + if (deadline === 'closing') input.events[0].closesAtMs = now + 1000; + expect(communityFreshFor(parseCommunityStatus(input))).toBe(1000); +}); +test.each([ + (value: any) => { const v = value; v.schemaVersion = 2; }, + (value: any) => { const v = value; delete v.observedAtMs; }, + (value: any) => { const v = value; v.observedAtMs = Infinity; }, + (value: any) => { const v = value; v.membership.accessThroughMs = now; }, + (value: any) => { const v = value; v.membership.entitlement = 'admin'; }, + (value: any) => { const v = value; v.membership.applicationStatus = 'pending'; }, + (value: any) => { const v = value; v.events[0].amountCents = 1; }, + (value: any) => { const v = value; v.events[0].signupStatus = 'full'; }, + (value: any) => { const v = value; v.events.push(v.events[0]); }, + (value: any) => { const v = value; v.events[0].confirmedCount = 3; }, + (value: any) => { const v = value; v.events[0].registration.status = 'paid'; }, + (value: any) => { const v = value; v.events[0].waiverHash = 'wrong'; }, + (value: any) => { const v = value; v.events[0].extra = 'private-canary'; }, + (value: any) => { const v = value; v.extra = 'private-canary'; }, + (value: any) => { const v = value; v.events[0].waiverVersion = 'x'.repeat(81); }, + (value: any) => { const v = value; v.events = Array(21).fill(event()); }, + (value: any) => { const v = value; v.events = new Array(1); }, +])('rejects corrupt/foreign/bounded response without echoing it %#', (change) => { + const input = state(); change(input); + expect(() => parseCommunityStatus(input)).toThrow('Community status is unavailable. Refresh or sign in again.'); +}); +test('response getters are rejected without invocation', () => { + const getter = jest.fn(() => 'private-canary'); const input = state(); + Object.defineProperty(input.membership, 'entitlement', { enumerable: true, get: getter }); + expect(() => parseCommunityStatus(input)).toThrow(); expect(getter).not.toHaveBeenCalled(); +}); +test('cancel remains representable after a term lapses or an event closes', () => { + const input: any = state(); input.membership.entitlement = 'not_entitled'; input.membership.accessThroughMs = null; + input.events[0].status = 'cancelled'; input.events[0].signupStatus = 'cancelled'; + input.events[0].registration = { revision: 1, status: 'confirmed' }; + expect(parseCommunityStatus(input).events[0].registration.status).toBe('confirmed'); +}); +test('only matching action and revision acknowledgements allow a status reread', () => { + const request: any = { action: 'event_join', expectedRevision: 0 }; + expect(() => parseCommunityResult({ registration: { revision: 1, status: 'confirmed' } }, request)).not.toThrow(); + [{}, { registration: { revision: 99, status: 'confirmed' } }, + { registration: { revision: 1, status: 'cancelled' } }, { membership: { revision: 1, applicationStatus: 'pending' } }].forEach((result) => { + expect(() => parseCommunityResult(result, request)).toThrow(); + }); +}); diff --git a/src/services/community/communityMemberStatus.ts b/src/services/community/communityMemberStatus.ts new file mode 100644 index 0000000..0471ec4 --- /dev/null +++ b/src/services/community/communityMemberStatus.ts @@ -0,0 +1,115 @@ +import type { CommunityRequest, CommunityStatus } from './communityService'; + +const MESSAGE = 'Community status is unavailable. Refresh or sign in again.'; +export const COMMUNITY_FRESHNESS_MS = 60_000; +const check = (value: unknown) => { if (!value) throw new Error(MESSAGE); }; +const integer = (value: unknown, max = 8_640_000_000_000_000): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max; +const id = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value); +const text = (value: unknown, max = 200) => typeof value === 'string' && value.trim().length > 0 && value.length <= max; +const oneOf = (value: unknown, values: string[]) => typeof value === 'string' && values.includes(value); +type Data = Record; +function object(value: unknown, required: string[], optional: string[] = []): Data { + check(value !== null && typeof value === 'object' && !Array.isArray(value)); + const prototype = Object.getPrototypeOf(value); + check(prototype === Object.prototype || prototype === null); + const descriptors = Object.getOwnPropertyDescriptors(value); + check(required.every((key) => Object.prototype.hasOwnProperty.call(descriptors, key))); + check(Reflect.ownKeys(descriptors).every((key) => typeof key === 'string' + && [...required, ...optional].includes(key) + && Object.prototype.hasOwnProperty.call(descriptors[key], 'value') && descriptors[key].enumerable)); + return Object.fromEntries(Object.keys(descriptors).map((key) => [key, descriptors[key].value])); +} +function eventsArray(value: unknown): unknown[] { + check(Array.isArray(value)); + const fields = Object.getOwnPropertyDescriptors(value); + const length = fields.length?.value; + check(integer(length, 20) && Reflect.ownKeys(fields).length === length + 1); + return Array.from({ length }, (_, index) => { + const field = fields[String(index)]; + check(field && Object.prototype.hasOwnProperty.call(field, 'value') && field.enumerable); + return field.value; + }); +} +function status(input: unknown): CommunityStatus { + const value = object(input, ['schemaVersion', 'observedAtMs', 'membership', 'events']); + check(value.schemaVersion === 1 && integer(value.observedAtMs)); + const now = Number(value.observedAtMs); + const membership = object(value.membership, ['revision', 'applicationStatus', 'entitlement', 'accessThroughMs']); + check(integer(membership.revision, 1_000_000) + && oneOf(membership.applicationStatus, ['none', 'pending', 'approved', 'withdrawn', 'cancellation_requested']) + && oneOf(membership.entitlement, ['current_member', 'not_entitled', 'decision_pending'])); + if (membership.entitlement === 'current_member') { + check(oneOf(membership.applicationStatus, ['approved', 'cancellation_requested']) + && integer(membership.accessThroughMs) && membership.accessThroughMs > now + && Number(membership.revision) > 0); + } else check(membership.accessThroughMs === null); + const ids = new Set(); + const events = eventsArray(value.events).map((inputEvent) => { + const event = object(inputEvent, ['id', 'schemaVersion', 'title', 'location', 'startAtMs', 'opensAtMs', 'closesAtMs', 'status', 'amountCents', 'capacity', 'confirmedCount', 'waiverVersion', 'waiverText', 'waiverHash', 'signupStatus', 'registration'], ['revision']); + check(id(event.id) && !ids.has(event.id)); ids.add(event.id); + check(event.schemaVersion === 1 && event.amountCents === 0 + && text(event.title) && text(event.location) + && text(event.waiverVersion, 80) && text(event.waiverText, 2000) + && typeof event.waiverHash === 'string' && /^[a-f0-9]{64}$/.test(event.waiverHash) + && [event.startAtMs, event.opensAtMs, event.closesAtMs].every((time) => integer(time)) + && Number(event.opensAtMs) < Number(event.closesAtMs) + && Number(event.closesAtMs) <= Number(event.startAtMs) + && integer(event.capacity, 10000) && event.capacity > 0 + && integer(event.confirmedCount, event.capacity) + && (event.revision === undefined || integer(event.revision, 1_000_000)) + && oneOf(event.status, ['open', 'closed', 'cancelled'])); + let expected = 'open'; + if (event.status === 'cancelled') expected = 'cancelled'; + else if (event.status === 'closed') expected = 'closed'; + else if (now < Number(event.opensAtMs)) expected = 'not_open_yet'; + else if (now >= Number(event.closesAtMs)) expected = 'ended'; + else if (event.confirmedCount === event.capacity) expected = 'full'; + check(event.signupStatus === expected); + const registration = object(event.registration, ['revision', 'status']); + check(integer(registration.revision, 1_000_000) && oneOf(registration.status, ['none', 'confirmed', 'cancelled']) + && (registration.status === 'none' ? registration.revision === 0 : Number(registration.revision) > 0)); + return Object.freeze({ ...event, registration: Object.freeze(registration) }); + }); + return Object.freeze({ + schemaVersion: 1, +observedAtMs: now, + membership: Object.freeze(membership), +events: Object.freeze(events) +}) as unknown as CommunityStatus; +} +export function parseCommunityStatus(input: unknown): CommunityStatus { + try { return status(input); } catch { throw new Error(MESSAGE); } +} +export function parseCommunityResult(input: unknown, request: CommunityRequest) { + try { + check(request.action !== 'status' && integer(request.expectedRevision, 999999)); + const key = request.action.startsWith('membership_') ? 'membership' : 'registration'; + const result = object(input, [key]); + const field = key === 'membership' ? 'applicationStatus' : 'status'; + const record = object(result[key], ['revision', field]); + check(integer(record.revision, 1_000_000) + && [request.expectedRevision, Number(request.expectedRevision) + 1] + .includes(Number(record.revision))); + const outcomes = { + membership_join: ['pending'], +membership_cancel: ['withdrawn', 'cancellation_requested'], + event_join: ['confirmed'], +event_cancel: ['cancelled'] + }; + const expected = outcomes[request.action as keyof typeof outcomes]; + check(expected); + check(oneOf(record[field], expected)); + } catch { throw new Error(MESSAGE); } +} +// Subtract request duration from this budget at the screen. Server time, rather +// than the user's wall clock, determines known deadlines. No provider polling. +export function communityFreshFor(value: CommunityStatus) { + const deadlines = [value.observedAtMs + COMMUNITY_FRESHNESS_MS]; + if (value.membership.accessThroughMs !== null) deadlines.push(value.membership.accessThroughMs); + value.events.forEach((event) => { + [event.opensAtMs, event.closesAtMs].forEach((time) => { + if (time > value.observedAtMs) deadlines.push(time); + }); + }); + return Math.max(0, Math.min(...deadlines) - value.observedAtMs); +} diff --git a/src/services/community/communityService.test.ts b/src/services/community/communityService.test.ts index 87904a6..97759f2 100644 --- a/src/services/community/communityService.test.ts +++ b/src/services/community/communityService.test.ts @@ -18,11 +18,34 @@ test.each([['production', 'true'], ['staging', 'false'], ['staging', undefined]] jest.resetModules(); const { COMMUNITY_ENABLED, callCommunity, callAdministration } = await import('./communityService'); expect(COMMUNITY_ENABLED).toBe(false); - await expect(callCommunity({ app: { options: { projectId: 'run-mprc-staging' } } } as any, { action: 'status', accountUid: 'synthetic' })).rejects.toThrow(); + await expect(callCommunity({ app: { options: { projectId: 'run-mprc-staging' } } } as any, { action: 'status', accountUid: 'synthetic' }, {} as any)).rejects.toThrow(); await expect(callAdministration({ app: { options: { projectId: 'run-mprc-staging' } } } as any, { action: 'admin_status', accountUid: 'synthetic' })).rejects.toThrow(); },); test('a staging flag cannot admit a production Firebase project', async () => { process.env.REACT_APP_FIREBASE_ENVIRONMENT = 'staging'; process.env.REACT_APP_COMMUNITY_STAGING = 'true'; jest.resetModules(); const { callCommunity } = await import('./communityService'); - await expect(callCommunity({ app: { options: { projectId: 'mid-peninsula-running-club' } } } as any, { action: 'status', accountUid: 'synthetic' })).rejects.toThrow(); + await expect(callCommunity({ app: { options: { projectId: 'mid-peninsula-running-club' } } } as any, { action: 'status', accountUid: 'synthetic' }, {} as any)).rejects.toThrow(); +}); + +test('native account binding fences cross-account, cross-app and changed-identity results', async () => { + process.env.REACT_APP_FIREBASE_ENVIRONMENT = 'staging'; process.env.REACT_APP_COMMUNITY_STAGING = 'true'; + jest.resetModules(); const { callCommunity } = await import('./communityService'); + const { httpsCallable } = await import('firebase/functions'); + const app = { options: { projectId: 'run-mprc-staging' } }; + const identity = { uid: 'synthetic', emailVerified: true }; + const auth: any = { app, tenantId: null, currentUser: identity }; + const functions: any = { app }; + const request: any = { action: 'status', accountUid: 'synthetic' }; + await expect(callCommunity(functions, request, auth)).resolves.toEqual({ synthetic: true }); + await Promise.all([{ ...auth, tenantId: 'foreign' }, { ...auth, app: {} }, + { ...auth, currentUser: { ...identity, uid: 'other' } }, { ...auth, currentUser: null }] + .map((altered) => expect(callCommunity(functions, request, altered)).rejects.toThrow())); + let finish!: (value: unknown) => void; + (httpsCallable as jest.Mock).mockReturnValueOnce( + () => new Promise((resolve) => { finish = resolve; }) + ); + const pending = callCommunity(functions, request, auth); + auth.currentUser = { ...identity }; + finish({ data: { synthetic: true } }); + await expect(pending).rejects.toThrow(); }); diff --git a/src/services/community/communityService.ts b/src/services/community/communityService.ts index 35867dc..d0fe0f5 100644 --- a/src/services/community/communityService.ts +++ b/src/services/community/communityService.ts @@ -1,9 +1,11 @@ import { httpsCallable, Functions } from 'firebase/functions'; +import { Auth } from 'firebase/auth'; export const COMMUNITY_ENABLED = process.env.REACT_APP_FIREBASE_ENVIRONMENT === 'staging' && process.env.REACT_APP_COMMUNITY_STAGING === 'true'; export interface CommunityStatus { + schemaVersion: 1; observedAtMs: number; membership: { revision: number; applicationStatus: string; entitlement: string; accessThroughMs: number | null; @@ -25,11 +27,22 @@ export interface CommunityRequest { waiverVersion?: string; waiverAccepted?: boolean; } -export async function callCommunity(functions: Functions, request: CommunityRequest) { - if (!COMMUNITY_ENABLED || functions.app.options.projectId !== 'run-mprc-staging') { +export async function callCommunity( + functions: Functions, + request: CommunityRequest, + auth: Auth, +): Promise { + const identity = auth?.currentUser; + const bound = () => identity && auth.currentUser === identity + && auth.app === functions.app && auth.tenantId === null + && identity.uid === request.accountUid + && (request.action === 'status' || identity.emailVerified); + if (!COMMUNITY_ENABLED || functions.app.options.projectId !== 'run-mprc-staging' || !bound()) { throw new Error('Community staging is unavailable.'); } - return (await httpsCallable(functions, 'communityActions')(request)).data; + const result = await httpsCallable(functions, 'communityActions')(request); + if (!bound()) throw new Error('Community staging is unavailable.'); + return result.data; } export interface FreeEventDraft { From 7f8d804e6a755011d07c19f349c441b332eb3134 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Fri, 16 Oct 2026 17:31:12 -0700 Subject: [PATCH 2/2] Count new member parser files in exact lint inventory guard --- tests/ci-workflow.test.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index b3a7bac..50e1fea 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 = 131; +const EXPECTED_LINT_FILES = 133; const EXPECTED_LINT_ERRORS = 113; const EXPECTED_LINT_WARNINGS = 6; const YAML_TO_JSON = [ @@ -592,6 +592,21 @@ test('fictional race client and test additions have no baseline lint debt', () = } }); +test('member screen and parser additions have no baseline lint debt', () => { + const clean = [ + 'src/pages/community/Community.tsx', + 'src/pages/community/Community.test.tsx', + 'src/services/community/communityService.ts', + 'src/services/community/communityService.test.ts', + 'src/services/community/communityMemberStatus.ts', + 'src/services/community/communityMemberStatus.test.ts', + ]; + for (const file of clean) { + 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'),