From 95860e2b8c31897496e4d5b1cb2f4942ae7c6411 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 12 Oct 2026 13:28:25 -0700 Subject: [PATCH] feat: record fictional external dues in isolated staging --- GITHUB_ISSUES.md | 2 + IMPLEMENTATION_PLAN.md | 2 + OFFICER_START_HERE.md | 2 + OPERATIONS_RUNBOOK.md | 2 + SECURITY.md | 2 + STRIPE_COMMERCE_DESIGN.md | 2 + SYSTEM_DESIGN.md | 6 +- docs/officers/ACCESS_CONTINUITY.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 4 + docs/officers/GLOSSARY.md | 2 + docs/officers/README.md | 2 + docs/officers/STAGING_COMMUNITY.md | 27 ++++ docs/officers/SYSTEM_MAPS.md | 15 +++ functions/communityActions.appCheck.test.js | 8 ++ functions/communityActions.emulator.test.js | 27 +++- functions/communityAdministration.js | 54 ++++++-- functions/communityDues.js | 72 +++++++++++ functions/communityDues.test.js | 86 +++++++++++++ functions/membershipManualEvidence.js | 5 +- scripts/staging-community-release.js | 2 +- src/pages/community/CommunityAdmin.test.tsx | 46 +++++++ src/pages/community/CommunityAdmin.tsx | 117 +++++++++++++++++- src/services/community/communityService.ts | 13 +- tests/firestore-rules/shop-and-locked.test.js | 1 + tests/staging-community-release.test.js | 2 +- 25 files changed, 479 insertions(+), 24 deletions(-) create mode 100644 functions/communityDues.js create mode 100644 functions/communityDues.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index b9b07170..661abe4d 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**COMMUNITY-DUES-001 / #716 — source prepared, NOT AVAILABLE YET:** owner-selected fictional external dues on the existing isolated staging service. Separate exact v2 capability, server-supplied fake amount/method/short term, confirmation UUID/global duplicate fence, immutable command/audit and unlinked test authority; twenty minimal receipts per page. No real policy, account association/access, claims, provider/payment, migration, IAM expansion, service/schedule/dependency or production change. Officer impact: supervised fictional recording only. Officer documentation: STAGING_COMMUNITY, entrypoints, Events-Shop-Members, glossary and map. Deployment evidence: must separately verify exact tests/CI/merge, native Function/archive/limits/security, staging Hosting/UI/denials/retries/race/cleanup; source is not live proof. Broad #115/#81 and real club launch remain separate; native #114's closed status is not readiness proof. + **COMMUNITY-003 / #711, staging recovery and handoff verified 2026-10-12:** 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: supervised disposable staging approval/event review. Officer documentation: STAGING_COMMUNITY, entrypoints and maps. Deployment evidence: Function version 2/native archive/spec/IAM/MFA and Hosting source `57261ae` verified; operator/negative/race/retry/phone/offline/natural-expiry proof and exact owned cleanup recorded in the [dated receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/711#issuecomment-6117987215). Natural expiry, denied old session, fresh native TOTP/App Check recovery, actual UI restoration, final no-terminal handoff and exact owned cleanup passed on 2026-10-12. #711 has its final bounded staging acceptance; #114/#115/AUTH-003/ADMIN-001 and ordinary production access remain separate and 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. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1a4b0355..8a2a04b6 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 #716 fictional external dues — source prepared, hosted proof pending:** consume completed #395/#208/#451 contracts and #711's hosted service/MFA foundation. Record one immutable externally-confirmed test receipt for an unlinked membership; no account association or entitlement. Separate v2 dues capability, server-owned fake policy, global confirmation uniqueness, immutable retry and bounded no-profile review; no new IAM/service/dependency/schedule. Owner chose fictional values, not actual dues policy. Gate source/positive-negative/retry/race tests, exact PR/main checks/merge, fixed staging Function/Hosting readback, genuine native MFA/App Check/UI/offline/cleanup and the no-terminal handoff independently. Broad #81/#115, real policy/association/renewal and production remain separate. Native #114 closed status is not proof those gates are complete. + **Bounded community slice — #711, staging recovery and handoff verified 2026-10-12:** synthetic membership approval and free-event management use 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 passed hosted checks, along with phone/offline behavior and natural sign-in expiry. 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: supervised disposable admin review only. Officer documentation: STAGING_COMMUNITY, entrypoints and maps. Deployment evidence: #711 separately records exact source/CI/merge, native Function version 2/archive/spec/IAM/MFA, Hosting source `57261ae`, real browser tests and exact owned cleanup. Natural expiry and sign-out/fresh native TOTP recovery, renewed genuine browser attestation, cleared/restored privileged UI and the no-terminal handoff passed on 2026-10-12. Record the final dated acceptance and documentation merge before closing #711. Ordinary officer access and production are not available. ### Phase 0 — Stop accidental harm and establish a trustworthy baseline diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 26a54a5f..8e136570 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 +**Fictional external dues — #716, NOT AVAILABLE YET:** a separate staging-only permission records made-up off-platform dues for an unlinked test membership, with no money movement, account access or Auth role. Dave selected fictional amounts/methods/short terms, not the club's actual dues policy. The [short dues review](./docs/officers/STAGING_COMMUNITY.md#fictional-external-dues--716) requires specialist-supplied fixtures and separate hosted proof. Production/real dues/association remain unavailable; no officer uses a terminal, bank reference or real member detail. + **Officer staging controls — #711, staging recovery and handoff verified 2026-10-12:** synthetic membership approval and made-up free-event management passed hosted checks using two narrow expiring permissions and authenticator sign-in, not the broad admin role. Use the [supervised staging checklist](./docs/officers/STAGING_COMMUNITY.md#officer-review--711-staging-verified) only with new specialist-supplied disposable fixtures. After the ten-minute limit, sign out and sign in again with a fresh authenticator code; this recovery and the no-terminal handoff passed on 2026-10-12. A denied refresh hides the protected sections. Ordinary officer access and recovery without the authenticator are **NOT AVAILABLE YET**. Do not use Gmail credentials for the website test. 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**. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 4495bf1d..cc48d2c0 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. +**#716 fictional dues — NOT AVAILABLE YET; source prepared:** the specialist supplies only disposable MFA identity, an expiring v2 dues capability, a fictional UUID confirmation and exact ten-field server test policy. Reuse the one community service and its existing six-permission runtime; never widen a browser role or use a real payment/member. Follow the [dues staging handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-external-dues--716) only after exact native Function/Hosting/security/UI/cleanup proof. Recording leaves the term unlinked; account association/activation and corrections/refunds are unavailable. Disable the capability to stop both new commands and saved retries, then use an exact reviewed staging rollback; v1 capabilities remain compatible. No automatic expiry/deletion job or new service is added. Provider setup/configuration, fixture cleanup and rollback remain specialist-only. Real policy and production remain on hold. + **#711 officer community release — staging recovery and handoff verified 2026-10-12:** use the no-terminal [staging officer checklist](./docs/officers/STAGING_COMMUNITY.md#officer-review--711-staging-verified). Dave authorizes only staging under `runmprc@gmail.com`. Function version 2/native archive/spec/IAM and Hosting source `57261ae` are verified, with genuine Auth/App Check/MFA, permission denials, concurrency/retry, phone/offline, natural sign-in expiry and exact owned cleanup recorded in #711. Natural expiry and sign-out/fresh native TOTP recovery, renewed genuine browser App Check and final no-terminal handoff passed on 2026-10-12. Ordinary officer access, lost-authenticator recovery and production remain NOT AVAILABLE YET. The fixed release command reported an unexplained failure after provider completion; independent native checks, Hosting's source gate and later read-only postchecks passed. No unchanged redeploy was performed. Optional TOTP (one adjacent interval) is enabled on already-upgraded staging Identity Platform, preserving email/password settings/domains; no SMS or billing upgrade. Runtime gained only bounded Firestore list and target Auth read. 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 diff --git a/SECURITY.md b/SECURITY.md index cfd86850..9044de55 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 +**#716 fictional dues source boundary — staging only, hosted proof pending:** `duesRecording` is separate from approval/event permissions and exists only in exact seven-field capability schema v2; v1 never acquires it. Native verified TOTP within ten minutes and native App Check remain mandatory. Every transaction reads current capability before immutable retry replay. Server-only synthetic policy fixes amount/currency/method/term; request accepts only a UUID confirmation, true test attestation, zero create revision and expected policy revision. A global server-hashed confirmation key prevents cross-officer duplicates; no arbitrary evidence/notes, bank refs, profiles, email, uploads or export. Receipt terms remain unlinked and cannot grant entitlement/claims. New collections are denied by existing default Rules, including browser admins. No new runtime permission, production fallback, provider call, real payment evidence or schema migration. #716 must separately verify native deployment/denials/UI/cleanup before availability. + **#711 synthetic staging boundary (hosted proof and expired-session recovery verified 2026-10-12):** 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. Native version 2/Hosting source `57261ae`, capability/identity denial matrix, race/retry/phone/offline checks, natural ten-minute expiry and exact owned cleanup are recorded in #711. Auth and Firestore App Check remain ENFORCED. Natural expiry, cleared privileged UI and fresh native TOTP sign-in recovery passed on 2026-10-12, with independently renewed genuine browser App Check and native server acceptance. The no-terminal handoff and exact disposable cleanup passed; this is not lost-authenticator recovery or ordinary production access. 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. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 4507773e..acb5ce19 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 +**#716 is fictional external-dues recording, not payment processing:** server-approved synthetic amount/method/short term and an officer's test attestation produce an unlinked test receipt. No Stripe identifiers/state, charge, refund, bank credential, real dues policy, payment-provider reconciliation or account activation. Real plan/term/method/confirming-officer policy stays unset. Production and real payment blockers remain unchanged; staged source availability does not imply hosted or financial proof. + **#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. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index e2321f9a..69741b48 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 +**Fictional external dues — #716, staging source prepared; NOT AVAILABLE YET:** Dave chose fictional test amounts/methods/short terms on October 12. The existing isolated service gains only `admin_dues_record` and a bounded receipt projection. A separate expiring `duesRecording` permission in exact capability schema v2 is required; v1 retains its six-field behavior and denies this action. Server-only `communityConfig/duesTestTerm` supplies the fictional amount/currency/method/term/revision. One confirmation UUID creates a stable non-email membership ID, an unlinked authority/test term via the existing manual-evidence contract and an immutable `communityDues` receipt plus command audit. No UID association, entitlement/claim grant, member-record edit, Stripe/payment state, real policy, migration or provider action. Same-command retry is immutable; global confirmation uniqueness prevents another command/officer double-recording it. Only twenty minimal receipts per page; no profile/contact/bank data or exports. Runtime/IAM/cost limits are unchanged. Source/tests/merge, native deployment/Hosting/attestation/UI and exact owned cleanup require separate #716 proof; production remains unavailable. + **Staging officer extension — #711, hosted checks and expired-session recovery verified 2026-10-12:** 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. Function version 2 and Hosting source `57261ae`, native Auth/App Check/MFA, capability/race/retry/phone/offline/real-clock expiry and owned cleanup are recorded in #711 and the [staging guide](./docs/officers/STAGING_COMMUNITY.md). On 2026-10-12, natural ten-minute expiry denied the unchanged session; sign-out and fresh native TOTP sign-in restored the actual officer view, and a new genuine native App Check token passed the same server boundary. The no-terminal handoff and exact disposable cleanup passed. This is supervised synthetic staging only; ordinary officer access, lost-authenticator recovery and production are NOT AVAILABLE YET. **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. The first release's separate runtime had exactly Firestore transaction/get/create/update permissions at staging-project scope; #711 adds only list and read-only Auth lookup as described above. 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. This original slice included no role/dues activation, Stripe/mail, officer admin endpoint, schedule or production release. 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. @@ -811,7 +813,9 @@ This contract does not verify a person, payment, plan, evidence item, refund, di The module is imported by no runtime or Functions index. It reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, changes no current profile/role/claim, and cannot make #81, annual renewal, discounts, roster export, or officer membership tools available. Source tests and a merge are not Firebase deployment or live behavior proof. -### 8.0b Officer manual off-platform dues-evidence command — SOURCE ONLY, UNUSED +### 8.0b Officer manual off-platform dues-evidence command — PRODUCTION UNUSED + +**#716 isolated staging adoption prepared:** the pure contract below is now imported only by `communityDues.js` for explicitly fictional, unlinked test terms. Its old source-only statements are historical outside that one staging path. No production authority, payment policy or account association is enabled; hosted availability requires #716's separate deployment/UI/cleanup evidence. MEMBERS-ADMIN-001A [#395](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/395) defines one unused pure contract for the first MEMBERS-ADMIN-001 [#115](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/115) behavior: recording an owner-approved off-platform dues confirmation for a membership term without creating or claiming a Stripe payment. It turns one exact officer-audit envelope into the exact approved `record_term_decision` command the §8.0a reducer already accepts, plus a paired immutable audit record stamped with an off-platform provenance. It decides no policy; every owner-meaningful value is carried through as an opaque token. diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index bd7026a6..4b0afa59 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -6,6 +6,8 @@ ## Sole-owner backend exception — approved October 11, 2026 +**Fictional dues permission — #716 source prepared, NOT AVAILABLE YET:** only Dave/platform specialist supplies an explicitly disposable staging officer and separate expiring dues-recording permission for the [supervised test](./STAGING_COMMUNITY.md#fictional-external-dues--716). Existing approval/event permissions do not gain this access. No ordinary officer, shared Gmail password or browser admin role can grant it. Test configuration/enrollment, revocation, exact cleanup and rollback remain specialist-only. Stop and contact Dave privately if access is absent or survives revocation; do not edit records or share passwords/codes. This changes no backup appointment, real officer authority or production permission. + **Purpose:** allow backend setup and protected releases without appointing a backup. **Approver:** Dave Liu (`daliu`), the sole backend release/recovery owner. **Status:** owner decision approved under [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697). Backup coverage remains **INCOMPLETE**. The general backend release remains **NOT AVAILABLE YET** until #113/#133/#136 supply their other proof. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 47f21c85..d4f220b7 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -10,6 +10,10 @@ The repository contains screens for these jobs. Their presence does not prove th There is currently no proven no-code switch that safely stops all new Stripe payments. Hiding a button, closing an event, or marking a product sold out may leave an already-created Stripe Checkout page payable. +## Fictional external dues — #716, NOT AVAILABLE YET + +Dave approved fictional amounts/methods/short terms for this staging-only recording test, not real dues rules. Use the [short supervised guide](./STAGING_COMMUNITY.md#fictional-external-dues--716) only after its separate hosted acceptance. It records an unlinked test term, moves no money and grants no account access or role. Real club prices/terms/methods/confirmers, deliberate account association, renewal, corrections and refunds need separate approved work. The specialist handles fixtures/permissions/provider proof/cleanup; an officer does not change a role, record or payment directly. + ## Membership cancellation — POLICY APPROVED, NOT AVAILABLE YET [#707](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/707) verified the separate [staging-only request and free-event review](./STAGING_COMMUNITY.md) on 2026-10-11. Fresh disposable fixtures still require the specialist. This does not launch production membership, grant a paid term, or repair paid race cancellation. diff --git a/docs/officers/GLOSSARY.md b/docs/officers/GLOSSARY.md index 05b522d4..c74ed5ef 100644 --- a/docs/officers/GLOSSARY.md +++ b/docs/officers/GLOSSARY.md @@ -2,6 +2,8 @@ | Word | Plain meaning | | --- | --- | +| Fictional confirmation ID (UUID) | A long random test code supplied by the specialist. Reusing it cannot record the same made-up payment twice. It is not a bank reference or evidence of real money. | +| Unlinked test membership | A made-up term and receipt not connected to any account. It grants no account access or role. | | AI assistant | A tool that can read instructions and help prepare a change. It still needs review and approval. | | GitHub | The service that stores the website's source and change history. | | Repository | The MPRC website project stored in GitHub. | diff --git a/docs/officers/README.md b/docs/officers/README.md index d256b65f..921baf87 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 +[#716 fictional external-dues recording](./STAGING_COMMUNITY.md#fictional-external-dues--716) is source prepared, **NOT AVAILABLE YET**. It keeps test receipts unlinked and moves no money. A separate permission and specialist-provided fake configuration are required; source/green checks do not prove deployment or availability. + Core staging officer approval/free-event management checks passed on 2026-10-11 under [#711](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/711). Expired-session recovery and the no-terminal handoff passed on 2026-10-12: sign out, sign in again and enter a fresh authenticator code. Follow the [supervised staging checklist](./STAGING_COMMUNITY.md#officer-review--711-staging-verified) only with new specialist-supplied disposable fixtures; ordinary officer access, real dues and production remain **NOT AVAILABLE YET**. 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. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 8e3d8ada..68458508 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -51,6 +51,33 @@ The specialist's Hosting release uses Dave's existing club login for a short-liv ## Cost and scope +## Fictional external dues — #716 + +**Status:** SOURCE PREPARED — **NOT AVAILABLE YET** until #716 records native staging deployment, website and disposable-test acceptance. Real club rules remain unset. Recording does not associate an account, grant access/roles or move money. + +**Purpose:** rehearse one fictional externally confirmed dues receipt and safe retry. **Approver:** Dave, sole staging owner. **Prerequisites:** the specialist supplies the reviewed staging link, disposable MFA officer, separate expiring dues permission, fictional confirmation ID and made-up amount/method/short term. No real details or bank references. + +1. Open the supplied staging `/community/admin` link and confirm **Staging test only**. +2. Sign in privately with the disposable account and its authenticator code. +3. Find **Record fictional external dues**. If absent, stop and ask the specialist; do not change a role. +4. Check the server-displayed test amount and term against the specialist's fictional example. +5. Enter only the supplied **Fictional confirmation ID (UUID)**. This random test code is not a bank reference. +6. Select the checkbox confirming this is a fictional test, not real money. +7. Select **Record fictional dues**. +8. Confirm one test membership appears with **Unlinked; no account access granted**. +9. If the result is uncertain, select **Retry saved action**. Do not prepare a new confirmation to work around an uncertain result. +10. Use **Refresh officer status** to read the current result. Use **Next dues receipts** / **First dues receipts** for bounded review. +11. If the ten-minute sign-in expires, follow **Recover an expired officer session** below. A revoked/expired permission cannot be restored by signing in again. +12. Sign out and ask the specialist to remove the owned fixtures. + +**Expected result:** one immutable fictional external confirmation for an unlinked test term. Same-command retries return the original result; another command using that confirmation is rejected. No account, profile, role, entitlement, real payment, email or Stripe state changes. + +**Stop:** production address, real data/money/reference, missing authenticator/dues permission, changed server amount/term, duplicate confirmation, unexplained result, or any request for secrets/record edits. Stop new actions and tell Dave/platform specialist privately. + +**Success proof:** #716 separately records exact source/tests/CI/merge, native Function archive/limits/security and staging Hosting, genuine Auth/App Check/TOTP denials, immutable retry/cross-officer duplicate safety, actual UI/offline/phone checks, and exact owned cleanup. Green checks alone are not live proof. **Undo:** sign out and stop; Dave/specialist disables the staged capability first, verifies saved requests denied, then uses the reviewed staging rollback if needed. Recorded receipts have no edit/refund/correction button; do not alter one yourself. **Escalation:** Dave/platform specialist. Test enrollment/configuration/permission supply and cleanup remain specialist-only; no officer uses a terminal or handles a bank credential. + +**Cost and compatibility:** same one on-demand service, six runtime permissions, min0/max2/256MB/30s, no new service/dependency/polling/scheduler. Existing six-field v1 officer permissions continue working but never gain dues recording. New exact seven-field v2 includes the separate dues permission. New server-only receipt collection is additive; no migration. These limits and warning budgets are not a bill ceiling. + diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 22a8334c..ad63f47b 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -2,6 +2,21 @@ These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. +## Fictional external dues — #716, source prepared; NOT AVAILABLE YET + +```mermaid +flowchart LR + Officer["Disposable staging officer; recent TOTP + native App Check"] --> Permission["Separate live expiring dues permission"] + Permission --> Service["Same bounded communityActions service"] + Policy["Server-owned fictional amount/method/short term"] --> Service + Service --> Receipt["One global confirmation key; immutable unlinked test term/receipt"] + Receipt --> View["Twenty minimal receipts per page; no profiles"] + Service --> Audit["Actor/permission/reason/result; saved retry"] + Browser["Browser including admin"] -. "Direct writes denied" .-> Receipt +``` + +Text alternative: a recent authenticated officer with a separate expiring dues permission uses the existing service and server-owned fictional policy to record one globally unique, immutable unlinked test receipt and audit, with bounded review and no direct browser write, account access or money movement. Native deployment/UI/cleanup proof is still required. + ## Staging community lifecycle — #707, first hosted release verified 2026-10-11 Core hosted staging checks for the #711 officer extension below passed on 2026-10-11. Function version 2 and Hosting source `57261ae` are recorded in the [staging guide](./STAGING_COMMUNITY.md#officer-review--711-staging-verified). Expired-session recovery and the no-terminal handoff passed on 2026-10-12; ordinary officer access, lost-authenticator recovery and production are **NOT AVAILABLE YET**. diff --git a/functions/communityActions.appCheck.test.js b/functions/communityActions.appCheck.test.js index 04927f7e..f9071e41 100644 --- a/functions/communityActions.appCheck.test.js +++ b/functions/communityActions.appCheck.test.js @@ -62,4 +62,12 @@ describe('native authenticated boundary and context binding', () => { expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { action: 'admin_status', accountUid: 'synthetic-uid' })).status).toBe(403); expect(admin.firestore).not.toHaveBeenCalled(); }); + const dues = { action: 'admin_dues_record', accountUid: 'synthetic-uid', requestId: '123e4567-e89b-42d3-a456-426614174000', expectedRevision: 0, reason: 'synthetic_staging_review', confirmationId: '123e4567-e89b-42d3-a456-426614174001', externalConfirmed: true, policyRevision: 1 }; + test('dues native boundary requires App Check, bound UID and recent MFA before business work', async () => { + expect((await request({ authorization: 'Bearer synthetic-auth' }, dues)).status).toBe(401); + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { ...dues, accountUid: 'other' })).status).toBe(403); + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, dues)).status).toBe(403); + expect((await request({ ...app, authorization: 'Bearer synthetic-unverified-auth' }, dues)).status).toBe(403); + expect(admin.firestore).not.toHaveBeenCalled(); + }); }); diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js index afac0711..b63cf97e 100644 --- a/functions/communityActions.emulator.test.js +++ b/functions/communityActions.emulator.test.js @@ -7,7 +7,7 @@ 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', 'communityOfficers', 'communityOfficerCommands', 'communityOfficerLimits']; + const roots = ['communityEvents', 'communityMemberships', 'communityConfig', 'communityCommands', 'communityLimits', 'communityOfficers', 'communityOfficerCommands', 'communityOfficerLimits', 'communityDues']; 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(() => { @@ -75,4 +75,29 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', await db.doc(`communityOfficers/${uid}`).update({ enabled: false, revision: 2 }); await expect(officer.execute(uid, token, close)).rejects.toHaveProperty('code', 'permission-denied'); }, 45000); + test('cross-officer dues uniqueness, immutable retry and revocation use real Firestore transactions', async () => { + const policy = { schemaVersion: 1, syntheticOnly: true, policyVersion: 'fictional-external-dues-v1', revision: 1, amountCents: 100, currency: 'USD', method: 'fictional_external', termId: 'fictional-term', startsAtMs: now - 1000, endsAtMs: now + 3600000 }; + await db.doc('communityConfig/duesTestTerm').create(policy); + const auth = { getUser: jest.fn() }; + const officer = createAdministrationService(db, auth, { memberView, eventView }, () => now); + const token = { email_verified: true, auth_time: Math.floor(now / 1000), firebase: { sign_in_second_factor: 'totp' } }; + const confirmationId = randomUUID(); const requests = []; + for (let n = 0; n < 6; n++) { + const uid = `dues-officer-${n}`; + await db.doc(`communityOfficers/${uid}`).create({ schemaVersion: 2, enabled: true, revision: 1, expiresAtMs: now + 3600000, membershipApproval: false, freeEventManagement: false, duesRecording: true }); + requests.push(parseAdminRequest({ action: 'admin_dues_record', accountUid: uid, requestId: randomUUID(), expectedRevision: 0, reason: 'synthetic_staging_review', confirmationId, externalConfirmed: true, policyRevision: 1 })); + } + const result = await Promise.allSettled(requests.map((request) => officer.execute(request.accountUid, token, request))); + const winners = result.flatMap((value, index) => value.status === 'fulfilled' ? [index] : []); + expect(winners).toHaveLength(1); + const request = requests[winners[0]]; const uid = request.accountUid; + const first = result[winners[0]].value; + expect(await officer.execute(uid, token, request)).toEqual(first); + const receipts = await db.collection('communityDues').get(); expect(receipts.size).toBe(1); + expect(receipts.docs[0].data().authority.association).toEqual({ state: 'unlinked', uid: null, revision: 0 }); + expect((await db.collection('communityMemberships').get()).empty).toBe(true); + expect(auth.getUser).not.toHaveBeenCalled(); + await db.doc(`communityOfficers/${uid}`).update({ enabled: false, revision: 2 }); + await expect(officer.execute(uid, token, request)).rejects.toHaveProperty('code', 'permission-denied'); + }, 45000); }); diff --git a/functions/communityAdministration.js b/functions/communityAdministration.js index 9d5e8fe9..adea75cb 100644 --- a/functions/communityAdministration.js +++ b/functions/communityAdministration.js @@ -6,6 +6,7 @@ const functions = require('firebase-functions'); const { createHash } = require('node:crypto'); const { parseStrictObject } = require('./requestValidation'); const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('./membershipAuthority'); +const { readDuesPolicy, createDuesReceipt, duesView, duesId } = require('./communityDues'); 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']); @@ -18,18 +19,21 @@ const text = (value) => typeof value === 'string' && value.trim() === value && v function parseAdminRequest(input) { try { - const value = parseStrictObject(input, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['requestId', 'expectedRevision', 'targetUid', 'eventId', 'event', 'capacity', 'reason', 'cursor'] }); + const value = parseStrictObject(input, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['requestId', 'expectedRevision', 'targetUid', 'eventId', 'event', 'capacity', 'reason', 'cursor', 'duesCursor', 'confirmationId', 'externalConfirmed', 'policyRevision'] }); 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'); + parseStrictObject(value, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['cursor', 'duesCursor'] }); + check((value.cursor === undefined || identifier(value.cursor)) && (value.duesCursor === undefined || identifier(value.duesCursor)), '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'] : []; + const dues = value.action === 'admin_dues_record'; + check(dues || value.action === 'admin_membership_approve' || EVENTS.has(value.action), 'invalid-argument'); + const target = dues ? 'confirmationId' : value.action === 'admin_membership_approve' ? 'targetUid' : 'eventId'; + const extra = dues ? ['externalConfirmed', 'policyRevision'] : ['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 (dues) check(UUID.test(value.confirmationId) && value.externalConfirmed === true + && value.expectedRevision === 0 && integer(value.policyRevision) && value.policyRevision > 0, 'invalid-argument'); if (extra.includes('event')) { parseStrictObject(value.event, { requiredKeys: ['title', 'location', 'startAtMs', 'opensAtMs', 'closesAtMs', 'capacity'], optionalKeys: [] }); check(text(value.event.title) && text(value.event.location) @@ -48,11 +52,14 @@ function assertOfficerToken(token, now) { && 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) + const v2 = record?.schemaVersion === 2; + check((record?.schemaVersion === 1 || v2) && 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 }; + && Object.keys(record).length === (v2 ? 7 : 6) + && (!v2 || typeof record.duesRecording === 'boolean'), 'permission-denied'); + return { membershipApproval: record.membershipApproval, freeEventManagement: record.freeEventManagement, + ...(v2 ? { duesRecording: record.duesRecording } : {}) }; } function manifestIds(record) { const ids = record ? record.eventIds : []; @@ -99,9 +106,20 @@ function createAdministrationService(db, auth, views, clock = Date.now) { 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 })); } + if (caps.duesRecording) { + const policy = await tx.get(db.doc('communityConfig/duesTestTerm')); + let query = db.collection('communityDues').orderBy('__name__').limit(21); + if (request.duesCursor) query = query.startAfter(request.duesCursor); + const records = await tx.get(query); + const dues = records.docs.slice(0, 20).map((snapshot) => duesView(snapshot.data(), snapshot.id)); + const duesPolicy = policy.exists ? readDuesPolicy(policy.data()) : null; + return { capabilities: caps, pending, nextCursor, events, dues, duesPolicy, + duesRecordingOpen: !!duesPolicy && duesPolicy.startsAtMs <= now && duesPolicy.endsAtMs > now, + nextDuesCursor: records.docs.length > 20 ? dues[dues.length - 1].membershipId : null }; + } return { capabilities: caps, pending, nextCursor, events }; } - const required = request.action === 'admin_membership_approve' ? 'membershipApproval' : 'freeEventManagement'; + const required = request.action === 'admin_dues_record' ? 'duesRecording' : 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}`); @@ -113,7 +131,15 @@ function createAdministrationService(db, auth, views, clock = Date.now) { 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') { + if (required === 'duesRecording') { + const ref = db.doc(`communityDues/${duesId(request.confirmationId)}`); + const [snapshot, policy] = await tx.getAll(ref, db.doc('communityConfig/duesTestTerm')); + check(!snapshot.exists); // Global confirmation uniqueness, including other officers. + const receipt = createDuesReceipt(uid, capability.data(), token, request, policy.data(), now); + const dues = duesView(receipt, ref.id); + before = { recorded: false }; after = { recorded: true, membershipId: receipt.membershipId, linked: false }; + writes.push([ref, receipt, false]); result = { dues, syntheticOnly: true }; + } else 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); @@ -159,9 +185,13 @@ function createAdministrationService(db, auth, views, clock = Date.now) { writes.push([ref, event, snapshot.exists]); result = { event: { id: request.eventId, ...after } }; } assertOfficerToken(token, clock()); readCapabilities(capability.data(), clock()); + if (required === 'duesRecording') { + const policy = writes[0][1].policy; + check(policy.startsAtMs <= clock() && policy.endsAtMs > 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 }); + tx.create(commandRef, { schemaVersion: 1, fingerprint, actorUid: uid, capability: required, capabilityRevision: capability.data().revision, action: request.action, targetId: request.action === 'admin_dues_record' ? after.membershipId : request.targetUid || request.eventId, reason: request.reason, before, after, result, createdAtMs: now }); return result; }); } diff --git a/functions/communityDues.js b/functions/communityDues.js new file mode 100644 index 00000000..65b20b71 --- /dev/null +++ b/functions/communityDues.js @@ -0,0 +1,72 @@ +'use strict'; + +// Synthetic staging receipts only. No UID association, claim or provider call. +const functions = require('firebase-functions'); +const { createHash } = require('node:crypto'); +const { parseStrictObject } = require('./requestValidation'); +const { createMembershipAuthority, applyMembershipAuthorityCommand, deriveMembershipEntitlement } = require('./membershipAuthority'); +const { projectManualDuesEvidence } = require('./membershipManualEvidence'); +const POLICY = 'fictional-external-dues-v1'; +const fail = () => { throw new functions.https.HttpsError('failed-precondition', 'Fictional dues recording unavailable. Refresh the staging status.'); }; +const check = (value) => { if (!value) fail(); }; +const hash = (value) => createHash('sha256').update(value).digest('hex'); +const integer = (value, max) => Number.isSafeInteger(value) && value >= 0 && value <= max; +const duesId = (confirmationId) => `stage_dues_${hash(confirmationId)}`; + +function readDuesPolicy(value) { + try { + parseStrictObject(value, { requiredKeys: ['schemaVersion', 'syntheticOnly', 'policyVersion', 'revision', 'amountCents', 'currency', 'method', 'termId', 'startsAtMs', 'endsAtMs'], optionalKeys: [] }); + check(value.schemaVersion === 1 && value.syntheticOnly === true && value.policyVersion === POLICY + && integer(value.revision, 1000000) && value.revision > 0 + && integer(value.amountCents, 10000) && value.amountCents > 0 + && value.currency === 'USD' && value.method === 'fictional_external' + && typeof value.termId === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value.termId) + && integer(value.startsAtMs, 8640000000000000) && integer(value.endsAtMs, 8640000000000000) + && value.startsAtMs < value.endsAtMs && value.endsAtMs - value.startsAtMs <= 86400000); + return value; + } catch { fail(); } +} + +function createDuesReceipt(actorUid, capability, token, request, input, now) { + const policy = readDuesPolicy(input); + check(policy.revision === request.policyRevision && policy.startsAtMs <= now && policy.endsAtMs > now); + const membershipId = duesId(request.confirmationId); + const evidenceRef = `evidence_${hash(request.confirmationId)}`; + const authority = createMembershipAuthority({ membershipAuthoritySchemaVersion: 1, membershipId, commandId: `create_${request.requestId}` }); + const projection = projectManualDuesEvidence({ + membershipManualEvidenceSchemaVersion: 1, commandId: request.requestId, + actorRef: `actor_${hash(actorUid)}`, capabilityRef: `cap_${hash(`${actorUid}:${capability.revision}`)}`, + recentAuthRef: `auth_${hash(`${actorUid}:${token.auth_time}`)}`, + membershipId, evidenceCategoryRef: 'fictional_external', evidenceRef, + reasonRef: 'synthetic_staging_review', correlationRef: request.requestId, + expectedRevision: 1, termRevision: 1, termId: policy.termId, + startsAtMs: policy.startsAtMs, endsAtMs: policy.endsAtMs, + planRef: 'fictional_dues_only', policyVersion: POLICY, + }); + return { + schemaVersion: 1, syntheticOnly: true, membershipId, + confirmationHash: hash(request.confirmationId), policy, + authority: applyMembershipAuthorityCommand(authority, projection.reducerCommand), + audit: projection.auditRecord, createdAtMs: now, + }; +} + +function duesView(value, id) { + try { + parseStrictObject(value, { requiredKeys: ['schemaVersion', 'syntheticOnly', 'membershipId', 'confirmationHash', 'policy', 'authority', 'audit', 'createdAtMs'], optionalKeys: [] }); + const policy = readDuesPolicy(value.policy); + check(value.schemaVersion === 1 && value.syntheticOnly === true && value.membershipId === id + && /^[a-f0-9]{64}$/.test(value.confirmationHash) && id === `stage_dues_${value.confirmationHash}` + && integer(value.createdAtMs, 8640000000000000) + && value.authority.membershipId === id && value.authority.association.state === 'unlinked' + && value.authority.association.uid === null && value.authority.term.state === 'approved' + && value.authority.term.termId === policy.termId && value.authority.term.startsAtMs === policy.startsAtMs + && value.authority.term.endsAtMs === policy.endsAtMs && value.authority.term.policyVersion === POLICY + && value.audit.provenance === 'manual_off_platform' && value.audit.membershipId === id + && value.audit.evidenceRef === value.authority.term.evidenceRef); + check(deriveMembershipEntitlement({ membershipAuthoritySchemaVersion: 1, record: value.authority, uid: 'staging_unlinked', asOfMs: value.createdAtMs }).entitlement === 'not_entitled'); + return { membershipId: id, amountCents: policy.amountCents, currency: policy.currency, method: policy.method, + termId: policy.termId, startsAtMs: policy.startsAtMs, endsAtMs: policy.endsAtMs, linked: false }; + } catch { fail(); } +} +module.exports = { readDuesPolicy, createDuesReceipt, duesView, duesId }; diff --git a/functions/communityDues.test.js b/functions/communityDues.test.js new file mode 100644 index 00000000..8f933718 --- /dev/null +++ b/functions/communityDues.test.js @@ -0,0 +1,86 @@ +const { randomUUID } = require('node:crypto'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const { memberView, eventView } = require('./communityActions'); +const { parseAdminRequest, readCapabilities, createAdministrationService } = require('./communityAdministration'); +const { readDuesPolicy, duesId } = require('./communityDues'); +const now = 1800000000000; +const policy = { schemaVersion: 1, syntheticOnly: true, policyVersion: 'fictional-external-dues-v1', revision: 1, amountCents: 100, currency: 'USD', method: 'fictional_external', termId: 'fictional-term', startsAtMs: now - 1000, endsAtMs: now + 60000 }; +const caps = { schemaVersion: 2, enabled: true, revision: 1, expiresAtMs: now + 3600000, membershipApproval: false, freeEventManagement: false, duesRecording: true }; +const token = { email_verified: true, auth_time: now / 1000, firebase: { sign_in_second_factor: 'totp' } }; +const command = (extra = {}) => parseAdminRequest({ action: 'admin_dues_record', accountUid: 'officer', requestId: randomUUID(), expectedRevision: 0, reason: 'synthetic_staging_review', confirmationId: randomUUID(), externalConfirmed: true, policyRevision: 1, ...extra }); +let db; let docs; let service; let auth; +beforeEach(() => { + ({ db, docs } = memoryDb()); + auth = { getUser: jest.fn() }; + service = createAdministrationService(db, auth, { memberView, eventView }, () => now); + docs.set('communityOfficers/officer', caps); docs.set('communityConfig/duesTestTerm', policy); +}); +const execute = (request, credentials = token) => service.execute('officer', credentials, request); +test('records one audited unlinked term, retries immutably and changes no account or member state', async () => { + const request = command(); const first = await execute(request); + expect(await execute(request)).toEqual(first); + const receipt = docs.get(`communityDues/${duesId(request.confirmationId)}`); + expect(receipt.authority.association).toEqual({ state: 'unlinked', uid: null, revision: 0 }); + expect(receipt.audit.provenance).toBe('manual_off_platform'); + expect(first).toMatchObject({ dues: { amountCents: 100, currency: 'USD', linked: false }, syntheticOnly: true }); + expect(docs.get(`communityOfficerCommands/officer/requests/${request.requestId}`)).toMatchObject({ capability: 'duesRecording', actorUid: 'officer', after: { linked: false, recorded: true } }); + expect([...docs.keys()].filter((key) => key.startsWith('communityDues/'))).toHaveLength(1); + expect([...docs.keys()].some((key) => key.startsWith('communityMemberships/') || key.startsWith('members/'))).toBe(false); + expect(auth.getUser).not.toHaveBeenCalled(); + expect(JSON.stringify(receipt)).not.toMatch(/stripe|checkout|paymentStatus|email/i); +}); +test('different commands and officers cannot record the same confirmation twice', async () => { + docs.set('communityOfficers/other', caps); + const first = command(); const second = command({ accountUid: 'other', confirmationId: first.confirmationId }); + const result = await Promise.allSettled([execute(first), service.execute('other', token, second)]); + expect(result.filter((entry) => entry.status === 'fulfilled')).toHaveLength(1); + expect([...docs.keys()].filter((key) => key.startsWith('communityDues/'))).toHaveLength(1); +}); +test('conflicting command payload is rejected, policy change cannot alter a saved receipt', async () => { + const request = command(); const first = await execute(request); + await expect(execute({ ...request, confirmationId: randomUUID() })).rejects.toHaveProperty('code', 'already-exists'); + docs.set('communityConfig/duesTestTerm', { ...policy, revision: 2, amountCents: 250 }); + expect(await execute(request)).toEqual(first); + await expect(execute(command())).rejects.toHaveProperty('code', 'failed-precondition'); +}); +test.each([{ ...caps, enabled: false }, { ...caps, expiresAtMs: now }, { ...caps, duesRecording: false }, { ...caps, schemaVersion: 1 }, { ...caps, forged: true }])('capability denies new commands and replay %#', async (value) => { + const request = command(); await execute(request); docs.set('communityOfficers/officer', value); + await expect(execute(request)).rejects.toHaveProperty('code', 'permission-denied'); + await expect(execute(command())).rejects.toHaveProperty('code', 'permission-denied'); +}); +test('old six-field capability keeps old status shape but never gains dues authority', async () => { + const old = { ...caps, schemaVersion: 1 }; delete old.duesRecording; + docs.set('communityOfficers/officer', old); + expect(readCapabilities(old, now)).toEqual({ membershipApproval: false, freeEventManagement: false }); + expect(await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer' }))).toEqual({ capabilities: { membershipApproval: false, freeEventManagement: false }, pending: [], nextCursor: null, events: [] }); + await expect(execute(command())).rejects.toHaveProperty('code', 'permission-denied'); +}); +test.each([{ ...token, email_verified: false }, { ...token, auth_time: now / 1000 - 601 }, { ...token, firebase: {} }])('native officer token gate still protects dues %#', async (value) => { + await expect(execute(command(), value)).rejects.toHaveProperty('code', 'permission-denied'); +}); +test.each([{ amountCents: 0 }, { amountCents: 1.5 }, { amountCents: 10001 }, { currency: 'EUR' }, { syntheticOnly: false }, { method: 'bank_transfer' }, { endsAtMs: now - 2000 }, { endsAtMs: now + 86400000 }, { revision: '1' }, { policyVersion: 'real-club-policy' }, { email: 'fixture@invalid.test' }])('malformed or real policy is rejected %#', async (extra) => { + docs.set('communityConfig/duesTestTerm', { ...policy, ...extra }); + await expect(execute(command())).rejects.toBeDefined(); +}); +test.each([{ amountCents: 1 }, { currency: 'USD' }, { targetUid: 'runner' }, { startsAtMs: now }, { externalConfirmed: false }, { confirmationId: 'real-bank-ref' }, { policyRevision: 0 }, { expectedRevision: 1 }])('client forged or unconfirmed request is rejected %#', (extra) => { + expect(() => command(extra)).toThrow(); +}); +test('bounded paginated view contains no audit, actor, profile or authority record', async () => { + for (let n = 0; n < 23; n++) { docs.delete('communityOfficerLimits/officer'); await execute(command()); } + const status = await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer' })); + expect(status.dues).toHaveLength(20); expect(status.nextDuesCursor).toBeDefined(); + expect(status.duesPolicy.amountCents).toBe(100); expect(status.duesRecordingOpen).toBe(true); + expect(JSON.stringify(status.dues)).not.toMatch(/audit|actor|authority|confirmationHash|email/i); + const next = await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer', duesCursor: status.nextDuesCursor })); + expect(next.dues).toHaveLength(3); expect(next.nextDuesCursor).toBeNull(); +}); +test('missing policy still permits review, no policy creates no receipt', async () => { + docs.delete('communityConfig/duesTestTerm'); + expect(await execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer' }))).toMatchObject({ duesPolicy: null, duesRecordingOpen: false, dues: [] }); + await expect(execute(command())).rejects.toBeDefined(); + expect([...docs.keys()].some((key) => key.startsWith('communityDues/'))).toBe(false); +}); +test('policy reader rejects accessors without invoking them', () => { + const getter = jest.fn(); const value = { ...policy }; Object.defineProperty(value, 'amountCents', { enumerable: true, get: getter }); + expect(() => readDuesPolicy(value)).toThrow(); expect(getter).not.toHaveBeenCalled(); +}); diff --git a/functions/membershipManualEvidence.js b/functions/membershipManualEvidence.js index 678895b2..21d29848 100644 --- a/functions/membershipManualEvidence.js +++ b/functions/membershipManualEvidence.js @@ -1,13 +1,14 @@ 'use strict'; -// Source-only, unused contract (MEMBERS-ADMIN-001A). Given one officer-attested +// Pure contract (MEMBERS-ADMIN-001A). Given one officer-attested // off-platform dues-evidence command, it validates an exact officer-audit // envelope and projects two paired, frozen values: the exact // `record_term_decision` input the shipped membership authority reducer accepts, // and an immutable audit record stamped with a manual, off-platform provenance. // It invents no policy (every owner-meaningful value is carried through as an // opaque token), reads no stored record, contacts no provider, and fabricates -// no external charge state. No runtime imports it. +// no external charge state. Only the isolated staging recorder consumes it, +// for explicitly fictional, unlinked terms. Production does not import it. const { types: { isProxy } } = require('node:util'); diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index 94d867a2..86201f90 100644 --- a/scripts/staging-community-release.js +++ b/scripts/staging-community-release.js @@ -16,7 +16,7 @@ const REGION = 'us-central1'; const BASE = `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/${REGION}`; const ACCOUNT = 'runmprc@gmail.com'; const RUNTIME = `mprc-community-runtime@${PROJECT}.iam.gserviceaccount.com`; -const FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; +const FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; const check = (value) => { if (!value) throw new Error('staging_community_release_failed'); }; // Owner tokens do not inherit the CLI's --project as their API quota project. diff --git a/src/pages/community/CommunityAdmin.test.tsx b/src/pages/community/CommunityAdmin.test.tsx index b496fa03..d2b1c861 100644 --- a/src/pages/community/CommunityAdmin.test.tsx +++ b/src/pages/community/CommunityAdmin.test.tsx @@ -76,6 +76,52 @@ test('permissions control minimum views without relying on browser admin role', })); render(show()); await screen.findByText('Manage free test events'); expect(screen.queryByText('Pending membership requests')).not.toBeInTheDocument(); }); +const duesState = (extra = {}) => state({ + capabilities: { membershipApproval: false, freeEventManagement: false, duesRecording: true }, +pending: [], + dues: [], +duesPolicy: { + revision: 3, amountCents: 100, currency: 'USD', method: 'fictional_external', termId: 'fictional-term', startsAtMs: 1800000000000, endsAtMs: 1800000060000 +}, +duesRecordingOpen: true, +nextDuesCursor: null, +...extra +}); +test('dues recording sends only confirmation and server policy revision, not price, term or UID association', async () => { + call.mockResolvedValue(duesState()); render(show()); + const confirmation = await screen.findByLabelText('Fictional confirmation ID (UUID)'); + fireEvent.change(confirmation, { target: { value: '123e4567-e89b-42d3-a456-426614174001' } }); + fireEvent.click(screen.getByLabelText(/I confirm this is a fictional test/)); + fireEvent.click(screen.getByRole('button', { name: 'Record fictional dues' })); + await screen.findByText('Action confirmed. Current server status is shown.'); + expect(call.mock.calls[1][1]).toEqual({ + action: 'admin_dues_record', accountUid: 'officer', requestId: '123e4567-e89b-42d3-a456-426614174000', expectedRevision: 0, confirmationId: '123e4567-e89b-42d3-a456-426614174001', externalConfirmed: true, policyRevision: 3, reason: 'synthetic_staging_review' +}); +}); +test('uncertain dues hides records and retry reuses exact confirmation command', async () => { + call.mockResolvedValue(duesState()); render(show()); + fireEvent.change(await screen.findByLabelText('Fictional confirmation ID (UUID)'), { target: { value: '123e4567-e89b-42d3-a456-426614174001' } }); + fireEvent.click(screen.getByLabelText(/I confirm this is a fictional test/)); + call.mockRejectedValueOnce(new Error('synthetic offline')); + fireEvent.click(screen.getByRole('button', { name: 'Record fictional dues' })); + const retry = await screen.findByRole('button', { name: 'Retry saved action' }); + await screen.findByText(/Action could not be confirmed/); + expect(screen.queryByText('Record fictional external dues')).not.toBeInTheDocument(); + const request = 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(request); +}); +test('dues projection requires its permission and closed policy disables submission', async () => { + call.mockResolvedValue(duesState({ duesRecordingOpen: false })); render(show()); + expect(await screen.findByRole('button', { name: 'Record fictional dues' })).toBeDisabled(); + expect(screen.queryByText('Pending membership requests')).not.toBeInTheDocument(); +}); +test('dues pagination sends only the dues cursor', async () => { + call.mockResolvedValue(duesState({ nextDuesCursor: 'stage_dues_fixture' })); render(show()); + fireEvent.click(await screen.findByRole('button', { name: 'Next dues receipts' })); + await screen.findByText('Status refreshed.'); + expect(call.mock.calls[1][1]).toEqual({ action: 'admin_status', accountUid: 'officer', duesCursor: 'stage_dues_fixture' }); +}); test('authenticator challenge clears passwords and resolves only TOTP', async () => { mockUser = null; const resolveSignIn = jest.fn().mockResolvedValue({}); diff --git a/src/pages/community/CommunityAdmin.tsx b/src/pages/community/CommunityAdmin.tsx index da315aff..5dff655c 100644 --- a/src/pages/community/CommunityAdmin.tsx +++ b/src/pages/community/CommunityAdmin.tsx @@ -99,9 +99,11 @@ export default function CommunityAdmin() { const inFlight = useRef(false); const pending = useRef(null); const cursor = useRef(undefined); + const duesCursor = useRef(undefined); useEffect(() => { generation.current += 1; const {current} = generation; inFlight.current = false; pending.current = null; cursor.current = undefined; + duesCursor.current = undefined; setSnapshot(null); setBusy(false); setMessage(''); setEditing(null); if (enabled && uid && functions) { callAdministration(functions, { action: 'admin_status', accountUid: uid }).then((value) => { @@ -111,12 +113,12 @@ export default function CommunityAdmin() { return () => { generation.current += 1; }; }, [enabled, uid, functions]); - async function perform(request?: Omit, refresh = false, nextCursor?: string) { + async function perform(request?: Omit, refresh = false, nextCursor?: string, nextDuesCursor?: 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; + if (refresh) { cursor.current = nextCursor; duesCursor.current = nextDuesCursor; } let command = refresh ? null : pending.current; if (request) { command = { ...request, accountUid: uid, requestId: crypto.randomUUID(), reason: 'synthetic_staging_review' @@ -125,7 +127,12 @@ export default function CommunityAdmin() { 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 } : {}) }); + const value = await callAdministration(functions, { + action: 'admin_status', +accountUid: uid, + ...(cursor.current ? { cursor: cursor.current } : {}), +...(duesCursor.current ? { duesCursor: duesCursor.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) { @@ -159,7 +166,11 @@ closesAtMs: Date.parse(String(values.get('closes'))),

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.

+

+ Staging test only. Made-up members, free events and fictional dues receipts only. + Approval simulates a test term. Dues recording moves no money, + links no account and grants no Auth role. +

Member signup and events {isLoading &&

Loading account…

} {!isLoading && !uid && } @@ -175,6 +186,104 @@ closesAtMs: Date.parse(String(values.get('closes'))), )} {message &&

{message}

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

Record fictional external dues

+

+ Test only. No money moves. Receipts remain unlinked and grant no account access. + Do not enter real payment references or member details. +

+ {status.duesPolicy ? ( + <> +

+ Server test amount: + {' '} + {status.duesPolicy.currency} + {' '} + {(status.duesPolicy.amountCents / 100).toFixed(2)} + . + {' '} + Fictional external method. Test term: + {' '} + {status.duesPolicy.termId} + ; + {' '} + {new Date(status.duesPolicy.startsAtMs).toLocaleString()} + {' '} + to + {' '} + {new Date(status.duesPolicy.endsAtMs).toLocaleString()} + . +

+
{ + event.preventDefault(); + const values = new FormData(event.currentTarget); + if (values.get('externalConfirmed') !== 'on' || !status.duesPolicy) return; + perform({ + action: 'admin_dues_record', +expectedRevision: 0, + confirmationId: String(values.get('confirmationId')), +externalConfirmed: true, +policyRevision: status.duesPolicy.revision +}); + }} + > + + + +
+ {!status.duesRecordingOpen && ( +

+ The test term is not open. + Ask the staging specialist for a new test configuration. +

+ )} + + ) :

No test dues configuration. Ask the staging specialist.

} + {(status.dues || []).length === 0 &&

No fictional dues receipts.

} + {(status.dues || []).map((receipt) => ( +
+

+ Test membership: + {receipt.membershipId} +

+

+ {receipt.currency} + {' '} + {(receipt.amountCents / 100).toFixed(2)} + {' '} + · + {' '} + {receipt.termId} + {' '} + · Unlinked; no account access granted. +

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

Pending membership requests

diff --git a/src/services/community/communityService.ts b/src/services/community/communityService.ts index b20b71b7..21cf633f 100644 --- a/src/services/community/communityService.ts +++ b/src/services/community/communityService.ts @@ -36,19 +36,28 @@ export interface FreeEventDraft { closesAtMs: number; capacity: number; } export interface AdministrationStatus { - capabilities: { membershipApproval: boolean; freeEventManagement: boolean }; + capabilities: { + membershipApproval: boolean; freeEventManagement: boolean; duesRecording?: boolean; + }; pending: Array<{ uid: string; revision: number; applicationStatus: string }>; nextCursor: string | null; events: Array; + dues?: Array<{ membershipId: string; amountCents: number; currency: string; method: string; + termId: string; startsAtMs: number; endsAtMs: number; linked: false }>; + duesPolicy?: { revision: number; amountCents: number; currency: string; method: string; + termId: string; startsAtMs: number; endsAtMs: number } | null; + duesRecordingOpen?: boolean; + nextDuesCursor?: string | null; } 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'; + | 'admin_event_capacity' | 'admin_event_open' | 'admin_event_close' | 'admin_event_cancel' | 'admin_dues_record'; accountUid: string; requestId?: string; expectedRevision?: number; reason?: 'synthetic_staging_review'; targetUid?: string; eventId?: string; event?: FreeEventDraft; capacity?: number; cursor?: string; + confirmationId?: string; externalConfirmed?: true; policyRevision?: number; duesCursor?: string; } export async function callAdministration(functions: Functions, request: AdministrationRequest) { if (!COMMUNITY_ENABLED || functions.app.options.projectId !== 'run-mprc-staging') { diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index c1d2876b..20ebbdf0 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -368,6 +368,7 @@ describe('server-only operational collections', () => { 'communityCommands/runner-a/requests/test', 'communityLimits/runner-a', 'communityOfficers/runner-a', 'communityOfficerCommands/runner-a/requests/test', 'communityOfficerLimits/runner-a', + 'communityDues/stage-dues', 'communityDues/stage-dues/private/test', 'communityConfig/duesTestTerm', ])('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 58389415..d9eef363 100644 --- a/tests/staging-community-release.test.js +++ b/tests/staging-community-release.test.js @@ -28,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', 'communityAdministration.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']); + assert.deepEqual(FILES, ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']); assert.ok(!FILES.includes('index.js') && !FILES.includes('stripeHelpers.js')); }); test('fixed staging specification and provider readback reject drift', () => {