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.
+