diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index f6a143fa..6dce24d2 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -10,12 +10,12 @@ "targets": [ "src" ], - "scannedFileCount": 120, + "scannedFileCount": 124, "scannedFilesByExtension": { ".js": 12, ".jsx": 35, - ".ts": 43, - ".tsx": 30 + ".ts": 45, + ".tsx": 32 }, "errorCount": 113, "warningCount": 6, @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:b62532df877e1a1599273d0bdbf9876e75dae8be6e60ecadce601f854c283b6a", + "fingerprint": "sha256:314fc7ec13b35e59b8dfddedb5c8f7b4a82d02c368c81e991e7de0aa50d3245a", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -79,6 +79,8 @@ "src/pages/admin/shop/AdminOrders.tsx", "src/pages/admin/shop/AdminProductEditor.tsx", "src/pages/admin/shop/AdminProducts.tsx", + "src/pages/community/Community.test.tsx", + "src/pages/community/Community.tsx", "src/pages/contact/Contact.jsx", "src/pages/events/EventCalendar.tsx", "src/pages/events/EventDetail.tsx", @@ -122,6 +124,8 @@ "src/services/accountEmail/accountEmailSender.ts", "src/services/analytics/analytics.test.ts", "src/services/analytics/analytics.ts", + "src/services/community/communityService.test.ts", + "src/services/community/communityService.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 6663357d..70ee857b 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**COMMUNITY-002 / #707:** one staging-only service/UI for pending membership requests/withdrawal, paid-term cancellation intent and free-event signup/cancellation. Dependencies: #701 hosted profiles/Auth/App Check, #705 approved paid-term policy and existing pure authority; paid activation/dues/production work stays separate. UID-only additive server collections, native identity/attestation, transaction/revision/saved-command safety and bounded reads; no payment, role, email, schedule, migration or production fallback. Officer impact: disposable staging review only. Officer documentation: STAGING_COMMUNITY plus entrypoints/maps/cost guide. Deployment evidence: source/emulator preparation only until native hosted acceptance is recorded. #114 and production launch remain open. + **MEMBERS-CANCEL-001A / #705 (2026-10-11):** owner-approved cancellation retains membership access through the already-paid annual term and does not issue an automatic refund. Scope: versioned backend source policy, existing authority expiry/ownership/suspension regressions, and officer handoff. No cancellation endpoint, persistence, claim, Rule, IAM, website, provider or deployment change. #114 and hosted membership/event signup/cancellation remain open. Officer impact: the rule is recorded; no new member control is available. Officer documentation: entrypoint and Events/Shop/Members guide. Deployment evidence: source/local checks only until native issue records otherwise. **CI-001C4 / #701 (2026-10-11):** exactly `createMemberOnSignUp` and `ensureMemberProfile` were deployed to isolated engineering staging by protected run 38176811598 from `2843388`, with six exact-main checks and normal delegated daliu approval. Both native source archives match the reviewed bundle; Node 22/min0/max2/256 MB/30s and explicit runtime/build identities are verified. Genuine browser App Check/Auth, seven callable denials, signup, recovery, two concurrency cases, profile/claim preservation and background replay passed. Two owned test accounts/profiles were removed; Auth count0/empty collection metadata restored. No schema migration, real data, email, payment, website publication or production deployment. Officer impact: verified staging profiles and no administrator bypass. Officer documentation: low-cost procedure, maps and entrypoints updated. Deployment evidence: native issue and dated guide. #113/#133/#136 and broader core operations stay open. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ab2fbd07..3184c843 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -15,6 +15,8 @@ RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) pr ## 1. Program outcome +**#707 staging slice:** implement and test pending membership signup/withdrawal, paid-term cancellation intent and free-event signup/cancellation using one isolated service. No payment/privileged membership activation is prerequisite for these nonfinancial requests; those workflows remain separate. Fixed owner-approved staging release must verify exact merged source and all six main CI jobs, native source archive, explicit runtime/build identities and resource limits before staging Hosting. No production release is authorized. + **Membership cancellation policy, 2026-10-11 — #705:** Dave approved retaining access until the already-paid annual term ends, without an automatic refund. Versioned backend source configuration and boundary regressions are a small dependency-clearing slice only. A member-owned durable cancellation request, retries/audit, UI and disposable hosted staging proof remain separate implementation work; do not route this request through legacy admin race cancellation or mark the membership ended early. MPRC can open a race or merchandise item for sale only when the platform can: diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 513dc5b1..b5a02628 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -16,6 +16,8 @@ Attach the exact wording, public link, or approved photo when you have it. The A ## Choose the closest task +**Staging membership/free events — #707:** one isolated service and screen are being prepared for pending membership signup/withdrawal, paid-term cancellation requests and free-event signup/cancellation. No paid membership activation or real payment is included. Hosted acceptance is not verified yet. Follow the [staging-only review](./docs/officers/STAGING_COMMUNITY.md); production remains **NOT AVAILABLE YET**. + **Membership cancellation decision, 2026-10-11:** Dave approved keeping access through the already-paid annual term, without an automatic refund. [#705](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/705) records the rule and checks the existing term-end logic only. A member cancellation control is **NOT AVAILABLE YET**; do not edit a member record or use race cancellation as a workaround. See [the membership policy handoff](./docs/officers/EVENTS_SHOP_MEMBERS.md#membership-cancellation--policy-approved-not-available-yet). **Staging profiles verified, 2026-10-11:** [#701](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/701) deployed and tested exactly two profile Functions, using scoped identities, genuine browser App Check and made-up accounts. Signup, recovery, denied requests, retries and concurrency passed; both test accounts/profiles were removed. With Dave's delegated decision, administrator bypass is disabled for production/backend-staging; normal self-approval remains. Original read-only staging is unchanged. Follow [the dated staging proof](./docs/officers/LOW_COST_COMMUNITY.md#engineering-staging-profiles--701). Production, membership, registration and payments are not launched; no officer handles a key or member record. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 81abcd66..5cc474df 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -828,6 +828,8 @@ The current protected release plan names only the profile-recovery Functions. It ## 9. Staging dress rehearsal +For #707, only the club owner may use the fixed `scripts/staging-community-release.js` after reviewed exact-main source/six successful jobs. It deploys one isolated Function through the existing explicit builder, with a separate four-permission runtime and native archive/resource readback. This is a separately approved staging owner route, not a widening of the protected profile workflow or a reusable production release. The profile path may inspect the one known community service but still deploys only its original pair. The specialist supplies/removes UID-only synthetic fixtures, tests actual Auth/App Check plus retry/ownership/capacity/term-boundary behavior, then records staged Hosting and cleanup evidence. Officers use [the no-terminal staging review](./docs/officers/STAGING_COMMUNITY.md). Never run against production or a payment provider; future release cleanup/rollback uses a separate reviewed owner action, not direct member edits. + For future membership cancellation acceptance, use a disposable account and a synthetic approved term. Prove access remains just before the original term end and is denied at/after it; the term and payment evidence must not change. Also prove ownership denials, repeated/lost-response recovery, and that suspension is not undone. #705 currently supplies only the approved policy and existing-authority unit regressions, not the cancellation request or hosted proof. Do not test with production members or real payments. Before every major payment release: diff --git a/SECURITY.md b/SECURITY.md index 0bd88b58..8a3e4380 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,8 @@ This is both the repository security policy and the current engineering risk reg ## 1. Production readiness decision +**#707 isolated staging boundary:** one new service requires native Auth/App Check and an exact caller-account match; mutations also require verified email. It accepts closed bounded inputs and server-owned free-event/waiver/capacity state, saves command results transactionally and fences stale revisions. Browser access to new collections remains denied, including admins. The separate staging runtime has only transaction/get/create/update Firestore permissions; IAM is project-scoped, not collection-scoped. It has no Auth mutation, delete, secret, key, payment or mail permission. No claims, real membership/payment data, production fallback, raw error/body logging or legacy schema migration. Existing two profile Functions and their runtime IAM are unchanged. Hosted enforcement must be separately verified before claiming this source is live. + The public informational website can continue operating with normal care. **Live race or merchandise payments should remain disabled until every P0 launch blocker below is closed and verified in a staging dress rehearsal.** The existing payment implementation is a useful prototype, but several correctness defects could mark unpaid transactions paid, lose payment callbacks, oversell race capacity, fail to reconcile late-payment links, or allow overly broad administrative access. ### Runtime prerequisite — source only, 2026-09-27 diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index db358027..a6bd3bea 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -8,6 +8,8 @@ This document defines how MPRC should configure Stripe and how race-registration ## 1. Launch status +**#707 exception is nonfinancial staging only:** pending membership requests and zero-price free-event registrations use a separate service/collections, never `createCheckoutSession`, legacy admin cancellation, Stripe, mail or payment state. Free registrations store `paymentStatus=not_required`, never paid/comp money evidence. Approved membership cancellation does not trigger a refund or alter a term. All live-payment blockers below remain unchanged. + **Do not enable or advertise live checkout yet.** The initial 2026-07-12 assessment found unsafe payment confirmation, no Event-ID ledger, enabled unmodeled promotions, and callback loss. Repository safety slices now validate paid/amount/currency/mode/explicit livemode, reject unapproved adjustments, deduplicate Events, protect terminal/refund state, and preserve callbacks. PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) is merged source; PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) supplies the adjustment guard and exact tests. Neither statement is deployment, provider, or live-behavior evidence, and these slices do not close the payment architecture. These residual conditions still prevent safe production use: - Checkout creation lacks validated fail-closed environment configuration, command idempotency, a persistence-first saga, and the canonical versioned state/schema contract. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 0556cd2d..fa4e76ac 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -10,6 +10,8 @@ The repository already contains a substantial prototype: a public React site, Fi ## 1. Goals and non-goals +**Isolated nonfinancial lifecycle — #707, source prepared:** `communityOnly.js` exports only `communityActions`, outside the prototype index. Native Auth/App Check, exact account binding and verified email protect mutations. UID-only pending membership requests never grant dues/roles; cancellation preserves an approved synthetic authority record and its original expiry. Server-owned zero-price events/waiver snapshots and UID registrations share one capacity transaction. Per-account command results/revision fences prevent old cancellation retries changing a later signup. New `communityMemberships`, `communityEvents`, `communityCommands`, `communityLimits` and `communityConfig` collections are server-only; no migration or legacy commerce adoption. A separate runtime has exactly Firestore transaction/get/create/update permissions at staging-project scope. UI and server fail closed outside staging. The existing pure authority is now consumed only by this isolated staging service; earlier unused-runtime statements below remain historical outside this scope. No role/dues activation, Stripe/mail, officer admin endpoint, schedule or production release is included. Hosted acceptance remains unverified; see the [officer staging guide](./docs/officers/STAGING_COMMUNITY.md). + **Engineering staging checkpoint, 2026-10-11 — #701:** only signup-profile creation and profile recovery are hosted and verified on Node 22. A profile-only entrypoint, explicit runtime/build accounts, scoped IAM and protected keyless deployment exclude commerce/mail/directory/Strava and website publication. Actual source archives/resource limits, genuine App Check/Auth, synthetic signup/recovery/denials/retries/concurrency and owned cleanup passed. Auth count returned to zero; no migration or production data/payment change. This supersedes source-only runtime/profile wording below only for isolated staging, not production or the target commerce platform. See the [dated officer map](./docs/officers/SYSTEM_MAPS.md#scoped-engineering-profile-release--701) and [acceptance proof](./docs/officers/LOW_COST_COMMUNITY.md#engineering-staging-profiles--701). ### Community-first cost direction — 2026-09-11 diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 59ba4d0b..0d572f2c 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -12,6 +12,8 @@ There is currently no proven no-code switch that safely stops all new Stripe pay ## Membership cancellation — POLICY APPROVED, NOT AVAILABLE YET +[#707](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/707) prepares the separate [staging-only request and free-event review](./STAGING_COMMUNITY.md). This does not launch production membership, grant a paid term, or repair paid race cancellation. Hosted evidence is still required. + Dave approved this rule on October 11, 2026: cancelling membership keeps access through the already-paid annual term. Cancellation does not issue a refund. It does not extend the term or restore suspended access. Refunds and race cancellation remain separate. There is no new member cancellation button or officer procedure from [#705](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/705). Its versioned rule and local tests do not prove a hosted workflow. Do not edit membership dates, mark a term ended, issue a refund, or use an admin race action as a workaround. Ask Dave, the sole release owner, for the separately reviewed request workflow and disposable staging proof before using it. A specialist is still needed to build and release that workflow; an officer needs no terminal, source edit, secret or member record to review this decision. diff --git a/docs/officers/LOW_COST_COMMUNITY.md b/docs/officers/LOW_COST_COMMUNITY.md index 68765620..67624c5e 100644 --- a/docs/officers/LOW_COST_COMMUNITY.md +++ b/docs/officers/LOW_COST_COMMUNITY.md @@ -8,6 +8,8 @@ ## What we are prioritizing +[#707](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/707) adds one staging-only nonfinancial service, with the same min0/max2/256MB/30s limits and no polling, mail worker, subscription or scheduled job. Existing profile IAM stays unchanged; the new runtime has four Firestore permissions at staging-project scope. See the [staging scope, proof and recovery guide](./STAGING_COMMUNITY.md). This source preparation is not deployment proof or a cost guarantee. + - Clear information about regular runs, meeting places, joining, and contacts. - A useful events/calendar experience on phones as well as computers. - Public information without requiring an account. diff --git a/docs/officers/README.md b/docs/officers/README.md index f7f22904..40670fe2 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -33,6 +33,8 @@ In words: approve the merge, request one exact release, and approve its protecte ## Short guides +For the isolated membership/free-event staging test, use [Staging community actions](./STAGING_COMMUNITY.md). Its status distinguishes source preparation from hosted proof; it is not a production procedure. + **Staging profiles verified, #701:** two scoped Node 22 Functions passed protected deployment, genuine browser App Check/Auth, signup, recovery, negative requests, retries and concurrency. Only made-up accounts/profiles were used and then removed. Administrator bypass is disabled for production/backend-staging; Dave/main and normal self-approval remain. Read [the dated proof and procedure](./LOW_COST_COMMUNITY.md#engineering-staging-profiles--701). Existing read-only reviewers gain no deployment access; production data/payments and website publication are unchanged. Membership and registrations remain unavailable. 1. [Request a change](./REQUEST_A_CHANGE.md) diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md new file mode 100644 index 00000000..fe73da58 --- /dev/null +++ b/docs/officers/STAGING_COMMUNITY.md @@ -0,0 +1,52 @@ +# Membership and free events — staging only + +**Status:** SOURCE PREPARED; hosted deployment and acceptance are not verified yet under [#707](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/707). Production is **NOT AVAILABLE YET**. + +**Purpose:** test membership requests and free-event signup/cancellation without real members or payments. + +**Approver:** Dave Liu, the sole staging release/recovery owner. No backup owner is appointed. + +**Prerequisites:** the platform specialist supplies the exact reviewed release, a disposable verified account and a visibly made-up free event. Use only the staging website. Do not use real runner details or payment information. + +## What the actions mean + +- Request membership: records a pending request. It does not prove dues or grant membership. +- Withdraw membership request: withdraws a pending application. +- Cancel membership: records a cancellation request for an already-approved synthetic membership. Access lasts through its original paid term. No refund occurs. +- Free-event signup: confirms one place and records acceptance of the displayed test waiver. +- Cancel event signup: releases that place once. Old retries cannot cancel a later signup. + +Officer review after the specialist confirms deployment: + +1. Open the specialist-supplied staging `/community` link. +2. Confirm the page says **Staging test only**. +3. Sign in with the disposable verified account supplied privately for this test. +4. Select **Request membership**. +5. Confirm the request is pending and does not grant paid membership. +6. Select **Withdraw membership request**. +7. Confirm the request is withdrawn. +8. Read the visibly synthetic event waiver. +9. Select its acceptance checkbox. +10. Select the event signup button. +11. Confirm your signup is confirmed and the booked count increases by one. +12. Select the event cancellation button. +13. Confirm your signup is cancelled and the place is released. +14. If the result is uncertain, use **Retry request**. It reuses the same request. +15. Use **Refresh status** to read the current result without sending a new action. +16. Sign out when the review ends. + +**Expected result:** pending/withdrawn membership requests and confirmed/cancelled free registrations are visible. No charge, refund, email, role grant or real race entry occurs. + +**Stop conditions:** a production address, real data, payment form, unexplained count, early loss/extension of paid access, restored suspended access, or request for secrets or direct database edits. Tell Dave privately; do not paste private details into an issue or AI tool. + +**Success proof:** the specialist records exact source/CI, native Function archive and resource/IAM readback, staging Hosting release, genuine Auth/App Check denials, capacity/retry/ownership tests, and disposable-data cleanup separately. Local tests or a merge alone are not hosted proof. The term-end check uses synthetic evidence, not a real payment. + +**Undo:** stop the staging review. Ask Dave and the platform specialist to use the reviewed staging rollback or remove the one staged community service. Do not edit a member record, role, payment or production setting yourself. + +**Escalation:** Dave/platform specialist for deployment, access, uncertain results or recovery. Future officer dues confirmation, event editing, paid registration/refunds, and production release require separate work. The specialist still provisions and removes test fixtures; officers do not use a terminal or change source. + +## Cost and scope + +One new staging Function has zero reserved instances, at most two instances, 256 MB memory and a 30-second limit. The page reads at most 20 configured events and does not poll. Successful new commands are limited to 12 per account per minute; native Auth/App Check and request bounds are also required. These controls and the existing $10 warning budget are not a bill ceiling. + +The separate runtime account has only Firestore transaction/get/create/update permissions, at project scope—not per collection. It has no Auth-user administration, deletes, secret access, key, payment or mail permission. Browser access to all new collections remains denied, including for admins. Existing profile Functions, runtime permissions, production and payment integrations are unchanged. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 6c589a32..f95c3f8d 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. +## Staging community lifecycle — #707, source prepared + +```mermaid +flowchart LR + Account["Staging My Account"] --> Page["/community; production disabled"] + Page --> Identity["Native Auth + App Check; own account only"] + Identity --> Service["One communityActions Function; min0/max2"] + Service --> Requests["Pending membership / cancellation request; no paid grant"] + Service --> Seats["Free event + own registration; atomic seat count"] + Service --> Commands["Saved command results; old retry cannot change later state"] + Requests --> Term["Approved synthetic term unchanged; original expiry applies"] + Browser["Browser member/admin"] -. "Denied direct access" .-> Requests +``` + +Text alternative: a staging-only account link opens one authenticated service that stores member requests, transactionally manages free-event seats and saves retry results, while preserving the original term and denying direct browser writes. Hosting/provider proof is not yet recorded; no production, payment, mail or role-grant path is added. + ## Engineering staging billing — checked 2026-10-11 ```mermaid diff --git a/functions/communityActions.appCheck.test.js b/functions/communityActions.appCheck.test.js new file mode 100644 index 00000000..79dbe70f --- /dev/null +++ b/functions/communityActions.appCheck.test.js @@ -0,0 +1,61 @@ +const { EventEmitter } = require('node:events'); +jest.mock('firebase-admin/app', () => ({ getApp: () => ({ name: 'synthetic' }) })); +jest.mock('firebase-admin/auth', () => ({ getAuth: () => ({ verifyIdToken: jest.fn().mockImplementation(async (token) => { + if (token === 'synthetic-invalid-auth') throw new Error('synthetic denial'); + return { uid: 'synthetic-uid', email_verified: token !== 'synthetic-unverified-auth' }; +}) }) })); +jest.mock('firebase-admin/app-check', () => ({ getAppCheck: () => ({ verifyToken: jest.fn().mockImplementation(async (token) => { + if (token !== 'synthetic-valid-app-token') throw new Error('synthetic denial'); + return { appId: 'synthetic-app' }; +}) }) })); +jest.mock('firebase-admin', () => ({ firestore: jest.fn() })); +jest.mock('firebase-functions/logger', () => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() })); +const admin = require('firebase-admin'); +const { communityActions } = require('./communityActions'); +async function request(headers = {}, data = { action: 'status', accountUid: 'synthetic-uid' }) { + const requestHeaders = { 'content-type': 'application/json', ...headers }; + const req = { method: 'POST', body: { data }, headers: requestHeaders, header: (key) => requestHeaders[key.toLowerCase()] }; + const res = new EventEmitter(); const values = new Map(); + res.setHeader = (key, value) => values.set(key.toLowerCase(), value); + res.getHeader = (key) => values.get(key.toLowerCase()); + res.status = (code) => { res.statusCode = code; return res; }; + res.send = (body) => { res.body = body; res.emit('finish'); return res; }; + await communityActions(req, res); return { status: res.statusCode, body: res.body }; +} +test.each([undefined, 'synthetic-invalid-app-token'])('native App Check rejects %s before business work', async (token) => { + const headers = { authorization: 'Bearer synthetic-auth-token' }; + if (token) headers['x-firebase-appcheck'] = token; + expect((await request(headers)).status).toBe(401); + expect(admin.firestore).not.toHaveBeenCalled(); +}); +describe('native authenticated boundary and context binding', () => { + const keys = ['GCLOUD_PROJECT', 'FIRESTORE_EMULATOR_HOST', 'FIREBASE_AUTH_EMULATOR_HOST']; + const saved = {}; + beforeAll(() => { + keys.forEach((key) => { saved[key] = process.env[key]; }); + process.env.GCLOUD_PROJECT = 'demo-community-native-test'; + process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; + process.env.FIREBASE_AUTH_EMULATOR_HOST = '127.0.0.1:9099'; + }); + afterAll(() => keys.forEach((key) => { + if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; + })); + beforeEach(() => jest.clearAllMocks()); + const app = { 'x-firebase-appcheck': 'synthetic-valid-app-token' }; + test.each([undefined, 'synthetic-invalid-auth'])('valid App Check does not replace valid Auth %s', async (token) => { + const headers = { ...app }; + if (token) headers.authorization = `Bearer ${token}`; + expect((await request(headers)).status).toBe(401); + expect(admin.firestore).not.toHaveBeenCalled(); + }); + test('a caller cannot select another account or use a context from before switching accounts', async () => { + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { action: 'status', accountUid: 'other-uid' })).status).toBe(403); + expect(admin.firestore).not.toHaveBeenCalled(); + }); + test('unverified Auth cannot mutate membership', async () => { + expect((await request({ ...app, authorization: 'Bearer synthetic-unverified-auth' }, { + action: 'membership_join', accountUid: 'synthetic-uid', expectedRevision: 0, requestId: '123e4567-e89b-42d3-a456-426614174000', + })).status).toBe(403); + expect(admin.firestore).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js new file mode 100644 index 00000000..8b706a3e --- /dev/null +++ b/functions/communityActions.emulator.test.js @@ -0,0 +1,54 @@ +const admin = require('firebase-admin'); +const { randomUUID } = require('node:crypto'); +const { createCommunityService, assertCommunityEnvironment, parseRequest } = require('./communityActions'); +const { freeEvent, approvedMember } = require('./testSupport/communityFixtures'); + +const describeLocal = process.env.FIRESTORE_EMULATOR_HOST ? describe : describe.skip; +describeLocal('community lifecycle real Firestore transactions (emulator only)', () => { + let app; let db; let service; let now; + const roots = ['communityEvents', 'communityMemberships', 'communityConfig', 'communityCommands', 'communityLimits']; + const command = (action, expectedRevision = 0, extra = {}) => parseRequest({ action, accountUid: 'fixture-member', expectedRevision, requestId: randomUUID(), ...extra }); + const join = (revision = 0) => command('event_join', revision, { eventId: 'fixture-event', waiverAccepted: true, waiverVersion: 'synthetic-only-v1' }); + beforeAll(() => { + assertCommunityEnvironment(); // All local hosts and a demo project required. + app = admin.initializeApp({ projectId: process.env.GCLOUD_PROJECT }, 'community-emulator-test'); + db = app.firestore(); + now = Date.now(); service = createCommunityService(db, () => now); + }); + beforeEach(async () => { + for (const root of roots) await db.recursiveDelete(db.collection(root)); + await db.doc('communityConfig/events').create({ schemaVersion: 1, eventIds: ['fixture-event'] }); + await db.doc('communityEvents/fixture-event').create(freeEvent(now)); + }); + afterAll(async () => { + if (db) for (const root of roots) await db.recursiveDelete(db.collection(root)); + if (app) await app.delete(); + }); + test('twenty concurrent final-seat requests confirm one; parallel cancellation releases once', async () => { + const result = await Promise.allSettled(Array.from({ length: 20 }, (_, n) => service.mutate(`fixture-${n}`, join()))); + const winners = result.flatMap((entry, index) => entry.status === 'fulfilled' ? [index] : []); + expect(winners).toHaveLength(1); + expect((await db.doc('communityEvents/fixture-event').get()).data().confirmedCount).toBe(1); + const uid = `fixture-${winners[0]}`; + const cancel = command('event_cancel', 1, { eventId: 'fixture-event' }); + await Promise.all(Array.from({ length: 5 }, () => service.mutate(uid, cancel))); + expect((await db.doc('communityEvents/fixture-event').get()).data().confirmedCount).toBe(0); + await service.mutate(uid, join(2)); + await service.mutate(uid, cancel); + expect((await db.doc('communityEvents/fixture-event').get()).data().confirmedCount).toBe(1); + }, 45000); + test('membership request retry, withdrawal, paid-term cancellation and exact expiry', async () => { + const uid = 'fixture-member'; const joining = command('membership_join'); + await Promise.all(Array.from({ length: 5 }, () => service.mutate(uid, joining))); + expect((await service.status(uid)).membership).toMatchObject({ revision: 1, entitlement: 'not_entitled' }); + await service.mutate(uid, command('membership_cancel', 1)); + expect((await service.status(uid)).membership.applicationStatus).toBe('withdrawn'); + const paid = approvedMember(uid, now); + await db.doc(`communityMemberships/${uid}`).set(paid); + await service.mutate(uid, command('membership_cancel', 1)); + expect((await db.doc(`communityMemberships/${uid}`).get()).data().authority).toEqual(paid.authority); + expect((await service.status(uid)).membership.entitlement).toBe('current_member'); + now = paid.authority.term.endsAtMs; + expect((await service.status(uid)).membership.entitlement).toBe('not_entitled'); + }); +}); diff --git a/functions/communityActions.js b/functions/communityActions.js new file mode 100644 index 00000000..7706fd4a --- /dev/null +++ b/functions/communityActions.js @@ -0,0 +1,203 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const { createHash } = require('node:crypto'); +const { parseStrictObject } = require('./requestValidation'); +const { deriveMembershipEntitlement } = require('./membershipAuthority'); +const policy = require('./membershipCancellationPolicy.json'); +const limits = require('./profileRuntime'); + +const ID = /^[A-Za-z0-9_-]{1,80}$/; +const COMMAND = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const ACTIONS = new Set(['membership_join', 'membership_cancel', 'event_join', 'event_cancel']); +const error = (code) => new functions.https.HttpsError(code, { + 'invalid-argument': 'Request is invalid.', + 'permission-denied': 'Verify your account before making this request.', + 'failed-precondition': 'This action is unavailable. Refresh your status.', + 'already-exists': 'This request conflicts with an earlier request.', + 'resource-exhausted': 'No place is available, or too many requests were made. Try later.', + unavailable: 'Community status is temporarily unavailable.', + unauthenticated: 'Sign in to continue.', +}[code]); +const check = (value, code = 'unavailable') => { if (!value) throw error(code); }; +const integer = (value, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= 0 && value <= max; + +function assertCommunityEnvironment(env = process.env) { + const project = env.GCLOUD_PROJECT; + const emulator = /^demo-[a-z0-9-]+$/.test(project || '') + && /^127\.0\.0\.1:\d+$/.test(env.FIRESTORE_EMULATOR_HOST || '') + && /^127\.0\.0\.1:\d+$/.test(env.FIREBASE_AUTH_EMULATOR_HOST || ''); + check(project === 'run-mprc-staging' || emulator, 'failed-precondition'); + check(env.COMMERCE_ENABLED === 'false' || emulator, 'failed-precondition'); +} + +function parseRequest(input) { + try { + const bounded = parseStrictObject(input, { + requiredKeys: ['action', 'accountUid'], optionalKeys: ['requestId', 'expectedRevision', 'eventId', 'waiverVersion', 'waiverAccepted'], + }); + const status = bounded.action === 'status'; + const event = bounded.action === 'event_join' || bounded.action === 'event_cancel'; + const value = parseStrictObject(bounded, { + requiredKeys: status ? ['action', 'accountUid'] : ['action', 'accountUid', 'requestId', 'expectedRevision', ...(event ? ['eventId'] : [])], + optionalKeys: event && bounded.action === 'event_join' ? ['waiverVersion', 'waiverAccepted'] : [], + }); + check(typeof value.accountUid === 'string' && ID.test(value.accountUid), 'invalid-argument'); + if (status) return value; + check(ACTIONS.has(value.action) && COMMAND.test(value.requestId) + && integer(value.expectedRevision, 1_000_000), 'invalid-argument'); + if (event) check(typeof value.eventId === 'string' && ID.test(value.eventId), 'invalid-argument'); + if (value.action === 'event_join') check(typeof value.waiverVersion === 'string' + && value.waiverVersion.length <= 80 && value.waiverAccepted === true, 'invalid-argument'); + return value; + } catch { throw error('invalid-argument'); } +} + +function memberView(record, uid, now) { + check(record?.schemaVersion === 1 && integer(record.revision, 1_000_000) + && ['none', 'pending', 'approved', 'withdrawn', 'cancellation_requested'].includes(record.applicationStatus)); + let entitlement = 'not_entitled'; + let accessThroughMs = null; + if (record.authority !== null) { + try { + check(record.authority.association.uid === uid); + entitlement = deriveMembershipEntitlement({ membershipAuthoritySchemaVersion: 1, record: record.authority, uid, asOfMs: now }).entitlement; + if (entitlement === 'current_member') accessThroughMs = record.authority.term.endsAtMs; + } catch { throw error('unavailable'); } + } + return { revision: record.revision, applicationStatus: record.applicationStatus, entitlement, accessThroughMs }; +} + +function eventView(value, id) { + const keys = ['schemaVersion', 'title', 'location', 'startAtMs', 'opensAtMs', 'closesAtMs', 'status', 'amountCents', 'capacity', 'confirmedCount', 'waiverVersion', 'waiverText', 'waiverHash']; + check(value && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key))); + check(value.schemaVersion === 1 && value.amountCents === 0 + && ['open', 'closed', 'cancelled'].includes(value.status) + && integer(value.capacity, 10000) && value.capacity > 0 + && integer(value.confirmedCount, value.capacity) + && [value.startAtMs, value.opensAtMs, value.closesAtMs].every((time) => integer(time, 8_640_000_000_000_000)) + && value.opensAtMs < value.closesAtMs && value.closesAtMs <= value.startAtMs); + check(['title', 'location', 'waiverVersion', 'waiverText'].every((key) => typeof value[key] === 'string' && value[key].length > 0 && value[key].length <= (key === 'waiverText' ? 2000 : 200))); + check(value.waiverHash === createHash('sha256').update(value.waiverText).digest('hex')); + return { id, ...value }; +} + +function registrationView(value) { + if (!value) return { revision: 0, status: 'none' }; + check(value.schemaVersion === 1 && integer(value.revision, 1_000_000) + && ['confirmed', 'cancelled'].includes(value.status) + && value.paymentStatus === 'not_required'); + return { revision: value.revision, status: value.status }; +} + +function createCommunityService(db, clock = Date.now) { + const memberRef = (uid) => db.doc(`communityMemberships/${uid}`); + const emptyMember = { schemaVersion: 1, revision: 0, applicationStatus: 'none', authority: null }; + async function status(uid) { + const now = clock(); + const [member, manifest] = await db.getAll(memberRef(uid), db.doc('communityConfig/events')); + const ids = manifest.exists ? manifest.data().eventIds : []; + check(Array.isArray(ids) && ids.length <= 20 && new Set(ids).size === ids.length && ids.every((id) => typeof id === 'string' && ID.test(id))); + const refs = ids.flatMap((id) => [db.doc(`communityEvents/${id}`), db.doc(`communityEvents/${id}/registrations/${uid}`)]); + const docs = refs.length ? await db.getAll(...refs) : []; + const events = ids.map((id, index) => ({ ...eventView(docs[index * 2].data(), id), registration: registrationView(docs[index * 2 + 1].data()) })); + return { membership: memberView(member.exists ? member.data() : emptyMember, uid, now), events }; + } + + async function mutate(uid, request) { + const fingerprint = createHash('sha256').update(JSON.stringify(Object.fromEntries(Object.keys(request).sort().map((key) => [key, request[key]])))).digest('hex'); + const commandRef = db.doc(`communityCommands/${uid}/requests/${request.requestId}`); + const rateRef = db.doc(`communityLimits/${uid}`); + return db.runTransaction(async (tx) => { + const now = clock(); + const command = await tx.get(commandRef); + if (command.exists) { + check(command.data().fingerprint === fingerprint, 'already-exists'); + return command.data().result; + } + const rate = await tx.get(rateRef); + const window = Math.floor(now / 60000); + const previous = rate.exists ? rate.data() : { window, count: 0 }; + check(integer(previous.window) && integer(previous.count, 12)); + const count = previous.window === window ? previous.count : 0; + check(count < 12, 'resource-exhausted'); + let result; + const writes = []; + if (request.action.startsWith('membership_')) { + const ref = memberRef(uid); + const snapshot = await tx.get(ref); + const record = snapshot.exists ? snapshot.data() : emptyMember; + const view = memberView(record, uid, now); + check(view.revision === request.expectedRevision, 'failed-precondition'); + let applicationStatus; + if (request.action === 'membership_join') { + check(view.entitlement !== 'current_member', 'failed-precondition'); + applicationStatus = 'pending'; + } else { + check(view.applicationStatus !== 'none', 'failed-precondition'); + applicationStatus = view.entitlement === 'current_member' ? 'cancellation_requested' : 'withdrawn'; + } + const changed = applicationStatus !== record.applicationStatus; + check(!changed || record.revision < 1_000_000); + const next = { ...record, applicationStatus, revision: record.revision + (changed ? 1 : 0), policyVersion: policy.policyVersion, updatedAtMs: now }; + if (changed) writes.push([ref, next, snapshot.exists]); + result = { membership: { revision: next.revision, applicationStatus: next.applicationStatus } }; + } else { + const ref = db.doc(`communityEvents/${request.eventId}`); + const regRef = ref.collection('registrations').doc(uid); + const [eventSnap, regSnap] = await tx.getAll(ref, regRef); + const event = eventView(eventSnap.data(), request.eventId); + const record = regSnap.exists ? regSnap.data() : null; + const registration = registrationView(record); + check(registration.revision === request.expectedRevision, 'failed-precondition'); + const joining = request.action === 'event_join'; + const target = joining ? 'confirmed' : 'cancelled'; + check(joining || registration.status !== 'none', 'failed-precondition'); + if (registration.status !== target) { + check(registration.revision < 1_000_000); + if (joining) { + check(event.status === 'open' && now >= event.opensAtMs && now < event.closesAtMs + && request.waiverVersion === event.waiverVersion, 'failed-precondition'); + check(event.confirmedCount < event.capacity, 'resource-exhausted'); + } else check(event.confirmedCount > 0); + const next = { + schemaVersion: 1, revision: registration.revision + 1, status: target, + paymentStatus: 'not_required', updatedAtMs: now, + ...(joining ? { waiverVersion: event.waiverVersion, waiverHash: event.waiverHash, waiverAcceptedAtMs: now } : { + waiverVersion: record.waiverVersion, waiverHash: record.waiverHash, waiverAcceptedAtMs: record.waiverAcceptedAtMs, + }), + }; + writes.push([ref, { ...eventSnap.data(), confirmedCount: event.confirmedCount + (joining ? 1 : -1) }, true], [regRef, next, regSnap.exists]); + result = { registration: registrationView(next) }; + } else result = { registration }; + } + for (const [ref, value, exists] of writes) { + if (exists) tx.set(ref, value); else tx.create(ref, value); + } + tx.set(rateRef, { window, count: count + 1 }); + tx.create(commandRef, { schemaVersion: 1, fingerprint, action: request.action, result, createdAtMs: now }); + return result; + }); + } + return { status, mutate }; +} + +exports.communityActions = functions.runWith({ ...limits, enforceAppCheck: true }).https.onCall(async (data, context) => { + assertCommunityEnvironment(); + check(context.app, 'permission-denied'); + check(context.auth?.uid && typeof context.auth.uid === 'string' && ID.test(context.auth.uid), 'unauthenticated'); + const request = parseRequest(data); + check(request.accountUid === context.auth.uid, 'permission-denied'); + if (request.action !== 'status') check(context.auth.token.email_verified === true, 'permission-denied'); + try { + const service = createCommunityService(admin.firestore()); + return await (request.action === 'status' ? service.status(context.auth.uid) : service.mutate(context.auth.uid, request)); + } catch (failure) { + if (failure instanceof functions.https.HttpsError) throw failure; + throw error('unavailable'); // No raw errors, IDs, request bodies or record logging. + } +}); +exports.createCommunityService = createCommunityService; +exports.assertCommunityEnvironment = assertCommunityEnvironment; +exports.parseRequest = parseRequest; diff --git a/functions/communityActions.test.js b/functions/communityActions.test.js new file mode 100644 index 00000000..9b350ea9 --- /dev/null +++ b/functions/communityActions.test.js @@ -0,0 +1,140 @@ +const { randomUUID } = require('node:crypto'); +const { createCommunityService, parseRequest, assertCommunityEnvironment, communityActions } = require('./communityActions'); +const { freeEvent, approvedMember } = require('./testSupport/communityFixtures'); + +function memoryDb() { + const docs = new Map(); + let tail = Promise.resolve(); + const doc = (path) => ({ path, collection: (name) => ({ doc: (id) => doc(`${path}/${name}/${id}`) }) }); + const get = async (ref) => ({ exists: docs.has(ref.path), data: () => docs.get(ref.path) }); + const db = { + doc, getAll: (...refs) => Promise.all(refs.map(get)), + runTransaction: (fn) => { + const result = tail.then(async () => { + const writes = []; + const output = await fn({ get, getAll: (...refs) => Promise.all(refs.map(get)), + set: (ref, value) => writes.push([ref.path, value]), + create: (ref, value) => { if (docs.has(ref.path)) throw new Error('exists'); writes.push([ref.path, value]); }, + }); + writes.forEach(([path, value]) => docs.set(path, JSON.parse(JSON.stringify(value)))); + return output; + }); + tail = result.catch(() => undefined); + return result; + }, + }; + return { db, docs }; +} +const request = (action, expectedRevision = 0, extra = {}) => parseRequest({ action, accountUid: 'runner-a', requestId: randomUUID(), expectedRevision, ...extra }); +const joining = (revision = 0) => request('event_join', revision, { eventId: 'one-seat', waiverVersion: 'synthetic-only-v1', waiverAccepted: true }); + +describe('staging-only community boundary', () => { + test('native App Check and bounded cold-start settings are mandatory', () => { + expect(communityActions.__endpoint).toMatchObject({ minInstances: 0, maxInstances: 2, availableMemoryMb: 256, timeoutSeconds: 30 }); + expect(communityActions.__endpoint.callableTrigger).toMatchObject({}); + }); + test.each(['mid-peninsula-running-club', '', 'demo-test'])('rejects non-staging or incomplete emulator project %s', (project) => { + expect(() => assertCommunityEnvironment({ GCLOUD_PROJECT: project, COMMERCE_ENABLED: 'false' })).toThrow(); + }); + test('accepts only staging with commerce disabled, or all local emulator hosts', () => { + expect(() => assertCommunityEnvironment({ GCLOUD_PROJECT: 'run-mprc-staging', COMMERCE_ENABLED: 'false' })).not.toThrow(); + expect(() => assertCommunityEnvironment({ GCLOUD_PROJECT: 'run-mprc-staging', COMMERCE_ENABLED: 'true' })).toThrow(); + expect(() => assertCommunityEnvironment({ GCLOUD_PROJECT: 'demo-test', FIRESTORE_EMULATOR_HOST: '127.0.0.1:8080', FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' })).not.toThrow(); + }); + test.each([null, [], { action: 'status', uid: 'other' }, { action: 'membership_join' }, { action: 'event_join', requestId: randomUUID(), expectedRevision: 0, eventId: 'one-seat', waiverAccepted: false }])('rejects malformed or forged input %#', (input) => { + expect(() => parseRequest(input)).toThrow('Request is invalid.'); + }); + test('rejects getters without executing them', () => { + const getter = jest.fn(); + expect(() => parseRequest(Object.defineProperty({}, 'action', { get: getter, enumerable: true }))).toThrow(); + expect(getter).not.toHaveBeenCalled(); + }); + test.each([null, 123, true])('rejects coerced non-string event identifiers %#', (eventId) => { + expect(() => parseRequest({ ...joining(), eventId })).toThrow('Request is invalid.'); + }); +}); + +describe('transactional membership and free-event lifecycle', () => { + let docs; let service; let now; + beforeEach(() => { + const memory = memoryDb(); docs = memory.docs; now = 1_800_000_000_000; + service = createCommunityService(memory.db, () => now); + docs.set('communityConfig/events', { schemaVersion: 1, eventIds: ['one-seat'] }); + docs.set('communityEvents/one-seat', freeEvent(now)); + }); + test('membership signup is pending, retry-safe, and grants no entitlement', async () => { + const command = request('membership_join'); + await expect(service.mutate('runner-a', command)).resolves.toEqual({ membership: { revision: 1, applicationStatus: 'pending' } }); + await expect(service.mutate('runner-a', command)).resolves.toEqual({ membership: { revision: 1, applicationStatus: 'pending' } }); + expect((await service.status('runner-a')).membership.entitlement).toBe('not_entitled'); + expect(docs.get('communityMemberships/runner-a').authority).toBeNull(); + }); + test('withdrawal and rejoining fence stale cancellation and old replay', async () => { + await service.mutate('runner-a', request('membership_join')); + const cancel = request('membership_cancel', 1); + await service.mutate('runner-a', cancel); + await service.mutate('runner-a', request('membership_join', 2)); + await service.mutate('runner-a', cancel); // Original result, no new mutation. + expect(docs.get('communityMemberships/runner-a')).toMatchObject({ revision: 3, applicationStatus: 'pending' }); + await expect(service.mutate('runner-a', request('membership_cancel', 1))).rejects.toHaveProperty('code', 'failed-precondition'); + }); + test('paid-term cancellation preserves evidence and access until the exact end', async () => { + const member = approvedMember('runner-a', now); + docs.set('communityMemberships/runner-a', member); + await service.mutate('runner-a', request('membership_cancel', 1)); + expect(docs.get('communityMemberships/runner-a').authority).toEqual(member.authority); + expect((await service.status('runner-a')).membership).toMatchObject({ applicationStatus: 'cancellation_requested', entitlement: 'current_member', accessThroughMs: now + 60000 }); + now += 60000; + expect((await service.status('runner-a')).membership.entitlement).toBe('not_entitled'); + }); + test('changed payload under a request ID conflicts; field order does not', async () => { + const command = request('membership_join'); + await service.mutate('runner-a', command); + await expect(service.mutate('runner-a', { expectedRevision: 0, accountUid: command.accountUid, requestId: command.requestId, action: command.action })).resolves.toHaveProperty('membership.revision', 1); + await expect(service.mutate('runner-a', { ...command, action: 'membership_cancel' })).rejects.toHaveProperty('code', 'already-exists'); + }); + test('20 concurrent final-seat attempts confirm exactly one', async () => { + const results = await Promise.allSettled(Array.from({ length: 20 }, (_, n) => service.mutate(`runner-${n}`, joining()))); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(docs.get('communityEvents/one-seat').confirmedCount).toBe(1); + }); + test('cancel releases once, even when closed, and cannot undo a later rejoin', async () => { + await service.mutate('runner-a', joining()); + const cancel = request('event_cancel', 1, { eventId: 'one-seat' }); + docs.get('communityEvents/one-seat').status = 'closed'; + await service.mutate('runner-a', cancel); + await service.mutate('runner-a', cancel); + expect(docs.get('communityEvents/one-seat').confirmedCount).toBe(0); + docs.get('communityEvents/one-seat').status = 'open'; + await service.mutate('runner-a', joining(2)); + await service.mutate('runner-a', cancel); + expect(docs.get('communityEvents/one-seat').confirmedCount).toBe(1); + expect(docs.get('communityEvents/one-seat/registrations/runner-a').status).toBe('confirmed'); + await expect(service.mutate('runner-a', request('event_cancel', 1, { eventId: 'one-seat' }))).rejects.toHaveProperty('code', 'failed-precondition'); + }); + test.each([{ amountCents: 1 }, { status: 'closed' }, { closesAtMs: 1_800_000_000_000 }, { waiverHash: 'forged' }, { confirmedCount: -1 }])('denies paid/closed/expired/corrupt events without writes %#', async (extra) => { + docs.set('communityEvents/one-seat', freeEvent(now, extra)); + await expect(service.mutate('runner-a', joining())).rejects.toBeDefined(); + expect(docs.has('communityEvents/one-seat/registrations/runner-a')).toBe(false); + expect(docs.has('communityLimits/runner-a')).toBe(false); + }); + test('requires exact server waiver version and never creates paid state', async () => { + await expect(service.mutate('runner-a', { ...joining(), waiverVersion: 'old-version' })).rejects.toHaveProperty('code', 'failed-precondition'); + await service.mutate('runner-a', joining()); + expect(docs.get('communityEvents/one-seat/registrations/runner-a')).toMatchObject({ paymentStatus: 'not_required', waiverVersion: 'synthetic-only-v1' }); + }); + test('status includes only the caller registration, not another runner', async () => { + await service.mutate('runner-a', joining()); + expect((await service.status('runner-b')).events[0].registration.status).toBe('none'); + }); + test('write failure rolls back all effects and a retry remains safe', async () => { + const memory = memoryDb(); const command = request('membership_join'); + const original = memory.db.runTransaction; + memory.db.runTransaction = () => Promise.reject(new Error('synthetic outage')); + const failing = createCommunityService(memory.db, () => now); + await expect(failing.mutate('runner-a', command)).rejects.toThrow(); + expect(memory.docs.size).toBe(0); + memory.db.runTransaction = original; + await expect(failing.mutate('runner-a', command)).resolves.toHaveProperty('membership.revision', 1); + }); +}); diff --git a/functions/communityOnly.js b/functions/communityOnly.js new file mode 100644 index 00000000..dba9f504 --- /dev/null +++ b/functions/communityOnly.js @@ -0,0 +1,6 @@ +// Isolated staging entrypoint: never load the prototype commerce index. +const admin = require('firebase-admin'); +const { assertCommunityEnvironment, communityActions } = require('./communityActions'); +assertCommunityEnvironment(); +admin.initializeApp(); +exports.communityActions = communityActions; diff --git a/functions/testSupport/communityFixtures.js b/functions/testSupport/communityFixtures.js new file mode 100644 index 00000000..5bf596ed --- /dev/null +++ b/functions/testSupport/communityFixtures.js @@ -0,0 +1,30 @@ +const { createHash } = require('node:crypto'); +const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('../membershipAuthority'); + +function freeEvent(now, overrides = {}) { + const waiverText = 'SYNTHETIC STAGING TEST ONLY. Not an approved club waiver or real event.'; + return { + schemaVersion: 1, title: 'Disposable staging run', location: 'Synthetic test location', + startAtMs: now + 3600000, opensAtMs: now - 1000, closesAtMs: now + 1800000, + status: 'open', amountCents: 0, capacity: 1, confirmedCount: 0, + waiverVersion: 'synthetic-only-v1', waiverText, + waiverHash: createHash('sha256').update(waiverText).digest('hex'), ...overrides, + }; +} + +function approvedMember(uid, now) { + let authority = createMembershipAuthority({ membershipAuthoritySchemaVersion: 1, membershipId: 'mbr_synthetic_only', commandId: 'cmd_synthetic_create' }); + authority = applyMembershipAuthorityCommand(authority, { + membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', + commandId: 'cmd_synthetic_link', expectedRevision: 1, uid, + }); + authority = applyMembershipAuthorityCommand(authority, { + membershipAuthoritySchemaVersion: 1, commandType: 'record_term_decision', + commandId: 'cmd_synthetic_term', expectedRevision: 2, termRevision: 1, + termState: 'approved', termId: 'term_synthetic_only', startsAtMs: now - 1000, + endsAtMs: now + 60000, planRef: 'plan_synthetic_only', evidenceRef: 'evidence_synthetic_only', + policyVersion: 'synthetic-only-not-payment-evidence', + }); + return { schemaVersion: 1, revision: 1, applicationStatus: 'approved', authority }; +} +module.exports = { freeEvent, approvedMember }; diff --git a/scripts/staging-community-hosting.js b/scripts/staging-community-hosting.js new file mode 100644 index 00000000..e533d802 --- /dev/null +++ b/scripts/staging-community-hosting.js @@ -0,0 +1,57 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { verifyCurrentSource } = require('./staging-profile-authority'); +const { functionSpec, validateHostedFunction, jsonRequest } = require('./staging-community-release'); +const { verifyExecutableArtifact } = require('./firebase-hosting-contract'); +const PROJECT = 'run-mprc-staging'; +const ACCOUNT = 'runmprc@gmail.com'; +const check = (value) => { if (!value) throw new Error('staging_community_hosting_failed'); }; +async function publish() { + const root = path.resolve(__dirname, '..'); + check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + const source = { GITHUB_SHA: sha, GH_TOKEN: execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() }; + await verifyCurrentSource(source); + const token = execFileSync('/opt/homebrew/bin/gcloud', ['auth', 'print-access-token', `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim(); + const native = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/us-central1/functions/communityActions`, token); + const digest = `${native.labels?.['bundle-a']}${native.labels?.['bundle-b']}`; + validateHostedFunction(native, functionSpec(sha, digest, 'https://storage.googleapis.com/synthetic')); + const configResponse = await fetch(`https://${PROJECT}.web.app/__/firebase/init.json`, { redirect: 'error', signal: AbortSignal.timeout(15000) }); + check(configResponse.ok); const config = await configResponse.json(); + check(config.projectId === PROJECT && typeof config.appId === 'string'); + const projectNumber = config.appId.match(/^1:([0-9]+):web:/)?.[1]; + check(projectNumber && projectNumber === config.messagingSenderId); + const appCheckResponse = await fetch(`https://firebaseappcheck.googleapis.com/v1/projects/${projectNumber}/apps/${encodeURIComponent(config.appId)}/recaptchaEnterpriseConfig`, { + headers: { Authorization: `Bearer ${token}`, 'x-goog-user-project': PROJECT }, redirect: 'error', signal: AbortSignal.timeout(15000), + }); + check(appCheckResponse.ok); const appCheck = await appCheckResponse.json(); + check(typeof appCheck.siteKey === 'string' && /^[A-Za-z0-9_-]{30,128}$/.test(appCheck.siteKey)); + const env = { + ...process.env, CI: 'true', DISABLE_ESLINT_PLUGIN: 'true', + REACT_APP_FIREBASE_ENVIRONMENT: 'staging', REACT_APP_COMMUNITY_STAGING: 'true', + REACT_APP_FIREBASE_API_KEY: config.apiKey, REACT_APP_FIREBASE_AUTH_DOMAIN: config.authDomain, + REACT_APP_FIREBASE_PROJECT_ID: PROJECT, REACT_APP_FIREBASE_STORAGE_BUCKET: config.storageBucket, + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: config.messagingSenderId, REACT_APP_FIREBASE_APP_ID: config.appId, + REACT_APP_RECAPTCHA_SITE_KEY: appCheck.siteKey, + }; + delete env.REACT_APP_FIREBASE_MEASUREMENT_ID; delete env.REACT_APP_SENTRY_DSN; + execFileSync(path.join(root, 'node_modules/.bin/react-scripts'), ['build'], { cwd: root, env, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 }); + verifyExecutableArtifact(path.join(root, 'build'), env); + fs.writeFileSync(path.join(root, 'build/community-release.json'), JSON.stringify({ schemaVersion: 1, project: PROJECT, sourceSha: sha, functionBundle: digest }) + '\n'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-community-hosting-owned-')); + try { + const filename = path.join(directory, 'firebase.json'); + fs.writeFileSync(filename, JSON.stringify({ hosting: { site: PROJECT, public: path.join(root, 'build'), ignore: ['firebase.json', '**/.*', '**/node_modules/**'], rewrites: [{ source: '**', destination: '/index.html' }] } })); + await verifyCurrentSource(source); + check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); + execFileSync(path.join(root, 'node_modules/.bin/firebase'), ['deploy', '--only', 'hosting', '--config', filename, '--project', PROJECT, '--account', ACCOUNT, '--non-interactive'], { cwd: root, env, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 }); + const response = await fetch(`https://${PROJECT}.web.app/community-release.json`, { cache: 'no-store', redirect: 'error' }); + check(response.ok); const marker = await response.json(); + check(marker.sourceSha === sha && marker.project === PROJECT && marker.functionBundle === digest); + process.stdout.write(`staging_community_hosting_published_verified ${sha}\n`); + } finally { fs.rmSync(directory, { recursive: true }); } +} +if (require.main === module) publish().catch(() => { process.stderr.write('staging_community_hosting_failed\n'); process.exitCode = 1; }); diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js new file mode 100644 index 00000000..2cc65169 --- /dev/null +++ b/scripts/staging-community-release.js @@ -0,0 +1,119 @@ +'use strict'; + +// Owner-approved, fixed one-Function staging deployment. No credential file, +// production target, Firebase prototype index, or provider/payment secret. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const { jsonRequest: providerRequest, waitForOperation } = require('./staging-profile-release'); +const { verifyCurrentSource } = require('./staging-profile-authority'); +const PROJECT = 'run-mprc-staging'; +const TARGET = 'communityActions'; +const REGION = 'us-central1'; +const BASE = `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/${REGION}`; +const ACCOUNT = 'runmprc@gmail.com'; +const RUNTIME = `mprc-community-runtime@${PROJECT}.iam.gserviceaccount.com`; +const FILES = ['communityOnly.js', 'communityActions.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; +const check = (value) => { if (!value) throw new Error('staging_community_release_failed'); }; + +// Owner tokens do not inherit the CLI's --project as their API quota project. +// Keep every authenticated provider request billed/routed to staging explicitly. +function jsonRequest(url, token, method = 'GET', body, fetchImpl = fetch) { + return providerRequest(url, token, method, body, (resource, options) => fetchImpl(resource, { + ...options, headers: { ...options.headers, 'x-goog-user-project': PROJECT }, + })); +} + +function createBundle(root) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-community-owned-')); + try { + for (const file of FILES) { + const source = path.join(root, 'functions', file); + check(fs.lstatSync(source).isFile() && !fs.lstatSync(source).isSymbolicLink()); + let bytes = fs.readFileSync(source); + check(bytes.length < 1_000_000); + if (file === 'package.json') { + const manifest = JSON.parse(bytes); check(manifest.main === 'index.js' && manifest.engines.node === '22'); + manifest.main = 'communityOnly.js'; bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + } + fs.writeFileSync(path.join(directory, file), bytes, { flag: 'wx', mode: 0o600 }); + fs.utimesSync(path.join(directory, file), new Date('1980-01-01T00:00:00Z'), new Date('1980-01-01T00:00:00Z')); + } + const zip = execFileSync('zip', ['-X', '-q', '-', ...FILES], { cwd: directory, maxBuffer: 2_000_000 }); + check(zip.length < 1_000_000); + return { zip, digest: createHash('sha256').update(zip).digest('hex') }; + } finally { fs.rmSync(directory, { recursive: true }); } // This exact generated folder only. +} + +function functionSpec(sha, digest, uploadUrl) { + check(/^[a-f0-9]{40}$/.test(sha) && /^[a-f0-9]{64}$/.test(digest)); + const url = new URL(uploadUrl); + check(url.protocol === 'https:' && url.hostname === 'storage.googleapis.com' && !url.username && !url.password && !url.port); + return { + name: `projects/${PROJECT}/locations/${REGION}/functions/${TARGET}`, entryPoint: TARGET, + runtime: 'nodejs22', serviceAccountEmail: RUNTIME, + buildServiceAccount: `projects/${PROJECT}/serviceAccounts/mprc-profile-build@${PROJECT}.iam.gserviceaccount.com`, + dockerRegistry: 'ARTIFACT_REGISTRY', dockerRepository: `projects/${PROJECT}/locations/${REGION}/repositories/mprc-profile-artifacts`, + sourceUploadUrl: uploadUrl, availableMemoryMb: 256, timeout: '30s', minInstances: 0, maxInstances: 2, + ingressSettings: 'ALLOW_ALL', httpsTrigger: { securityLevel: 'SECURE_ALWAYS' }, + labels: { 'managed-by': 'mprc-community-stage', 'source-sha': sha, 'bundle-a': digest.slice(0, 32), 'bundle-b': digest.slice(32) }, + environmentVariables: { FIREBASE_CONFIG: JSON.stringify({ projectId: PROJECT }), GCLOUD_PROJECT: PROJECT, COMMERCE_ENABLED: 'false' }, + }; +} +function validateHostedFunction(actual, expected) { + for (const key of ['name', 'entryPoint', 'runtime', 'serviceAccountEmail', 'buildServiceAccount', 'dockerRegistry', 'dockerRepository', 'availableMemoryMb', 'timeout', 'maxInstances', 'ingressSettings']) check(actual[key] === expected[key]); + check(actual.status === 'ACTIVE' && (actual.minInstances ?? 0) === 0 && !actual.eventTrigger + && actual.httpsTrigger?.securityLevel === 'SECURE_ALWAYS' && !actual.secretEnvironmentVariables?.length && !actual.secretVolumes?.length && !actual.vpcConnector); + for (const key of ['labels', 'environmentVariables']) check(Object.keys(actual[key] || {}).length === Object.keys(expected[key]).length && Object.entries(expected[key]).every(([name, value]) => actual[key][name] === value)); +} +async function release(root) { + const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); + const source = { GITHUB_SHA: sha, GH_TOKEN: gh('auth', 'token') }; + await verifyCurrentSource(source); + const gcloud = (...args) => execFileSync('/opt/homebrew/bin/gcloud', [...args, `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim(); + const role = JSON.parse(gcloud('iam', 'roles', 'describe', 'mprcCommunityRuntime', '--format=json')); + const permissions = ['datastore.databases.get', 'datastore.entities.get', 'datastore.entities.create', 'datastore.entities.update']; + check(!role.deleted && role.includedPermissions?.length === permissions.length && permissions.every((permission) => role.includedPermissions.includes(permission))); + const iam = JSON.parse(gcloud('projects', 'get-iam-policy', PROJECT, '--format=json')); + const runtimeRoles = iam.bindings.filter((binding) => binding.members?.includes(`serviceAccount:${RUNTIME}`)); + check(runtimeRoles.length === 1 && runtimeRoles[0].role === `projects/${PROJECT}/roles/mprcCommunityRuntime` && !runtimeRoles[0].condition); + const keys = JSON.parse(gcloud('iam', 'service-accounts', 'keys', 'list', `--iam-account=${RUNTIME}`, '--managed-by=user', '--format=json')); + check(keys.length === 0); + const token = gcloud('auth', 'print-access-token'); // Memory only, never logged. + const inventory = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); + check(!inventory.nextPageToken && !inventory.unreachable?.length && inventory.functions?.length >= 2 && inventory.functions.length <= 3); + check(inventory.functions.every((fn) => ['createMemberOnSignUp', 'ensureMemberProfile', TARGET].includes(fn.entryPoint) && fn.name.startsWith(`${BASE.replace('https://cloudfunctions.googleapis.com/v1/', '')}/functions/`))); + const bundle = createBundle(root); + await verifyCurrentSource(source); + const uploaded = await jsonRequest(`${BASE}/functions:generateUploadUrl`, token, 'POST', {}); + const expected = functionSpec(sha, bundle.digest, uploaded.uploadUrl); + const response = await fetch(uploaded.uploadUrl, { method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'application/zip', 'x-goog-content-length-range': '0,104857600' }, body: bundle.zip }); + check(response.ok); + await verifyCurrentSource(source); + const exists = inventory.functions.some((fn) => fn.name === expected.name); + const operation = await jsonRequest(exists ? `${BASE}/functions/${TARGET}?updateMask=${Object.keys(expected).filter((key) => key !== 'name').join(',')}` : `${BASE}/functions`, token, exists ? 'PATCH' : 'POST', expected); + await waitForOperation(operation, token, jsonRequest); + validateHostedFunction(await jsonRequest(`${BASE}/functions/${TARGET}`, token), expected); + const prior = await jsonRequest(`${BASE}/functions/${TARGET}:getIamPolicy`, token); + check((prior.bindings || []).every((binding) => binding.role === 'roles/cloudfunctions.invoker' && binding.members?.length === 1 && binding.members[0] === 'allUsers')); + await jsonRequest(`${BASE}/functions/${TARGET}:setIamPolicy`, token, 'POST', { policy: { version: 1, ...(prior.etag ? { etag: prior.etag } : {}), bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }] } }); + // Native source readback, not labels alone, must match the submitted bytes. + const download = await jsonRequest(`${BASE}/functions/${TARGET}:generateDownloadUrl`, token, 'POST', {}); + const archiveUrl = new URL(download.downloadUrl); + check(archiveUrl.protocol === 'https:' && archiveUrl.hostname === 'storage.googleapis.com'); + const archive = await fetch(download.downloadUrl, { redirect: 'error', signal: AbortSignal.timeout(30000) }); + check(archive.ok && createHash('sha256').update(Buffer.from(await archive.arrayBuffer())).digest('hex') === bundle.digest); + const final = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); + check(final.functions.length === 3 && !final.nextPageToken && !final.unreachable?.length); + for (const original of inventory.functions.filter((fn) => fn.entryPoint !== TARGET)) { + const unchanged = final.functions.find((fn) => fn.name === original.name); + check(unchanged?.versionId === original.versionId && JSON.stringify(unchanged.labels) === JSON.stringify(original.labels)); + } + process.stdout.write(JSON.stringify({ status: 'staging_community_deployed_verified', sha, digest, bytes: bundle.zip.length }) + '\n'); +} +if (require.main === module) release(path.resolve(__dirname, '..')).catch(() => { process.stderr.write('staging_community_release_failed\n'); process.exitCode = 1; }); +module.exports = { FILES, createBundle, functionSpec, validateHostedFunction, jsonRequest }; diff --git a/scripts/staging-profile-release.js b/scripts/staging-profile-release.js index 3344a913..414d0d39 100644 --- a/scripts/staging-profile-release.js +++ b/scripts/staging-profile-release.js @@ -23,6 +23,14 @@ const repository = `projects/${PROJECT}/locations/${REGION}/repositories/mprc-pr const sha256 = (value) => createHash('sha256').update(value).digest('hex'); const check = (value) => { if (!value) throw new Error('staging_profile_release_failed'); }; +function validateKnownCommunity(actual) { + // #707 adds one separately released staging-only service. This profile path + // may inspect it, never update it or load it into the profile bundle. + const { functionSpec: communitySpec, validateHostedFunction: validateCommunity } = require('./staging-community-release'); + validateCommunity(actual, communitySpec(actual.labels?.['source-sha'], + `${actual.labels?.['bundle-a']}${actual.labels?.['bundle-b']}`, 'https://storage.googleapis.com/synthetic')); +} + function validateReleaseContext(env, fingerprint) { check(env.GITHUB_WORKFLOW_REF === `Run-MPRC/Run-MPRC.github.io/.github/workflows/${WORKFLOW}@refs/heads/main`); // Reuse the immutable repo/main/environment/authority and exact-source checks; @@ -155,8 +163,11 @@ async function release(env) { const inventory = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, env.GCP_ACCESS_TOKEN); check(!inventory.nextPageToken && !inventory.unreachable?.length); const existing = inventory.functions || []; - check(Array.isArray(existing) && existing.length <= 2); - for (const fn of existing) { + check(Array.isArray(existing) && existing.length <= 3); + const community = existing.filter((fn) => fn.entryPoint === 'communityActions'); + check(community.length <= 1); + community.forEach(validateKnownCommunity); + for (const fn of existing.filter((item) => item.entryPoint !== 'communityActions')) { const target = fn.name?.split('/').at(-1); validateHostedFunction(fn, functionSpec(target, env.GITHUB_SHA, bundle.digest, 'https://storage.googleapis.com/synthetic'), { ready: false, previousInitialRelease: true }); } @@ -181,7 +192,12 @@ async function release(env) { process.stdout.write(`staging_profile_${target}_deployed_verified\n`); } const final = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, env.GCP_ACCESS_TOKEN); - check(final.functions?.length === 2 && !final.nextPageToken && !final.unreachable?.length); + check(final.functions?.length === 2 + community.length && !final.nextPageToken && !final.unreachable?.length); + if (community.length) { + const unchanged = final.functions.find((fn) => fn.name === community[0].name); + validateKnownCommunity(unchanged); + check(unchanged.versionId === community[0].versionId); + } for (const target of TARGETS) validateHostedFunction(final.functions.find((fn) => fn.name.endsWith(`/${target}`)), functionSpec(target, env.GITHUB_SHA, bundle.digest, 'https://storage.googleapis.com/synthetic')); process.stdout.write(`staging_profile_bundle_sha256=${bundle.digest}\n`); } @@ -204,4 +220,4 @@ async function main() { } if (require.main === module) void main(); -module.exports = { TARGETS, FILES, validateReleaseContext, bundleFiles, createBundle, functionSpec, validateHostedFunction, jsonRequest, validateActAs, waitForOperation }; +module.exports = { TARGETS, FILES, validateReleaseContext, bundleFiles, createBundle, functionSpec, validateHostedFunction, validateKnownCommunity, jsonRequest, validateActAs, waitForOperation }; diff --git a/src/App.jsx b/src/App.jsx index 65f18a66..39d38c71 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -29,6 +29,8 @@ import AnnouncementBanner from './components/AnnouncementBanner'; import JoinUsConditionalRoute from './pages/joinUs/JoinUsConditionalRoute'; import Terms from './pages/legal/Terms'; import Privacy from './pages/legal/Privacy'; +import Community from './pages/community/Community'; +import { COMMUNITY_ENABLED } from './services/community/communityService'; // Admin pages are lazy-loaded so public visitors don't download the admin JS bundle. const Admin = lazy(() => import('./pages/admin/AdminHome')); @@ -85,6 +87,7 @@ function App() { } /> } /> } /> + {COMMUNITY_ENABLED && } />} } /> } /> } /> diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index 5c671753..d44e655c 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -24,6 +24,7 @@ import MemberDirectoryProfile from './MemberDirectoryProfile'; import { getLocationReturnPath } from '../login/loginReturnPath'; import { getSpamGuidance } from '../../services/accountEmail/accountEmailSender'; import './Account.css'; +import { COMMUNITY_ENABLED } from '../../services/community/communityService'; function tsToDate(ts: Timestamp | null | undefined) { if (!ts) return ''; @@ -40,6 +41,11 @@ export function AccountPageShell({ children }: { children: React.ReactNode }) {
Manage your MPRC profile, registrations, and connected services.
+ {COMMUNITY_ENABLED && ( +

+ Membership and free-event signup (staging) +

+ )} {children} ); diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx new file mode 100644 index 00000000..8f8e33c7 --- /dev/null +++ b/src/pages/community/Community.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { HelmetProvider } from 'react-helmet-async'; +import { + act, fireEvent, render, screen, waitFor +} from '@testing-library/react'; +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 } }; +jest.mock('../../services/hooks/useAuth', () => ({ useAuth: () => ({ user: mockUser, isLoading: false }) })); +jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: () => ({ services: { firebaseResources: { functions: mockFunctions, auth: mockAuth } } }) })); +jest.mock('../../services/community/communityService', () => ({ COMMUNITY_ENABLED: true, callCommunity: jest.fn() })); +const call = callCommunity as jest.Mock; +const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); +afterAll(() => { + if (originalCrypto) Object.defineProperty(globalThis, 'crypto', originalCrypto); + else Reflect.deleteProperty(globalThis, 'crypto'); +}); +const state = (applicationStatus = 'none', revision = 0) => ({ + membership: { + applicationStatus, revision, entitlement: 'not_entitled', accessThroughMs: null +}, +events: [], +}); +const show = () => ( + + + + + +); +beforeEach(() => { + jest.clearAllMocks(); mockUser = { uid: 'runner-a' }; mockAuth.currentUser.emailVerified = true; + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { randomUUID: () => '123e4567-e89b-42d3-a456-426614174000' } }); + call.mockResolvedValue(state()); +}); +test('signed-out page makes no backend call', () => { + mockUser = null; render(show()); + expect(screen.getByRole('link', { name: 'Sign in to continue' })).toBeInTheDocument(); + expect(call).not.toHaveBeenCalled(); +}); +test('pending membership request uses the signed-in account and rereads status', async () => { + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + call.mockResolvedValueOnce({ membership: { revision: 1, applicationStatus: 'pending' } }).mockResolvedValueOnce(state('pending', 1)); + fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); + 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' }); +}); +test('an uncertain request retries exactly once with the original ID, then rereads', async () => { + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + call.mockRejectedValueOnce(new Error('synthetic timeout')); + fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); + await screen.findByRole('button', { name: 'Retry request' }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Retry request' })).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Request membership' })).toBeDisabled(); + const original = call.mock.calls[1][1]; + call.mockResolvedValueOnce({ membership: { revision: 1, applicationStatus: 'pending' } }).mockResolvedValueOnce(state('pending', 1)); + fireEvent.click(screen.getByRole('button', { name: 'Retry request' })); + await screen.findByText('Request status: pending.'); + expect(call.mock.calls[2][1]).toEqual(original); +}); +test('refresh can recover from a stale or denied command without sending it again', async () => { + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + call.mockRejectedValueOnce(new Error('synthetic conflict')); + fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); + await screen.findByRole('button', { name: 'Retry request' }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh status' })).toBeEnabled()); + call.mockResolvedValueOnce(state('pending', 1)); + fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); + await screen.findByText('Request status: pending.'); + expect(call.mock.calls[2][1]).toEqual({ action: 'status', accountUid: 'runner-a' }); + expect(screen.queryByRole('button', { name: 'Retry request' })).not.toBeInTheDocument(); +}); +test('unverified accounts cannot start a mutation', async () => { + mockAuth.currentUser.emailVerified = false; render(show()); + await screen.findByRole('button', { name: 'Request membership' }); + expect(screen.getByRole('button', { name: 'Request membership' })).toBeDisabled(); +}); +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' })); + 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', +title: 'Synthetic event', +location: 'Synthetic place', + startAtMs: 1800000000000, +opensAtMs: 1, +closesAtMs: 1800000000000, + status: 'open', +capacity: 1, +confirmedCount: 0, + waiverText: 'Synthetic waiver only', +waiverVersion: 'synthetic-v1', + registration: { revision: 0, status: 'none' }, + }; + call.mockResolvedValueOnce({ ...state(), events: [event] }); + render(show()); + await screen.findByRole('button', { name: 'Sign up for Synthetic event' }); + 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({ ...state(), events: [{ ...event, confirmedCount: 1, 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({ + action: 'event_join', eventId: 'fixture-event', expectedRevision: 0, waiverVersion: 'synthetic-v1', waiverAccepted: true +}); + await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel signup for Synthetic event' })).toBeEnabled()); + call.mockResolvedValueOnce({ registration: { status: 'cancelled', revision: 2 } }); + call.mockResolvedValueOnce({ ...state(), events: [{ ...event, registration: { status: 'cancelled', revision: 2 } }] }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel signup for Synthetic event' })); + await screen.findByText(/Your signup: cancelled/); + expect(call.mock.calls[3][1]).toMatchObject({ action: 'event_cancel', expectedRevision: 1 }); + expect(screen.getByRole('checkbox')).not.toBeChecked(); +}); diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx new file mode 100644 index 00000000..26dea04b --- /dev/null +++ b/src/pages/community/Community.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../../services/hooks/useAuth'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import { + callCommunity, COMMUNITY_ENABLED, CommunityRequest, CommunityStatus +} from '../../services/community/communityService'; +import SEO from '../../components/SEO'; + +export default function Community() { + const { services } = useServiceLocator(); + const { user, isLoading } = useAuth(); + const uid = user?.uid; + 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 [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]); + + 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; + } + generation.current += 1; + const {current} = generation; + inFlight.current = true; setBusy(true); setMessage(''); + 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.'); + } + } catch { + if (generation.current === current) { 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 (!enabled) { return ( +
+

Community signup is not available here

+ My Account +
+ ); } + if (isLoading) return

Loading account…

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

Membership and free events

+ Sign in to continue +
+ ); } + const verified = services?.firebaseResources.auth.currentUser?.emailVerified === true; + const blocked = busy || pending.current !== null || !verified; + return ( +
+ +

Membership and free events

+

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

+

+ My Account + {' '} + · + {' '} + Events +

+ + {' '} + {pending.current && } + {!verified && ( +

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

+ )} + {message &&

{message}

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

Club membership

+

+ Request status: + {' '} + {status.membership.applicationStatus.replace(/_/g, ' ')} + . +

+

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

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

+ Access continues through the paid term ending + {new Date(status.membership.accessThroughMs).toLocaleString()} + . +

+)} +

+ Cancelling withdraws a pending request, or keeps approved access through the paid term. + No refund is issued. +

+ + {' '} + +
+
+

Free events

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

No free events are available.

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

{event.title}

+

+ {new Date(event.startAtMs).toLocaleString()} + {' '} + · + {' '} + {event.location} +

+

+ {event.confirmedCount} + {' '} + of + {' '} + {event.capacity} + {' '} + places booked. Your signup: + {' '} + {event.registration.status} + . +

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

{event.waiverText}

+ +
+ + +)} + {event.registration.status === 'confirmed' && ( + +)} +
+))} +
+ +)} +
+ ); +} diff --git a/src/services/community/communityService.test.ts b/src/services/community/communityService.test.ts new file mode 100644 index 00000000..cce1b646 --- /dev/null +++ b/src/services/community/communityService.test.ts @@ -0,0 +1,27 @@ +jest.mock('firebase/functions', () => ({ httpsCallable: jest.fn(() => jest.fn(async () => ({ data: { synthetic: true } }))) })); +export {}; + +const saved = { + environment: process.env.REACT_APP_FIREBASE_ENVIRONMENT, + flag: process.env.REACT_APP_COMMUNITY_STAGING, +}; +afterAll(() => { + if (saved.environment === undefined) delete process.env.REACT_APP_FIREBASE_ENVIRONMENT; + else process.env.REACT_APP_FIREBASE_ENVIRONMENT = saved.environment; + if (saved.flag === undefined) delete process.env.REACT_APP_COMMUNITY_STAGING; + else process.env.REACT_APP_COMMUNITY_STAGING = saved.flag; +}); +test.each([['production', 'true'], ['staging', 'false'], ['staging', undefined]])('disabled environment %s / flag %s makes no call', async (environment, flag) => { + process.env.REACT_APP_FIREBASE_ENVIRONMENT = environment; + if (flag === undefined) delete process.env.REACT_APP_COMMUNITY_STAGING; + else process.env.REACT_APP_COMMUNITY_STAGING = flag; + jest.resetModules(); + const { COMMUNITY_ENABLED, callCommunity } = 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(); +},); +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(); +}); diff --git a/src/services/community/communityService.ts b/src/services/community/communityService.ts new file mode 100644 index 00000000..ccbd3c75 --- /dev/null +++ b/src/services/community/communityService.ts @@ -0,0 +1,32 @@ +import { httpsCallable, Functions } from 'firebase/functions'; + +export const COMMUNITY_ENABLED = process.env.REACT_APP_FIREBASE_ENVIRONMENT === 'staging' + && process.env.REACT_APP_COMMUNITY_STAGING === 'true'; + +export interface CommunityStatus { + membership: { + revision: number; applicationStatus: string; + entitlement: string; accessThroughMs: number | null; + }; + events: Array<{ + id: string; title: string; location: string; startAtMs: number; status: string; + opensAtMs: number; closesAtMs: number; capacity: number; confirmedCount: number; + waiverText: string; waiverVersion: string; + registration: { revision: number; status: string }; + }>; +} +export interface CommunityRequest { + action: 'status' | 'membership_join' | 'membership_cancel' | 'event_join' | 'event_cancel'; + accountUid: string; + requestId?: string; + expectedRevision?: number; + eventId?: string; + waiverVersion?: string; + waiverAccepted?: boolean; +} +export async function callCommunity(functions: Functions, request: CommunityRequest) { + if (!COMMUNITY_ENABLED || functions.app.options.projectId !== 'run-mprc-staging') { + throw new Error('Community staging is unavailable.'); + } + return (await httpsCallable(functions, 'communityActions')(request)).data; +} diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index fed5ee86..037e6f1f 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 = 120; +const EXPECTED_LINT_FILES = 124; const EXPECTED_LINT_ERRORS = 113; const EXPECTED_LINT_WARNINGS = 6; const YAML_TO_JSON = [ diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index 97774b0f..2e4f32d4 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -362,6 +362,18 @@ describe('orders collection', () => { }); describe('server-only operational collections', () => { + test.each([ + 'communityMemberships/runner-a', 'communityEvents/test', + 'communityEvents/test/registrations/runner-a', 'communityConfig/events', + 'communityCommands/runner-a/requests/test', 'communityLimits/runner-a', + ])('community path %s remains denied even for a browser admin', async (path) => { + await seed(path, { schemaVersion: 1 }); + for (const auth of [undefined, { uid: 'runner-a', role: 'member', emailVerified: true }, { uid: 'admin-a', role: 'admin', emailVerified: true }]) { + const client = await db(auth); + await assertFails(client.doc(path).get()); + await assertFails(client.doc(path).set({ schemaVersion: 1 })); + } + }); describe('systemConfig/commerce', () => { const existingPath = 'systemConfig/commerce'; const newPath = 'systemConfig/commerce-probe'; diff --git a/tests/staging-community-release.test.js b/tests/staging-community-release.test.js new file mode 100644 index 00000000..94c7ae95 --- /dev/null +++ b/tests/staging-community-release.test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { FILES, createBundle, functionSpec, validateHostedFunction } = require('../scripts/staging-community-release'); + +test('owner provider calls name only the staging quota project and retain the host boundary', async () => { + const { jsonRequest } = require('../scripts/staging-community-release'); + let calls = 0; + const transport = async (url, options) => { + calls += 1; + assert.equal(options.headers['x-goog-user-project'], 'run-mprc-staging'); + assert.equal(options.headers.Authorization, 'Bearer synthetic-only'); + assert.equal(options.redirect, 'error'); + return { ok: true, json: async () => ({ verified: true }) }; + }; + assert.deepEqual(await jsonRequest('https://cloudfunctions.googleapis.com/v1/projects/run-mprc-staging/locations/-/functions', 'synthetic-only', 'GET', undefined, transport), { verified: true }); + await assert.rejects(jsonRequest('https://foreign.invalid/', 'synthetic-only', 'GET', undefined, transport)); + assert.equal(calls, 1); +}); + +test('bundle is deterministic, bounded and excludes all prototype commerce/PII services', () => { + const root = path.resolve(__dirname, '..'); + const a = createBundle(root); const b = createBundle(root); + assert.equal(a.digest, b.digest); assert.ok(a.zip.length < 1_000_000); + assert.deepEqual(FILES, ['communityOnly.js', 'communityActions.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']); + assert.ok(!FILES.includes('index.js') && !FILES.includes('stripeHelpers.js')); +}); +test('fixed staging specification and provider readback reject drift', () => { + const spec = functionSpec('a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'); + assert.ok(spec.name.endsWith('/communityActions') && spec.name.includes('/run-mprc-staging/')); + assert.equal(spec.serviceAccountEmail, 'mprc-community-runtime@run-mprc-staging.iam.gserviceaccount.com'); + assert.equal(spec.environmentVariables.COMMERCE_ENABLED, 'false'); + const actual = { ...spec, status: 'ACTIVE' }; + assert.doesNotThrow(() => validateHostedFunction(actual, spec)); + for (const change of [{ runtime: 'nodejs20' }, { serviceAccountEmail: 'default' }, { maxInstances: 3 }, { minInstances: 1 }, { secretVolumes: [{}] }, { environmentVariables: { ...spec.environmentVariables, COMMERCE_ENABLED: 'true' } }]) { + assert.throws(() => validateHostedFunction({ ...actual, ...change }, spec)); + } +}); +test('rejects foreign upload destinations or malformed source identities', () => { + assert.throws(() => functionSpec('a'.repeat(40), 'b'.repeat(64), 'https://foreign.invalid/upload')); + assert.throws(() => functionSpec('wrong', 'b'.repeat(64), 'https://storage.googleapis.com/synthetic')); +}); +test('profile release can inspect only the known isolated service, never deploy it', () => { + const { validateKnownCommunity, functionSpec: profileSpec } = require('../scripts/staging-profile-release'); + const known = { ...functionSpec('a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'), status: 'ACTIVE' }; + assert.doesNotThrow(() => validateKnownCommunity(known)); + assert.throws(() => validateKnownCommunity({ ...known, serviceAccountEmail: 'default' })); + assert.throws(() => profileSpec('communityActions', 'a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic')); +}); diff --git a/tests/staging-profile-authority.test.js b/tests/staging-profile-authority.test.js index e661cc69..2167807b 100644 --- a/tests/staging-profile-authority.test.js +++ b/tests/staging-profile-authority.test.js @@ -1,5 +1,7 @@ 'use strict'; +require('./staging-community-release.test.js'); + const assert = require('node:assert/strict'); const { createHash } = require('node:crypto'); const { spawnSync } = require('node:child_process');