diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index 6dce24d..f11a093 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -10,12 +10,12 @@ "targets": [ "src" ], - "scannedFileCount": 124, + "scannedFileCount": 126, "scannedFilesByExtension": { ".js": 12, ".jsx": 35, ".ts": 45, - ".tsx": 32 + ".tsx": 34 }, "errorCount": 113, "warningCount": 6, @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:314fc7ec13b35e59b8dfddedb5c8f7b4a82d02c368c81e991e7de0aa50d3245a", + "fingerprint": "sha256:4edc2ab0b2f1376f47775011308236333cdd8e676b4cc325a1f55691fe000be6", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -81,6 +81,8 @@ "src/pages/admin/shop/AdminProducts.tsx", "src/pages/community/Community.test.tsx", "src/pages/community/Community.tsx", + "src/pages/community/CommunityAdmin.test.tsx", + "src/pages/community/CommunityAdmin.tsx", "src/pages/contact/Contact.jsx", "src/pages/events/EventCalendar.tsx", "src/pages/events/EventDetail.tsx", diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 70c727e..e78dc2a 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**COMMUNITY-003 / #711:** staging-only officer console extension to #707. One callable, two live expiring action capabilities, recent TOTP/verified Auth/native App Check, authoritative target read, configured synthetic test approval, free-only create/edit/capacity/open/close/cancel, bounded pending cursor review and immutable audited retries. No real dues/claims/production/payment/migration/export/mail. Dave selected externally confirmed dues later; policy remains separate. Officer impact: disposable staging approval/event review. Officer documentation: STAGING_COMMUNITY, entrypoints and maps. Deployment evidence: source prepared; hosted release, operator/negative/race proof and owned cleanup required before closure. #114/#115/AUTH-003/ADMIN-001 remain open. + **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: version1 native archive/spec/IAM, website60435c8,38 genuine hosted checks and all four page actions verified on 2026-10-11; #707 records final website/visual/cleanup proof. #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. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3184c84..1049a5a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -109,6 +109,8 @@ Parallel work is safe only across branches that do not share schemas or security ## 4. Phases and exit gates +**Bounded next community slice — #711:** source prepares synthetic membership approval and free-event management within the one existing staging callable. Dependencies #707/#701 are hosted-proven. Stage-only action capabilities, recent TOTP, authoritative target read, immutable/retry-safe audit, bounded cursor review and capacity/revision races precede release. Real off-platform dues confirmation is Dave's selected later direction, not implemented or enabled here. Term/plan/method/approver decisions, renewal/import, production AUTH-003/ADMIN-001 and live #114/#115 remain separate. Officer impact: disposable admin review only. Officer documentation: STAGING_COMMUNITY, entrypoints and maps. Deployment evidence: #711 must separately record source, CI, merge, native archive/spec/IAM/MFA, Hosting, real browser tests and cleanup. + ### Phase 0 — Stop accidental harm and establish a trustworthy baseline **Issues:** SAFETY-001, CONFIG-001, SUPPLY-001, first part of CI-001, LEGAL-001 discovery diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 19d0237..c96c21c 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 +**Officer staging controls — #711, NOT AVAILABLE YET until hosted acceptance:** pending synthetic membership approval and made-up free-event management use two narrow expiring permissions and authenticator sign-in, not the broad admin role. Use the [staging checklist](./docs/officers/STAGING_COMMUNITY.md#officer-review--711-source-prepared-not-available-yet). Externally confirmed real dues are a later separately approved workflow; production and real payments remain untouched. + **Staging membership/free events — #707:** one isolated service and screen passed hosted pending membership signup/withdrawal, synthetic paid-term cancellation and free-event signup/cancellation checks on 2026-10-11. No paid membership activation or real payment is included. Follow the [staging-only review](./docs/officers/STAGING_COMMUNITY.md); ask the specialist for disposable fixtures. The issue records the current website, visual review and cleanup; 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). diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 9eac528..1ba2da8 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -8,6 +8,8 @@ This runbook explains how to operate the website, Firebase services, and Stripe Club officers and backup maintainers should start with [OFFICER_START_HERE.md](./OFFICER_START_HERE.md). It converts this technical runbook into short request, approval, verification, access, and emergency steps without terminal commands. +**#711 officer community release — NOT AVAILABLE YET until hosted proof:** use the no-terminal [staging officer checklist](./docs/officers/STAGING_COMMUNITY.md#officer-review--711-source-prepared-not-available-yet). Dave authorizes only staging under `runmprc@gmail.com`. The fixed existing community release script additionally enables optional TOTP (one adjacent interval) on already-upgraded staging Identity Platform and preserves email/password settings/domains; no SMS or billing upgrade. Runtime gains only bounded Firestore list and target Auth read. Deploy the same one Function before the website and verify native source/spec, exact IAM/no keys, existing profile versions, native Auth/App Check/MFA/capabilities, concurrency/retry, UI and exact fixture cleanup. Keep Auth/Firestore App Check enforced; never use debug attestation, custom token MFA, real identities or financial evidence. A specialist supplies/enrolls/removes disposable fixtures; the officer signs in and uses the screen. Disable the staged officer capability to stop new admin actions and replay immediately; function rollback uses the previous reviewed source and must account for the changed staging MFA/runtime policy. Restore the prior MFA/runtime configuration only after all task-owned MFA accounts are removed; do not disable MFA for an enrolled surviving officer. + ### Node 22 profile release prerequisite — hosted behavior NOT AVAILABLE YET RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) changes source runtime selection, not a hosted service. The protected backend builder uses Node 22; frontend and baseline CI remain Node 20. Both pre-approval and post-approval checks require all six named jobs, including `Node 22 backend compatibility`. That job requires local Auth and Firestore and runs the profile create-once persistence test; it does not prove hosted Auth or App Check. diff --git a/SECURITY.md b/SECURITY.md index f6e10f2..30abd98 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -250,6 +250,8 @@ This is source-only attack-surface reduction. The component had no runtime route ### Authorization +**#711 synthetic staging boundary (source prepared, not hosted proof):** no generic admin-role shortcut. Every officer request requires native App Check, verified recent (ten-minute) TOTP Auth and a current enabled, expiring action capability read transactionally before receipt replay. Owner-only capability provisioning/revocation is outside the callable; no route grants roles or changes accounts. Approval verifies target UID/verified/enabled Auth state read-only, permits only a null-authority pending request and supplies a server-owned explicitly synthetic test term. Immutable receipts record actor, capability/revision, action, target, fixed test reason, before/after state, result and time without profiles or payment data. The runtime's two additional permissions are `datastore.entities.list` for <=21-record cursor queries and `firebaseauth.users.get` for one selected target, at staging-project scope. No delete, Auth mutation, secret or provider/payment permission. Production AUTH-003/MFA/admin-policy and #114/#115 are not completed by this test. + - Server code and Firestore rules check signed token capabilities; the UI is never the enforcement point. - Separate catalog/content editing from financial state, identity administration, exports, and secrets. - No client—including browser admins—can write webhook inbox, payment state, refund IDs, Stripe IDs, audit events, rate limits, or OAuth secrets. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index a6bd3be..4507773 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 +**#711 remains nonfinancial staging only:** officer approval simulates a configured synthetic test term, never a paid or manually confirmed real dues receipt. Dave chose externally confirmed dues for a later separately approved command; accepted methods, term, plan and confirmers are unresolved. Free-event management never calls Stripe, claims a payment, issues refunds or changes real records. No existing commerce blocker is closed by this slice. + **#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: diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 0298ee5..ad15634 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 +**Staging officer extension — #711, source prepared; NOT AVAILABLE YET:** the same isolated `communityActions` service admits only exact admin commands with native attestation, verified recent TOTP sign-in and live expiring `communityOfficers/{uid}` capabilities. Browser `admin` is not sufficient. `membershipApproval` returns at most 20 pending UID/status/revision projections per cursor and approves only a pending request with no prior authority, after read-only authoritative Auth verification. An owner-configured, explicitly synthetic <=24-hour term supplies all approval evidence; no real dues, renewal, claim, payment or off-platform evidence is written. Dave selected externally confirmed dues for later work; its actual plan/term/method/approver policy remains undecided. `freeEventManagement` creates closed zero-price test events, edits only unbooked content, changes capacity no lower than bookings and opens/closes/finally cancels signup. Member registration/cancellation keeps using the shared capacity transaction. New server-only officer permissions, rate and immutable command/audit records are additive; no legacy migration. Exactly Firestore list and Auth read are added to the existing runtime, not write/delete/grant rights. Optional TOTP is enabled only on already-upgraded staging Identity Platform; no SMS, billing upgrade or production change. Hosted proof remains required in #711. + **Isolated nonfinancial lifecycle — #707, staging only:** `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. First native hosted acceptance was verified on 2026-10-11; see the [officer staging guide](./docs/officers/STAGING_COMMUNITY.md) and #707 for current website/cleanup proof, not production availability. **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). diff --git a/docs/officers/README.md b/docs/officers/README.md index 40670fe..f1c4b45 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 +Staging officer approval/free-event management is prepared under [#711](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/711), not hosted-proven yet. Follow the [staging officer checklist](./STAGING_COMMUNITY.md#officer-review--711-source-prepared-not-available-yet) after its acceptance; no real dues or production procedure is included. + 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. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 4441c28..37fb7a1 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -51,6 +51,45 @@ The specialist's Hosting release uses Dave's existing club login for a short-liv ## Cost and scope +## Officer review — #711 source prepared, NOT AVAILABLE YET + +**Purpose:** review synthetic membership approval and free-event management. These controls are not a real dues procedure. Dave selected externally confirmed dues for later work; no real payment method, annual plan, term or confirmation authority has been chosen here. + +**Approver:** Dave, sole staging/recovery owner. **Prerequisites:** #711 must record hosted acceptance. The specialist supplies an explicitly disposable verified officer account, authenticator setup, expiring approval/event permission and a short server-configured synthetic term. It is not an ordinary browser admin role. No officer changes permissions, source or records directly. + +1. Open the supplied staging `/community/admin` link. +2. Confirm **Staging test only** and the staging address. +3. Enter the disposable account's sign-in details privately on this page. +4. Enter its authenticator code. +5. Check the pending test account ID against the specialist's made-up example. +6. Select **Approve test membership** for that account. +7. Confirm the request leaves the pending queue. +8. Ask the specialist to verify the member screen shows the original synthetic expiry and no payment or role grant. +9. Enter a made-up title, location, local event/signup times and capacity in **Create free test event**. +10. Select **Create closed test event**. +11. Confirm it is closed with zero bookings. +12. Select **Edit** before anyone has booked if a correction is needed. +13. Select **Open signup** when ready for the disposable member test. +14. Confirm the displayed booking count after the member signs up. +15. Use **Save capacity** only at or above the booking count. +16. Select **Close signup** to stop new entries without cancelling existing entries. +17. Select **Cancel event** only for the made-up cancellation test; this is final and sends no messages or refunds. +18. Confirm the booked member can still cancel their own signup and release the place. +19. Use **Retry saved action** for an uncertain result; do not repeatedly create new requests. +20. Use **Refresh officer status** to read current state. A successful refresh clears the saved request before a new action. +21. Sign out and sign in with the authenticator again after ten minutes, or if access is denied. +22. Sign out at the end and ask the specialist to remove all owned fixtures. + +**Expected result:** one nonfinancial test approval and coherent free-event changes/counts. Editing booked event content is blocked. A cancelled event cannot reopen. At most twenty configured events are supported; old event archival is not included. Pending review shows twenty opaque test account IDs per page, no profiles/emails or export. + +**Stop:** production address, real data/dues, payment, absent authenticator gate, permission unexpectedly surviving revocation, lost bookings, stale result, or a request to edit records/share secrets. Tell Dave privately. + +**Proof:** exact source/CI/merge, native archive/spec/runtime plus optional TOTP and unchanged sign-in/domain/App Check settings, two capability denial matrix, membership/withdrawal and capacity/closure races, replay/expiry/revocation, actual screen/mobile/offline checks and exact disposable cleanup. Before closing #711, review these steps without a terminal; test enrollment/permission setup and cleanup remain specialist-only. + +**Undo:** stop testing. Ask Dave/specialist to disable the staged capability first and verify old requests are denied, then use the reviewed staged rollback if needed. Do not try to reopen a cancelled event or change membership/payment evidence yourself. **Escalation:** Dave/platform specialist for access, deployment, uncertain results or recovery. + +**Cost/security:** reuse the one min0/max2/256MB/30s community Function; no new scheduled service, dependency or polling. The runtime adds only Firestore list and read-only Auth target lookup, not user changes, claims, deletes or secrets. Permissions expire and are read on every command, including saved retries. Optional authenticator support is staging-only and uses no SMS; ordinary disposable members still use email/password. These controls and warning budgets are not a bill ceiling. + 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 b1cf8ae..14168b4 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -4,6 +4,23 @@ These diagrams show where pages, information, and deployments go. Each diagram i ## Staging community lifecycle — #707, first hosted release verified 2026-10-11 +The #711 officer extension below is source prepared, **NOT AVAILABLE YET** until its issue records staging deployment/acceptance. + +```mermaid +flowchart LR + Officer["/community/admin; staging only"] --> MFA["Verified recent authenticator sign-in + native App Check"] + MFA --> Cap["Live expiring approval / free-event permission"] + Cap --> One["Same communityActions service; no new reserved instance"] + One --> Review["20 pending UID/revision requests per page; no profiles"] + Review --> Approval["Verified target + configured synthetic term; no paid/role grant"] + One --> Manage["Create closed; edit unbooked; capacity >= bookings; open/close/cancel"] + Manage --> Seats["Same transactional free-event seat count"] + One --> Audit["Immutable actor/reason/before/after/result receipt"] + Browser["Browser including broad admin"] -. "Denied direct access and capability writes" .-> Cap +``` + +Text alternative: staged officer tools require recent authenticator sign-in and current action permission, then use the same bounded service for synthetic approval, booking-safe free-event edits and immutable receipts; the browser cannot grant access or write records, and real dues/production remain unavailable. + ```mermaid flowchart LR Account["Staging My Account"] --> Page["/community; production disabled"] diff --git a/functions/communityActions.appCheck.test.js b/functions/communityActions.appCheck.test.js index 79dbe70..04927f7 100644 --- a/functions/communityActions.appCheck.test.js +++ b/functions/communityActions.appCheck.test.js @@ -58,4 +58,8 @@ describe('native authenticated boundary and context binding', () => { })).status).toBe(403); expect(admin.firestore).not.toHaveBeenCalled(); }); + test('native verified single-factor token cannot enter officer business work', async () => { + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { action: 'admin_status', accountUid: 'synthetic-uid' })).status).toBe(403); + expect(admin.firestore).not.toHaveBeenCalled(); + }); }); diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js index 8b706a3..afac071 100644 --- a/functions/communityActions.emulator.test.js +++ b/functions/communityActions.emulator.test.js @@ -1,12 +1,13 @@ const admin = require('firebase-admin'); const { randomUUID } = require('node:crypto'); -const { createCommunityService, assertCommunityEnvironment, parseRequest } = require('./communityActions'); +const { createCommunityService, assertCommunityEnvironment, parseRequest, memberView, eventView } = require('./communityActions'); +const { createAdministrationService, parseAdminRequest } = require('./communityAdministration'); 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 roots = ['communityEvents', 'communityMemberships', 'communityConfig', 'communityCommands', 'communityLimits', 'communityOfficers', 'communityOfficerCommands', 'communityOfficerLimits']; 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(() => { @@ -51,4 +52,27 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', now = paid.authority.term.endsAtMs; expect((await service.status(uid)).membership.entitlement).toBe('not_entitled'); }); + test('officer approval/withdrawal, capacity/closure concurrency and live revocation use real transactions', async () => { + const uid = 'fixture-officer'; const runner = 'fixture-member'; + const caps = { schemaVersion: 1, enabled: true, revision: 1, expiresAtMs: now + 3600000, membershipApproval: true, freeEventManagement: true }; + await db.doc(`communityOfficers/${uid}`).create(caps); + await db.doc('communityConfig/approvalTestTerm').create({ schemaVersion: 1, syntheticOnly: true, policyVersion: 'synthetic-only-not-payment-evidence', termId: 'stage-test', startsAtMs: now - 1000, endsAtMs: now + 60000 }); + const officer = createAdministrationService(db, { getUser: async (targetUid) => ({ uid: targetUid, emailVerified: true, disabled: false }) }, { memberView, eventView }, () => now); + const token = { email_verified: true, auth_time: Math.floor(now / 1000), firebase: { sign_in_second_factor: 'totp' } }; + const adminCommand = (action, revision, extra) => parseAdminRequest({ action, accountUid: uid, requestId: randomUUID(), expectedRevision: revision, reason: 'synthetic_staging_review', ...extra }); + await service.mutate(runner, command('membership_join')); + const review = await officer.execute(uid, token, parseAdminRequest({ action: 'admin_status', accountUid: uid })); + expect(review.pending.map((member) => member.uid)).toEqual([runner]); + const approval = adminCommand('admin_membership_approve', 1, { targetUid: runner }); + const result = await Promise.allSettled([officer.execute(uid, token, approval), service.mutate(runner, command('membership_cancel', 1))]); + expect(result.filter((entry) => entry.status === 'fulfilled')).toHaveLength(1); + const close = adminCommand('admin_event_close', 0, { eventId: 'fixture-event' }); + await Promise.allSettled([officer.execute(uid, token, close), service.mutate(runner, join())]); + const event = (await db.doc('communityEvents/fixture-event').get()).data(); + expect(event.status).toBe('closed'); expect([0, 1]).toContain(event.confirmedCount); + await expect(service.mutate('other', join())).rejects.toBeDefined(); + await officer.execute(uid, token, close); + await db.doc(`communityOfficers/${uid}`).update({ enabled: false, revision: 2 }); + await expect(officer.execute(uid, token, close)).rejects.toHaveProperty('code', 'permission-denied'); + }, 45000); }); diff --git a/functions/communityActions.js b/functions/communityActions.js index 7706fd4..97ed314 100644 --- a/functions/communityActions.js +++ b/functions/communityActions.js @@ -7,6 +7,7 @@ const { parseStrictObject } = require('./requestValidation'); const { deriveMembershipEntitlement } = require('./membershipAuthority'); const policy = require('./membershipCancellationPolicy.json'); const limits = require('./profileRuntime'); +const { parseAdminRequest, assertOfficerToken, createAdministrationService } = require('./communityAdministration'); 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}$/; @@ -71,7 +72,10 @@ function memberView(record, uid, now) { 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 && [keys.length, keys.length + 1].includes(Object.keys(value).length) + && keys.every((key) => Object.hasOwn(value, key)) + && Object.keys(value).every((key) => keys.includes(key) || key === 'revision') + && (!Object.hasOwn(value, 'revision') || integer(value.revision, 1_000_000))); check(value.schemaVersion === 1 && value.amountCents === 0 && ['open', 'closed', 'cancelled'].includes(value.status) && integer(value.capacity, 10000) && value.capacity > 0 @@ -187,10 +191,16 @@ exports.communityActions = functions.runWith({ ...limits, enforceAppCheck: true assertCommunityEnvironment(); check(context.app, 'permission-denied'); check(context.auth?.uid && typeof context.auth.uid === 'string' && ID.test(context.auth.uid), 'unauthenticated'); - const request = parseRequest(data); + const administrative = typeof data?.action === 'string' && data.action.startsWith('admin_'); + const request = administrative ? parseAdminRequest(data) : parseRequest(data); check(request.accountUid === context.auth.uid, 'permission-denied'); if (request.action !== 'status') check(context.auth.token.email_verified === true, 'permission-denied'); + if (administrative) assertOfficerToken(context.auth.token, Date.now()); try { + if (administrative) { + const service = createAdministrationService(admin.firestore(), admin.auth(), { memberView, eventView }); + return await service.execute(context.auth.uid, context.auth.token, request); + } const service = createCommunityService(admin.firestore()); return await (request.action === 'status' ? service.status(context.auth.uid) : service.mutate(context.auth.uid, request)); } catch (failure) { @@ -201,3 +211,5 @@ exports.communityActions = functions.runWith({ ...limits, enforceAppCheck: true exports.createCommunityService = createCommunityService; exports.assertCommunityEnvironment = assertCommunityEnvironment; exports.parseRequest = parseRequest; +exports.memberView = memberView; +exports.eventView = eventView; diff --git a/functions/communityAdministration.js b/functions/communityAdministration.js new file mode 100644 index 0000000..671e785 --- /dev/null +++ b/functions/communityAdministration.js @@ -0,0 +1,170 @@ +'use strict'; + +// Only the isolated staging entrypoint imports this service. No financial or +// role administration, email, roster/profile projection or production fallback. +const functions = require('firebase-functions'); +const { createHash } = require('node:crypto'); +const { parseStrictObject } = require('./requestValidation'); +const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('./membershipAuthority'); +const ID = /^[A-Za-z0-9_-]{1,80}$/; +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const EVENTS = new Set(['admin_event_create', 'admin_event_edit', 'admin_event_capacity', 'admin_event_open', 'admin_event_close', 'admin_event_cancel']); +const TEST_WAIVER = 'SYNTHETIC STAGING TEST ONLY. Not an approved club waiver or real event.'; +const fail = (code = 'failed-precondition') => { throw new functions.https.HttpsError(code, 'Staging officer action unavailable. Refresh or sign in again.'); }; +const check = (condition, code) => { if (!condition) fail(code); }; +const integer = (value, max = 1_000_000) => Number.isSafeInteger(value) && value >= 0 && value <= max; +const identifier = (value) => typeof value === 'string' && ID.test(value); +const text = (value) => typeof value === 'string' && value.trim() === value && value.length > 0 && value.length <= 200 && !/[\p{Cc}\p{Cf}]/u.test(value); + +function parseAdminRequest(input) { + try { + const value = parseStrictObject(input, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['requestId', 'expectedRevision', 'targetUid', 'eventId', 'event', 'capacity', 'reason', 'cursor'] }); + check(identifier(value.accountUid), 'invalid-argument'); + if (value.action === 'admin_status') { + parseStrictObject(value, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['cursor'] }); + check(value.cursor === undefined || identifier(value.cursor), 'invalid-argument'); + return value; + } + check(value.action === 'admin_membership_approve' || EVENTS.has(value.action), 'invalid-argument'); + const target = value.action === 'admin_membership_approve' ? 'targetUid' : 'eventId'; + const extra = ['admin_event_create', 'admin_event_edit'].includes(value.action) ? ['event'] : value.action === 'admin_event_capacity' ? ['capacity'] : []; + parseStrictObject(value, { requiredKeys: ['action', 'accountUid', 'requestId', 'expectedRevision', 'reason', target, ...extra], optionalKeys: [] }); + check(typeof value.requestId === 'string' && UUID.test(value.requestId) && integer(value.expectedRevision) && identifier(value[target]) && value.reason === 'synthetic_staging_review', 'invalid-argument'); + if (value.event) { + parseStrictObject(value.event, { requiredKeys: ['title', 'location', 'startAtMs', 'opensAtMs', 'closesAtMs', 'capacity'], optionalKeys: [] }); + check(text(value.event.title) && text(value.event.location) + && [value.event.startAtMs, value.event.opensAtMs, value.event.closesAtMs].every((time) => integer(time, 8_640_000_000_000_000)) + && value.event.opensAtMs < value.event.closesAtMs && value.event.closesAtMs <= value.event.startAtMs + && integer(value.event.capacity, 10000) && value.event.capacity > 0, 'invalid-argument'); + } + if (value.action === 'admin_event_capacity') check(integer(value.capacity, 10000) && value.capacity > 0, 'invalid-argument'); + return value; + } catch { fail('invalid-argument'); } +} + +function assertOfficerToken(token, now) { + check(token?.email_verified === true && token.firebase?.sign_in_second_factor === 'totp' + && integer(token.auth_time, Number.MAX_SAFE_INTEGER) + && token.auth_time * 1000 <= now + 30000 && now - token.auth_time * 1000 <= 600000, 'permission-denied'); +} +function readCapabilities(record, now) { + check(record?.schemaVersion === 1 && record.enabled === true && integer(record.revision) + && integer(record.expiresAtMs, 8_640_000_000_000_000) && record.expiresAtMs > now + && typeof record.membershipApproval === 'boolean' && typeof record.freeEventManagement === 'boolean' + && Object.keys(record).length === 6, 'permission-denied'); + return { membershipApproval: record.membershipApproval, freeEventManagement: record.freeEventManagement }; +} +function manifestIds(record) { + const ids = record ? record.eventIds : []; + check(Array.isArray(ids) && ids.length <= 20 && new Set(ids).size === ids.length && ids.every(identifier)); + return ids; +} +function syntheticAuthority(uid, commandId, config, now) { + check(config?.schemaVersion === 1 && config.syntheticOnly === true + && config.policyVersion === 'synthetic-only-not-payment-evidence' + && identifier(config.termId) && integer(config.startsAtMs, 8_640_000_000_000_000) + && integer(config.endsAtMs, 8_640_000_000_000_000) + && config.startsAtMs <= now && config.endsAtMs > now + && config.endsAtMs - config.startsAtMs <= 86400000 && Object.keys(config).length === 6); + let authority = createMembershipAuthority({ membershipAuthoritySchemaVersion: 1, membershipId: `stage_${uid}`, commandId: `create_${commandId}` }); + authority = applyMembershipAuthorityCommand(authority, { membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', commandId: `link_${commandId}`, expectedRevision: 1, uid }); + return applyMembershipAuthorityCommand(authority, { + membershipAuthoritySchemaVersion: 1, commandType: 'record_term_decision', commandId, + expectedRevision: 2, termRevision: 1, termState: 'approved', termId: config.termId, + startsAtMs: config.startsAtMs, endsAtMs: config.endsAtMs, + planRef: 'plan_synthetic_only', evidenceRef: `synthetic_${commandId}`, policyVersion: config.policyVersion, + }); +} + +function createAdministrationService(db, auth, views, clock = Date.now) { + async function execute(uid, token, request) { + // Recheck inside every retry as well as the native boundary. Revocation + // precedes receipt replay, so an old successful command grants no access. + return db.runTransaction(async (tx) => { + const now = clock(); assertOfficerToken(token, now); + const capability = await tx.get(db.doc(`communityOfficers/${uid}`)); + const caps = readCapabilities(capability.data(), now); + if (request.action === 'admin_status') { + let pending = []; let nextCursor = null; let events = []; + if (caps.membershipApproval) { + let query = db.collection('communityMemberships').where('applicationStatus', '==', 'pending').orderBy('__name__').limit(21); + if (request.cursor) query = query.startAfter(request.cursor); + const records = await tx.get(query); + pending = records.docs.slice(0, 20).map((snapshot) => ({ uid: snapshot.id, ...views.memberView(snapshot.data(), snapshot.id, now) })); + if (records.docs.length > 20) nextCursor = pending[pending.length - 1].uid; + } + if (caps.freeEventManagement) { + const manifest = await tx.get(db.doc('communityConfig/events')); + const ids = manifestIds(manifest.data()); + const records = ids.length ? await tx.getAll(...ids.map((id) => db.doc(`communityEvents/${id}`))) : []; + events = records.map((snapshot, n) => ({ ...views.eventView(snapshot.data(), ids[n]), revision: snapshot.data().revision ?? 0 })); + } + return { capabilities: caps, pending, nextCursor, events }; + } + const required = request.action === 'admin_membership_approve' ? 'membershipApproval' : 'freeEventManagement'; + check(caps[required], 'permission-denied'); + const fingerprint = createHash('sha256').update(JSON.stringify(request, Object.keys(request).sort().concat(['title', 'location', 'startAtMs', 'opensAtMs', 'closesAtMs', 'capacity']))).digest('hex'); + const commandRef = db.doc(`communityOfficerCommands/${uid}/requests/${request.requestId}`); + const command = await tx.get(commandRef); + if (command.exists) { check(command.data().fingerprint === fingerprint, 'already-exists'); return command.data().result; } + const rateRef = db.doc(`communityOfficerLimits/${uid}`); + 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, Number.MAX_SAFE_INTEGER) && integer(previous.count, 12)); + const count = previous.window === window ? previous.count : 0; check(count < 12, 'resource-exhausted'); + let result; let before; let after; const writes = []; + if (required === 'membershipApproval') { + const ref = db.doc(`communityMemberships/${request.targetUid}`); + const [snapshot, policy] = await tx.getAll(ref, db.doc('communityConfig/approvalTestTerm')); + const member = snapshot.data(); const view = views.memberView(member, request.targetUid, now); + check(view.revision === request.expectedRevision && view.applicationStatus === 'pending' && member.authority === null && view.revision < 1_000_000); + // Read-only Auth permission, not email matching/role administration. + const target = await auth.getUser(request.targetUid); + check(target.uid === request.targetUid && target.emailVerified === true && target.disabled === false); + const authority = syntheticAuthority(request.targetUid, request.requestId, policy.data(), now); + const next = { ...member, revision: member.revision + 1, applicationStatus: 'approved', authority, updatedAtMs: now }; + before = { revision: member.revision, applicationStatus: member.applicationStatus }; + after = { revision: next.revision, applicationStatus: next.applicationStatus }; + writes.push([ref, next, true]); result = { membership: after, syntheticOnly: true }; + } else { + const ref = db.doc(`communityEvents/${request.eventId}`); + const [snapshot, manifest] = await tx.getAll(ref, db.doc('communityConfig/events')); + const ids = manifestIds(manifest.data()); const creating = request.action === 'admin_event_create'; + let event; + if (creating) { + check(!snapshot.exists && request.expectedRevision === 0 && ids.length < 20 && !ids.includes(request.eventId)); + event = { schemaVersion: 1, ...request.event, status: 'closed', amountCents: 0, confirmedCount: 0, waiverVersion: 'synthetic-only-v1', waiverText: TEST_WAIVER, waiverHash: createHash('sha256').update(TEST_WAIVER).digest('hex'), revision: 1 }; + check(event.startAtMs > now && event.closesAtMs > now); + writes.push([db.doc('communityConfig/events'), { schemaVersion: 1, eventIds: [...ids, request.eventId] }, manifest.exists]); + before = { revision: 0, status: 'none', capacity: 0 }; + } else { + check(ids.includes(request.eventId)); + event = views.eventView(snapshot.data(), request.eventId); + const revision = event.revision ?? 0; + check(revision === request.expectedRevision && revision < 1_000_000 && event.status !== 'cancelled'); + delete event.id; + before = { revision, status: event.status, capacity: event.capacity }; + if (request.action === 'admin_event_edit') { + check(event.confirmedCount === 0 && request.event.startAtMs > now && request.event.closesAtMs > now); + event = { ...event, ...request.event }; + } else if (request.action === 'admin_event_capacity') { + check(request.capacity >= event.confirmedCount); event.capacity = request.capacity; + } else if (request.action === 'admin_event_open') { + check(now < event.closesAtMs && now < event.startAtMs); event.status = 'open'; + } else event.status = request.action === 'admin_event_cancel' ? 'cancelled' : 'closed'; + event.revision = revision + 1; + } + views.eventView(event, request.eventId); + after = { revision: event.revision, status: event.status, capacity: event.capacity }; + writes.push([ref, event, snapshot.exists]); result = { event: { id: request.eventId, ...after } }; + } + assertOfficerToken(token, clock()); readCapabilities(capability.data(), clock()); + 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, actorUid: uid, capability: required, capabilityRevision: capability.data().revision, action: request.action, targetId: request.targetUid || request.eventId, reason: request.reason, before, after, result, createdAtMs: now }); + return result; + }); + } + return { execute }; +} +module.exports = { parseAdminRequest, assertOfficerToken, readCapabilities, createAdministrationService, TEST_WAIVER }; diff --git a/functions/communityAdministration.test.js b/functions/communityAdministration.test.js new file mode 100644 index 0000000..10adb45 --- /dev/null +++ b/functions/communityAdministration.test.js @@ -0,0 +1,127 @@ +const { randomUUID } = require('node:crypto'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const { freeEvent, approvedMember } = require('./testSupport/communityFixtures'); +const { createCommunityService, eventView, memberView, parseRequest } = require('./communityActions'); +const { parseAdminRequest, assertOfficerToken, readCapabilities, createAdministrationService } = require('./communityAdministration'); +const now = 1_800_000_000_000; +const token = { email_verified: true, auth_time: now / 1000, firebase: { sign_in_second_factor: 'totp' } }; +const capabilities = (extra = {}) => ({ schemaVersion: 1, enabled: true, revision: 1, expiresAtMs: now + 3600000, membershipApproval: true, freeEventManagement: true, ...extra }); +const term = { schemaVersion: 1, syntheticOnly: true, policyVersion: 'synthetic-only-not-payment-evidence', termId: 'stage-term', startsAtMs: now - 1000, endsAtMs: now + 60000 }; +const draft = { title: 'Synthetic event', location: 'Synthetic location', startAtMs: now + 3600000, opensAtMs: now - 1000, closesAtMs: now + 1800000, capacity: 1 }; +const request = (action, extra = {}) => parseAdminRequest({ action, accountUid: 'officer', requestId: randomUUID(), expectedRevision: 0, reason: 'synthetic_staging_review', ...extra }); +const approve = (extra = {}) => request('admin_membership_approve', { targetUid: 'runner', expectedRevision: 1, ...extra }); +const join = (extra = {}) => parseRequest({ action: 'event_join', accountUid: 'runner', requestId: randomUUID(), expectedRevision: 0, eventId: 'event', waiverVersion: 'synthetic-only-v1', waiverAccepted: true, ...extra }); +let docs; let service; let members; let auth; +beforeEach(() => { + const memory = memoryDb(); docs = memory.docs; + auth = { getUser: jest.fn(async (uid) => ({ uid, emailVerified: true, disabled: false })) }; + service = createAdministrationService(memory.db, auth, { memberView, eventView }, () => now); + members = createCommunityService(memory.db, () => now); + docs.set('communityOfficers/officer', capabilities()); + docs.set('communityConfig/approvalTestTerm', term); + docs.set('communityConfig/events', { schemaVersion: 1, eventIds: ['event'] }); + docs.set('communityEvents/event', freeEvent(now)); + docs.set('communityMemberships/runner', { schemaVersion: 1, revision: 1, applicationStatus: 'pending', authority: null }); +}); +const execute = (command, credentials = token) => service.execute('officer', credentials, command); +test.each([null, {}, { ...token, email_verified: false }, { ...token, auth_time: now / 1000 - 601 }, { ...token, auth_time: now / 1000 + 31 }, { ...token, auth_time: '1800000000' }, { ...token, firebase: {} }, { ...token, firebase: { sign_in_second_factor: 'sms' } }])('denies unverified/non-TOTP/stale/malformed officer token %#', (input) => { + expect(() => assertOfficerToken(input, now)).toThrow(); +}); +test.each([null, capabilities({ enabled: false }), capabilities({ expiresAtMs: now }), capabilities({ membershipApproval: 'true' }), { ...capabilities(), forged: true }])('denies missing/revoked/expired/malformed capability %#', (input) => { expect(() => readCapabilities(input, now)).toThrow(); }); +test.each([ + { action: 'admin_status', accountUid: 'officer', role: 'admin' }, + { ...approve(), targetUid: {} }, { ...approve(), reason: 'real payment received' }, + { ...request('admin_event_create', { eventId: 'event', event: draft }), event: { ...draft, amountCents: 0 } }, + { ...request('admin_event_create', { eventId: 'event', event: draft }), event: { ...draft, title: '\u202eInjected' } }, + { ...request('admin_event_capacity', { eventId: 'event', capacity: 1 }), capacity: 1.5 }, +])('strict request rejects forged/financial/coerced/unsafe fields %#', (value) => { expect(() => parseAdminRequest(value)).toThrow(); }); +test('synthetic approval is atomic/repeat-safe, supplies no payment or Auth claims', async () => { + const command = approve(); await execute(command); await execute(command); + expect((await members.status('runner')).membership).toMatchObject({ revision: 2, applicationStatus: 'approved', entitlement: 'current_member', accessThroughMs: term.endsAtMs }); + const receipt = docs.get(`communityOfficerCommands/officer/requests/${command.requestId}`); + expect(receipt).toMatchObject({ actorUid: 'officer', capability: 'membershipApproval', targetId: 'runner', before: { revision: 1 }, after: { revision: 2 }, reason: 'synthetic_staging_review' }); + expect(auth.getUser).toHaveBeenCalledTimes(1); + expect(docs.get('communityMemberships/runner').authority.term.evidenceRef).toMatch(/^synthetic_/); + const authority = docs.get('communityMemberships/runner').authority; + await members.mutate('runner', parseRequest({ action: 'membership_cancel', accountUid: 'runner', requestId: randomUUID(), expectedRevision: 2 })); + expect(docs.get('communityMemberships/runner').authority).toEqual(authority); +}); +test('live capability is checked before an old successful receipt is replayed', async () => { + const command = approve(); await execute(command); + docs.set('communityOfficers/officer', capabilities({ enabled: false })); + await expect(execute(command)).rejects.toHaveProperty('code', 'permission-denied'); +}); +test.each(['membershipApproval', 'freeEventManagement'])('other capability cannot authorize %s or expose its projection', async (missing) => { + docs.set('communityOfficers/officer', capabilities({ [missing]: false })); + const command = missing === 'membershipApproval' ? approve() : request('admin_event_close', { eventId: 'event' }); + await expect(execute(command)).rejects.toHaveProperty('code', 'permission-denied'); + const state = await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer' })); + expect(missing === 'membershipApproval' ? state.pending : state.events).toEqual([]); + expect(auth.getUser).not.toHaveBeenCalled(); +}); +test.each([{ emailVerified: false, disabled: false }, { emailVerified: true, disabled: true }, { uid: 'other', emailVerified: true, disabled: false }])('target must be authoritative verified enabled Auth UID %#', async (target) => { + auth.getUser.mockResolvedValue({ uid: 'runner', ...target }); + await expect(execute(approve())).rejects.toHaveProperty('code', 'failed-precondition'); + expect(docs.get('communityMemberships/runner').revision).toBe(1); + expect(docs.has('communityOfficerLimits/officer')).toBe(false); +}); +test.each([null, { ...term, syntheticOnly: false }, { ...term, endsAtMs: now }, { ...term, policyVersion: 'real-dues' }, { ...term, endsAtMs: now + 86400001 }])('missing/wrong/expired/real policy never activates %#', async (config) => { + docs.set('communityConfig/approvalTestTerm', config); + await expect(execute(approve())).rejects.toBeDefined(); + expect(docs.get('communityMemberships/runner').authority).toBeNull(); +}); +test('withdrawal and approval cannot both mutate one pending revision', async () => { + const result = await Promise.allSettled([execute(approve()), members.mutate('runner', parseRequest({ action: 'membership_cancel', accountUid: 'runner', requestId: randomUUID(), expectedRevision: 1 }))]); + expect(result.filter((entry) => entry.status === 'fulfilled')).toHaveLength(1); +}); +test.each(['withdrawn', 'approved', 'cancellation_requested'])('approval cannot restore non-pending/suspended existing authority %s', async (status) => { + docs.set('communityMemberships/runner', { ...approvedMember('runner', now), applicationStatus: status }); + await expect(execute(approve())).rejects.toBeDefined(); +}); +test('bounded pending queue cursor projects no profiles, emails or evidence', async () => { + for (let n = 0; n < 22; n += 1) docs.set(`communityMemberships/test-${String(n).padStart(2, '0')}`, { schemaVersion: 1, revision: 1, applicationStatus: 'pending', authority: null, email: 'synthetic-private.invalid' }); + const first = await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer' })); + expect(first.pending).toHaveLength(20); expect(first.nextCursor).toBeTruthy(); + expect(JSON.stringify(first.pending)).not.toContain('email'); + const next = await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer', cursor: first.nextCursor })); + expect(next.pending).toHaveLength(3); expect(next.nextCursor).toBeNull(); +}); +test('create starts closed, edit/open/close/cancel are revision-fenced and free only', async () => { + const command = request('admin_event_create', { eventId: 'new', event: draft }); + await execute(command); await execute(command); + expect(docs.get('communityEvents/new')).toMatchObject({ revision: 1, status: 'closed', amountCents: 0, confirmedCount: 0 }); + expect(docs.get('communityConfig/events').eventIds).toEqual(['event', 'new']); + await execute(request('admin_event_edit', { eventId: 'new', expectedRevision: 1, event: { ...draft, title: 'Updated synthetic title' } })); + await execute(request('admin_event_open', { eventId: 'new', expectedRevision: 2 })); + await execute(request('admin_event_close', { eventId: 'new', expectedRevision: 3 })); + await execute(request('admin_event_cancel', { eventId: 'new', expectedRevision: 4 })); + await expect(execute(request('admin_event_open', { eventId: 'new', expectedRevision: 5 }))).rejects.toBeDefined(); + await expect(execute(request('admin_event_close', { eventId: 'new', expectedRevision: 1 }))).rejects.toBeDefined(); + expect(docs.get('communityEvents/new')).toMatchObject({ revision: 5, status: 'cancelled', amountCents: 0 }); +}); +test('changing same UUID nested event payload is a conflict', async () => { + const command = request('admin_event_create', { eventId: 'new', event: draft }); + await execute(command); + await expect(execute({ ...command, event: { ...draft, capacity: 2 } })).rejects.toHaveProperty('code', 'already-exists'); +}); +test('capacity changes preserve participant updates and cannot undercut bookings', async () => { + await members.mutate('runner', join()); + await expect(execute(request('admin_event_edit', { eventId: 'event', event: draft }))).rejects.toBeDefined(); + await execute(request('admin_event_capacity', { eventId: 'event', capacity: 2 })); + await members.mutate('second', join()); + await expect(execute(request('admin_event_capacity', { eventId: 'event', expectedRevision: 1, capacity: 1 }))).rejects.toBeDefined(); + expect(docs.get('communityEvents/event').confirmedCount).toBe(2); +}); +test('close prevents entry but member cancellation remains possible after final event cancellation', async () => { + await members.mutate('runner', join()); + await execute(request('admin_event_cancel', { eventId: 'event' })); + expect(docs.get('communityEvents/event').confirmedCount).toBe(1); + await expect(members.mutate('other', join())).rejects.toBeDefined(); + await members.mutate('runner', parseRequest({ action: 'event_cancel', accountUid: 'runner', requestId: randomUUID(), expectedRevision: 1, eventId: 'event' })); + expect(docs.get('communityEvents/event').confirmedCount).toBe(0); +}); +test('more than twenty events never creates an unlisted event', async () => { + docs.set('communityConfig/events', { eventIds: Array.from({ length: 20 }, (_, n) => `event-${n}`) }); + await expect(execute(request('admin_event_create', { eventId: 'new', event: draft }))).rejects.toBeDefined(); + expect(docs.has('communityEvents/new')).toBe(false); +}); diff --git a/functions/testSupport/communityMemoryDb.js b/functions/testSupport/communityMemoryDb.js new file mode 100644 index 0000000..4f5ad7c --- /dev/null +++ b/functions/testSupport/communityMemoryDb.js @@ -0,0 +1,22 @@ +// Transactional test double; real Firestore race checks remain separate. +function memoryDb() { + const docs = new Map(); let tail = Promise.resolve(); + const doc = (path) => ({ path, id: path.split('/').pop(), collection: (name) => ({ doc: (id) => doc(`${path}/${name}/${id}`) }) }); + const query = (path, match = null, cursor = '', limit = 100) => ({ path, match, cursor, limitCount: limit, + where: (key, op, value) => query(path, [key, value], cursor, limit), + orderBy: () => query(path, match, cursor, limit), limit: (count) => query(path, match, cursor, count), + startAfter: (id) => query(path, match, id, limit), + }); + const get = async (ref) => { + if (Object.hasOwn(ref, 'limitCount')) return { docs: [...docs].filter(([path, value]) => path.startsWith(`${ref.path}/`) && path.split('/').length === 2 && (!ref.match || value[ref.match[0]] === ref.match[1]) && path.split('/')[1] > ref.cursor).sort(([a], [b]) => a.localeCompare(b)).slice(0, ref.limitCount).map(([path, value]) => ({ id: path.split('/')[1], data: () => value })) }; + return { exists: docs.has(ref.path), data: () => docs.get(ref.path) }; + }; + const db = { doc, collection: (path) => query(path), 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 }; +} +module.exports = { memoryDb }; diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index d78911e..94d867a 100644 --- a/scripts/staging-community-release.js +++ b/scripts/staging-community-release.js @@ -7,6 +7,7 @@ const os = require('node:os'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const { createHash } = require('node:crypto'); +const { isDeepStrictEqual } = require('node:util'); const { jsonRequest: providerRequest, waitForOperation } = require('./staging-profile-release'); const { verifyCurrentSource } = require('./staging-profile-authority'); const PROJECT = 'run-mprc-staging'; @@ -15,12 +16,22 @@ 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 FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.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) { + const authUrl = `https://identitytoolkit.googleapis.com/admin/v2/projects/${PROJECT}/config`; + if (url === authUrl || url === `${authUrl}?updateMask=mfa`) { + check((url === authUrl && method === 'GET' && body === undefined) + || (method === 'PATCH' && url === `${authUrl}?updateMask=mfa` + && JSON.stringify(body) === JSON.stringify({ mfa: STAGING_MFA }))); + return fetchImpl(url, { method, redirect: 'error', signal: AbortSignal.timeout(15000), + headers: { Authorization: `Bearer ${token}`, 'x-goog-user-project': PROJECT, 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }).then((response) => { check(response.ok); return response.json(); }); + } return providerRequest(url, token, method, body, (resource, options) => fetchImpl(resource, { ...options, headers: { ...options.headers, 'x-goog-user-project': PROJECT }, })); @@ -68,6 +79,15 @@ function validateHostedFunction(actual, expected) { && 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)); } +const STAGING_MFA = { state: 'ENABLED', providerConfigs: [{ state: 'ENABLED', totpProviderConfig: { adjacentIntervals: 1 } }] }; +function validateStagingMfa(config) { + check(config.subtype === 'IDENTITY_PLATFORM' && config.signIn?.email?.enabled === true + && config.mfa?.state === STAGING_MFA.state && config.mfa.providerConfigs?.length === 1); + const provider = config.mfa.providerConfigs[0]; + check(provider.state === 'ENABLED' && provider.totpProviderConfig?.adjacentIntervals === 1 + && Object.keys(provider).length === 2 && Object.keys(provider.totpProviderConfig).length === 1 + && !config.mfa.enabledProviders?.length); +} 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(); @@ -76,7 +96,7 @@ async function release(root) { 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']; + const permissions = ['datastore.databases.get', 'datastore.entities.get', 'datastore.entities.create', 'datastore.entities.update', 'datastore.entities.list', 'firebaseauth.users.get']; 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}`)); @@ -84,6 +104,18 @@ async function release(root) { 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 authUrl = `https://identitytoolkit.googleapis.com/admin/v2/projects/${PROJECT}/config`; + const beforeAuth = await jsonRequest(authUrl, token); + check(beforeAuth.subtype === 'IDENTITY_PLATFORM' && beforeAuth.signIn?.email?.enabled === true); + if (beforeAuth.mfa?.state === 'DISABLED') { + check(!beforeAuth.mfa.providerConfigs?.length && !beforeAuth.mfa.enabledProviders?.length); + await verifyCurrentSource(source); + await jsonRequest(`${authUrl}?updateMask=mfa`, token, 'PATCH', { mfa: STAGING_MFA }); + } + const afterAuth = await jsonRequest(authUrl, token); + validateStagingMfa(afterAuth); + check(isDeepStrictEqual(beforeAuth.signIn, afterAuth.signIn) + && isDeepStrictEqual(beforeAuth.authorizedDomains, afterAuth.authorizedDomains)); 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/`))); @@ -118,4 +150,4 @@ async function release(root) { 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 }; +module.exports = { FILES, createBundle, functionSpec, validateHostedFunction, jsonRequest, validateStagingMfa }; diff --git a/src/App.jsx b/src/App.jsx index 39d38c7..a1b4cdc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,7 @@ 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 CommunityAdmin from './pages/community/CommunityAdmin'; import { COMMUNITY_ENABLED } from './services/community/communityService'; // Admin pages are lazy-loaded so public visitors don't download the admin JS bundle. @@ -88,6 +89,7 @@ function App() { } /> } /> {COMMUNITY_ENABLED && } />} + {COMMUNITY_ENABLED && } />} } /> } /> } /> diff --git a/src/pages/community/Community.css b/src/pages/community/Community.css index 198785d..1be8a5b 100644 --- a/src/pages/community/Community.css +++ b/src/pages/community/Community.css @@ -11,6 +11,9 @@ .community-staging h2 { margin-bottom: 0.75rem; } .community-staging p { margin-block: 0.75rem; } .community-staging a { text-decoration: underline; } +.community-staging form { margin-block: 1.5rem; } +.community-staging form label { display: block; margin-block: 0.75rem; } +.community-staging form input { display: block; box-sizing: border-box; width: 100%; max-width: 420px; min-height: 44px; padding: 0.5rem; color: #17241d; background: #fff; border: 1px solid #53675b; } .community-staging [role="note"] { padding: 1rem; diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 15681e4..646016f 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -105,6 +105,8 @@ export default function Community() { · {' '} Events + {' · '} + Officer tools (staging)

{' '} diff --git a/src/pages/community/CommunityAdmin.test.tsx b/src/pages/community/CommunityAdmin.test.tsx new file mode 100644 index 0000000..b496fa0 --- /dev/null +++ b/src/pages/community/CommunityAdmin.test.tsx @@ -0,0 +1,100 @@ +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 { getMultiFactorResolver, signInWithEmailAndPassword, TotpMultiFactorGenerator } from 'firebase/auth'; +import CommunityAdmin from './CommunityAdmin'; +import { callAdministration } from '../../services/community/communityService'; + +let mockUser: any = { uid: 'officer' }; +let mockEnabled = true; +const mockFunctions = { app: { options: { projectId: 'run-mprc-staging' } } }; +const mockAuth = { app: { options: { projectId: 'run-mprc-staging' } } }; +const mockSignOut = jest.fn(); +jest.mock('../../services/hooks/useAuth', () => ({ useAuth: () => ({ user: mockUser, isLoading: false, signOut: mockSignOut }) })); +jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: () => ({ services: { firebaseResources: { functions: mockFunctions, auth: mockAuth } } }) })); +jest.mock('../../services/community/communityService', () => ({ get COMMUNITY_ENABLED() { return mockEnabled; }, callAdministration: jest.fn() })); +jest.mock('firebase/auth', () => ({ signInWithEmailAndPassword: jest.fn(), getMultiFactorResolver: jest.fn(), TotpMultiFactorGenerator: { FACTOR_ID: 'totp', assertionForSignIn: jest.fn(() => 'synthetic-assertion') } })); +const call = callAdministration as jest.Mock; +const state = (extra = {}) => ({ + capabilities: { membershipApproval: true, freeEventManagement: true }, pending: [{ uid: 'runner', revision: 1, applicationStatus: 'pending' }], nextCursor: null, events: [], ...extra +}); +const show = () => ( + + + + + +); +const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); +afterAll(() => { if (cryptoDescriptor) Object.defineProperty(globalThis, 'crypto', cryptoDescriptor); else Reflect.deleteProperty(globalThis, 'crypto'); }); +beforeEach(() => { + jest.clearAllMocks(); mockEnabled = true; mockUser = { uid: 'officer' }; + mockFunctions.app.options.projectId = 'run-mprc-staging'; + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { randomUUID: () => '123e4567-e89b-42d3-a456-426614174000' } }); + call.mockResolvedValue(state()); + (TotpMultiFactorGenerator.assertionForSignIn as jest.Mock).mockReturnValue('synthetic-assertion'); +}); +test('production and disabled flag make no call', () => { + mockEnabled = false; const result = render(show()); expect(call).not.toHaveBeenCalled(); + mockEnabled = true; mockFunctions.app.options.projectId = 'production'; result.rerender(show()); expect(call).not.toHaveBeenCalled(); +}); +test('signed-out staging exposes a private MFA sign-in and makes no officer call', () => { + mockUser = null; render(show()); expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password'); expect(call).not.toHaveBeenCalled(); +}); +test('approval uses target revision and fixed reason then rereads current authority', async () => { + render(show()); const button = await screen.findByRole('button', { name: 'Approve test membership for runner' }); + call.mockResolvedValue(state({ pending: [] })); fireEvent.click(button); + await screen.findByText('No pending requests.'); + expect(call.mock.calls[1][1]).toMatchObject({ + action: 'admin_membership_approve', accountUid: 'officer', targetUid: 'runner', expectedRevision: 1, reason: 'synthetic_staging_review' +}); + expect(call.mock.calls[2][1].action).toBe('admin_status'); +}); +test('unknown result hides projections and retry reuses exact command', async () => { + render(show()); const button = await screen.findByRole('button', { name: 'Approve test membership for runner' }); + call.mockRejectedValueOnce(new Error('synthetic outage')); fireEvent.click(button); + const retry = await screen.findByRole('button', { name: 'Retry saved action' }); + await screen.findByText(/Action could not be confirmed/); + expect(screen.queryByText('Pending membership requests')).not.toBeInTheDocument(); + const command = call.mock.calls[1][1]; fireEvent.click(retry); + await screen.findByText('Action confirmed. Current server status is shown.'); + expect(call.mock.calls[2][1]).toEqual(command); +}); +test('service/account change discards obsolete privileged response', async () => { + let resolve: (value: any) => void = () => undefined; + call.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const result = render(show()); mockUser = { uid: 'other' }; call.mockResolvedValue(state({ pending: [] })); result.rerender(show()); + await screen.findByText('No pending requests.'); await act(async () => resolve(state())); + expect(screen.queryByText('Test account: runner')).not.toBeInTheDocument(); +}); +test('permissions control minimum views without relying on browser admin role', async () => { + call.mockResolvedValue(state({ + capabilities: { membershipApproval: false, freeEventManagement: true }, pending: [], + })); render(show()); + await screen.findByText('Manage free test events'); expect(screen.queryByText('Pending membership requests')).not.toBeInTheDocument(); +}); +test('authenticator challenge clears passwords and resolves only TOTP', async () => { + mockUser = null; + const resolveSignIn = jest.fn().mockResolvedValue({}); + (signInWithEmailAndPassword as jest.Mock).mockRejectedValue({ code: 'auth/multi-factor-auth-required' }); + (getMultiFactorResolver as jest.Mock).mockReturnValue({ hints: [{ factorId: 'totp', uid: 'synthetic-factor' }], resolveSignIn }); + render(show()); fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'synthetic@example.invalid' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'synthetic-test-only' } }); fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + const code = await screen.findByLabelText('Authenticator code'); expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + fireEvent.change(code, { target: { value: '123456' } }); fireEvent.click(screen.getByRole('button', { name: 'Verify authenticator code' })); + await waitFor(() => expect(resolveSignIn).toHaveBeenCalledWith('synthetic-assertion')); + expect(call).not.toHaveBeenCalled(); +}); +test('booked events disable content edits and cancelled events cannot reopen', async () => { + call.mockResolvedValue(state({ + events: [{ + id: 'test-event', title: 'Synthetic run', location: 'Synthetic place', startAtMs: 1800000000000, opensAtMs: 1799990000000, closesAtMs: 1799999900000, capacity: 2, confirmedCount: 1, revision: 3, status: 'cancelled' +}] +})); + render(show()); + expect(await screen.findByRole('button', { name: 'Edit Synthetic run' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Open signup for Synthetic run' })).toBeDisabled(); + expect(screen.getByLabelText('Capacity for Synthetic run')).toHaveAttribute('min', '1'); +}); diff --git a/src/pages/community/CommunityAdmin.tsx b/src/pages/community/CommunityAdmin.tsx new file mode 100644 index 0000000..da315af --- /dev/null +++ b/src/pages/community/CommunityAdmin.tsx @@ -0,0 +1,289 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + getMultiFactorResolver, MultiFactorError, MultiFactorResolver, + signInWithEmailAndPassword, TotpMultiFactorGenerator +} from 'firebase/auth'; +import { useAuth } from '../../services/hooks/useAuth'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import { + AdministrationRequest, AdministrationStatus, callAdministration, COMMUNITY_ENABLED, FreeEventDraft +} from '../../services/community/communityService'; +import SEO from '../../components/SEO'; +import './Community.css'; + +function OfficerSignIn() { + const { services } = useServiceLocator(); + const auth = services?.firebaseResources.auth; + const [resolver, setResolver] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const active = useRef(0); + const inFlight = useRef(false); + useEffect(() => { + active.current += 1; setResolver(null); setBusy(false); setMessage(''); + inFlight.current = false; + return () => { active.current += 1; }; + }, [auth]); + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (!auth || auth.app.options.projectId !== 'run-mprc-staging' || inFlight.current) return; + const form = event.currentTarget; + const values = new FormData(form); + const generation = active.current; + inFlight.current = true; setBusy(true); setMessage(''); + form.reset(); // Credentials/codes are never persisted or logged. + try { + if (resolver) { + const hint = resolver.hints.find( + (factor) => factor.factorId === TotpMultiFactorGenerator.FACTOR_ID, + ); + if (!hint) throw new Error('Unavailable second factor.'); + const assertion = TotpMultiFactorGenerator.assertionForSignIn(hint.uid, String(values.get('code')),); + await resolver.resolveSignIn(assertion); + } else { + await signInWithEmailAndPassword(auth, String(values.get('email')), String(values.get('password'))); + } + } catch (failure) { + if (active.current !== generation) return; + if (!resolver && (failure as MultiFactorError).code === 'auth/multi-factor-auth-required') { + setResolver(getMultiFactorResolver(auth, failure as MultiFactorError)); + } else setMessage('Sign-in was not completed. Try again or ask the staging owner.'); + } finally { + if (active.current === generation) { setBusy(false); inFlight.current = false; } + } + } + return ( +
+

Officer sign-in

+

Use only the disposable staging account supplied privately by the specialist.

+ {resolver ? ( + +) : ( + <> + + + + )} + + {resolver && } + {message &&

{message}

} +
+ ); +} + +export default function CommunityAdmin() { + const { services } = useServiceLocator(); + const { user, isLoading, signOut } = useAuth(); + const uid = user?.uid; + const functions = services?.firebaseResources.functions; + const enabled = COMMUNITY_ENABLED && functions?.app.options.projectId === 'run-mprc-staging'; + const [snapshot, setSnapshot] = useState<{ + uid: string; functions: unknown; value: AdministrationStatus; + } | null>(null); + const status = snapshot?.uid === uid && snapshot?.functions === functions + ? snapshot?.value : null; + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [editing, setEditing] = useState(null); + const generation = useRef(0); + const inFlight = useRef(false); + const pending = useRef(null); + const cursor = useRef(undefined); + useEffect(() => { + generation.current += 1; const {current} = generation; + inFlight.current = false; pending.current = null; cursor.current = undefined; + setSnapshot(null); setBusy(false); setMessage(''); setEditing(null); + if (enabled && uid && functions) { + callAdministration(functions, { action: 'admin_status', accountUid: uid }).then((value) => { + if (generation.current === current) setSnapshot({ uid, functions, value }); + }).catch(() => { if (generation.current === current) setMessage('Officer access is unavailable. It requires an assigned permission and a recent authenticator sign-in.'); }); + } + return () => { generation.current += 1; }; + }, [enabled, uid, functions]); + + async function perform(request?: Omit, refresh = false, nextCursor?: string) { + if (!enabled || !uid || !functions || inFlight.current) return; + if (request && typeof globalThis.crypto?.randomUUID !== 'function') { setMessage('Use an up-to-date browser to prepare a safe request.'); return; } + generation.current += 1; const {current} = generation; + inFlight.current = true; setBusy(true); setMessage(''); setEditing(null); + if (refresh) cursor.current = nextCursor; + let command = refresh ? null : pending.current; + if (request) { command = { + ...request, accountUid: uid, requestId: crypto.randomUUID(), reason: 'synthetic_staging_review' +}; } + if (command) pending.current = command; + try { + if (command) await callAdministration(functions, command); + if (generation.current !== current) return; + const value = await callAdministration(functions, { action: 'admin_status', accountUid: uid, ...(cursor.current ? { cursor: cursor.current } : {}) }); + if (generation.current === current) { setSnapshot({ uid, functions, value }); pending.current = null; setMessage(command ? 'Action confirmed. Current server status is shown.' : 'Status refreshed.'); } + } catch { + if (generation.current === current) { + setSnapshot(null); // Never keep privileged projections after a denial. + setMessage(command ? 'Action could not be confirmed. Retry the saved request; refresh before preparing a different action.' : 'Officer access is unavailable. Sign out and sign in with your authenticator, or ask the staging owner.'); + } + } finally { if (generation.current === current) { inFlight.current = false; setBusy(false); } } + } + function saveEvent(event: React.FormEvent) { + event.preventDefault(); const values = new FormData(event.currentTarget); + const draft: FreeEventDraft = { + title: String(values.get('title')).trim(), +location: String(values.get('location')).trim(), +capacity: Number(values.get('capacity')), + startAtMs: Date.parse(String(values.get('start'))), +opensAtMs: Date.parse(String(values.get('opens'))), +closesAtMs: Date.parse(String(values.get('closes'))), + }; + const valid = [draft.startAtMs, draft.opensAtMs, draft.closesAtMs].every(Number.isSafeInteger) + && draft.opensAtMs < draft.closesAtMs && draft.closesAtMs <= draft.startAtMs; + if (!valid) { setMessage('Signup must open before it closes, and close by the event start.'); return; } + if (!editing && typeof globalThis.crypto?.randomUUID !== 'function') { setMessage('Use an up-to-date browser.'); return; } + perform({ + action: editing ? 'admin_event_edit' : 'admin_event_create', eventId: editing?.id || `stage-${crypto.randomUUID()}`, expectedRevision: editing?.revision || 0, event: draft +}); + } + const localTime = (value?: number) => (value ? new Date(value - new Date(value).getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''); + const blocked = busy || pending.current !== null; + if (!enabled) return

Officer tools are unavailable here

; + return ( +
+ +

Officer community tools

+

Staging test only. Made-up members and free events only. Approval simulates a test term, not dues, payment or an Auth role.

+ Member signup and events + {isLoading &&

Loading account…

} + {!isLoading && !uid && } + {uid && ( + <> +

+ Permissions expire and are checked on every request. + Sign out and sign in again when your authenticator session is over ten minutes old. +

+ + {pending.current && } + + + )} + {message &&

{message}

} + {status?.capabilities.membershipApproval && ( +
+

Pending membership requests

+

+ Only synthetic test approval is available. The server supplies the original test term; + no annual policy or payment is inferred. +

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

No pending requests.

} + {status.pending.map((member) => ( +
+

+ Test account: + {' '} + {member.uid} +

+ +
+ ))} + {status.nextCursor && } + +
+ )} + {status?.capabilities.freeEventManagement && ( +
+

Manage free test events

+

+ Create starts closed. Open signup separately. Close stops new signups. + Cancel is final, but booked members can still cancel their own signup. + It sends no messages and issues no refunds. +

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

{event.title}

+

+ {event.status} + {' '} + · + {' '} + {event.confirmedCount} + {' '} + of + {' '} + {event.capacity} + {' '} + booked +

+ + + + +
{ e.preventDefault(); perform({ + action: 'admin_event_capacity', eventId: event.id, expectedRevision: event.revision, capacity: Number(new FormData(e.currentTarget).get('capacity')) +}); }} + > + + +
+
+ ))} +
+

{editing ? `Edit ${editing.title}` : 'Create free test event'}

+ + + + + + + + {editing && } +
+
+ )} +
+ ); +} diff --git a/src/services/community/communityService.test.ts b/src/services/community/communityService.test.ts index cce1b64..87904a6 100644 --- a/src/services/community/communityService.test.ts +++ b/src/services/community/communityService.test.ts @@ -16,9 +16,10 @@ test.each([['production', 'true'], ['staging', 'false'], ['staging', undefined]] 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'); + const { COMMUNITY_ENABLED, callCommunity, callAdministration } = await import('./communityService'); expect(COMMUNITY_ENABLED).toBe(false); await expect(callCommunity({ app: { options: { projectId: 'run-mprc-staging' } } } as any, { action: 'status', accountUid: 'synthetic' })).rejects.toThrow(); + await expect(callAdministration({ app: { options: { projectId: 'run-mprc-staging' } } } as any, { action: 'admin_status', accountUid: 'synthetic' })).rejects.toThrow(); },); test('a staging flag cannot admit a production Firebase project', async () => { process.env.REACT_APP_FIREBASE_ENVIRONMENT = 'staging'; process.env.REACT_APP_COMMUNITY_STAGING = 'true'; diff --git a/src/services/community/communityService.ts b/src/services/community/communityService.ts index ccbd3c7..b20b71b 100644 --- a/src/services/community/communityService.ts +++ b/src/services/community/communityService.ts @@ -30,3 +30,29 @@ export async function callCommunity(functions: Functions, request: CommunityRequ } return (await httpsCallable(functions, 'communityActions')(request)).data; } + +export interface FreeEventDraft { + title: string; location: string; startAtMs: number; opensAtMs: number; + closesAtMs: number; capacity: number; +} +export interface AdministrationStatus { + capabilities: { membershipApproval: boolean; freeEventManagement: boolean }; + pending: Array<{ uid: string; revision: number; applicationStatus: string }>; + nextCursor: string | null; + events: Array; +} +export interface AdministrationRequest { + action: 'admin_status' | 'admin_membership_approve' | 'admin_event_create' | 'admin_event_edit' + | 'admin_event_capacity' | 'admin_event_open' | 'admin_event_close' | 'admin_event_cancel'; + accountUid: string; requestId?: string; expectedRevision?: number; + reason?: 'synthetic_staging_review'; targetUid?: string; eventId?: string; + event?: FreeEventDraft; capacity?: number; cursor?: string; +} +export async function callAdministration(functions: Functions, request: AdministrationRequest) { + 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 037e6f1..e843216 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 = 124; +const EXPECTED_LINT_FILES = 126; 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 2e4f32d..c1d2876 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -366,6 +366,8 @@ describe('server-only operational collections', () => { 'communityMemberships/runner-a', 'communityEvents/test', 'communityEvents/test/registrations/runner-a', 'communityConfig/events', 'communityCommands/runner-a/requests/test', 'communityLimits/runner-a', + 'communityOfficers/runner-a', 'communityOfficerCommands/runner-a/requests/test', + 'communityOfficerLimits/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 }]) { diff --git a/tests/staging-community-release.test.js b/tests/staging-community-release.test.js index f39c495..5838941 100644 --- a/tests/staging-community-release.test.js +++ b/tests/staging-community-release.test.js @@ -2,6 +2,12 @@ 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('staging authenticator policy requires existing Identity Platform and no SMS provider', () => { + const { validateStagingMfa } = require('../scripts/staging-community-release'); + const config = { subtype: 'IDENTITY_PLATFORM', signIn: { email: { enabled: true } }, mfa: { state: 'ENABLED', providerConfigs: [{ state: 'ENABLED', totpProviderConfig: { adjacentIntervals: 1 } }] } }; + assert.doesNotThrow(() => validateStagingMfa(config)); + for (const invalid of [{ ...config, subtype: 'FIREBASE_AUTH' }, { ...config, mfa: { ...config.mfa, enabledProviders: ['PHONE_SMS'] } }, { ...config, mfa: { state: 'DISABLED' } }]) assert.throws(() => validateStagingMfa(invalid)); +}); test('owner provider calls name only the staging quota project and retain the host boundary', async () => { const { jsonRequest } = require('../scripts/staging-community-release'); @@ -22,7 +28,7 @@ test('bundle is deterministic, bounded and excludes all prototype commerce/PII s const root = path.resolve(__dirname, '..'); const a = createBundle(root); const b = createBundle(root); assert.equal(a.digest, b.digest); assert.ok(a.zip.length < 1_000_000); - assert.deepEqual(FILES, ['communityOnly.js', 'communityActions.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']); + assert.deepEqual(FILES, ['communityOnly.js', 'communityActions.js', 'communityAdministration.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', () => {