diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 23c9f5e..fc3e26a 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**COMMUNITY-DUES-002 / #719 — SOURCE PREPARED, NOT AVAILABLE YET:** link one immutable fictional receipt to one owner-marked disposable pending account and activate its unchanged current test term. Separate v3 association capability, recent native TOTP/App Check, authoritative verified/enabled Auth, strict input/revision/marker guards, atomic uniqueness and permission-first replay. No real policy/data/money, Auth claim, IAM expansion, dependency/service/schedule or production change. Officer impact: supervised screen-only fictional linking and member verification. Officer documentation: STAGING_COMMUNITY, entrypoints, ACCESS_CONTINUITY, EVENTS_SHOP_MEMBERS, glossary and maps. Deployment evidence: source/local tests only until native #719 records exact merge, staging Function/Hosting, security/UI/race/retry/cancel/expiry and owned cleanup. #716 is historical recording-only proof; broad #115/#81 and real launch remain separate. + **COMMUNITY-DUES-001 / #716 — staging verified 2026-10-12:** 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: PR717 merged d212fab; all six exact PR/main jobs and every step passed. Native Function v3/archive and staging Hosting matched; twenty-two hosted denials, immutable retry/race, twenty-plus-one pages, actual UI/offline/390×844/revocation and 51-document/two-account cleanup passed. Release-command reporting caveat and supervised fresh-fixture handoff are recorded in STAGING_COMMUNITY; production/runmprc.com and real payments were untouched. 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. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3e19b04..f67afac 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,7 @@ # MPRC Secure Commerce Implementation Plan +**#719 focused continuation — SOURCE PREPARED, NOT AVAILABLE YET:** verified/closed #716 is the dependency. Add only fictional receipt-to-disposable-account linking and derived current test membership. Preserve receipt/term, pending revision, separate capability, authoritative Auth, transaction uniqueness, permission-first replay and cancellation-through-term policy. Complete local checks, exact source/main CI, native archive/security/resource readback, hosted positive/negative/race/retry/cancellation/expiry/UI review and exact owned cleanup before closing this child. Broad #115/#81, real rules/payments and production launch remain separate. + **Status:** Approved technical sequence and assessment snapshot; GitHub issues are the live execution/status source **Last reviewed:** 2026-07-12 **Live status:** [Secure Commerce & Platform Hardening milestone](https://github.com/Run-MPRC/Run-MPRC.github.io/milestone/2) diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 1a44baa..29235f0 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,5 +1,7 @@ # MPRC Website: Officer Start Here +**Fictional account linking — #719, SOURCE PREPARED / NOT AVAILABLE YET:** a separate staging-only permission will link one fictional receipt to a specialist-marked disposable pending account. Its test access lasts only through the receipt's unchanged term, including after cancellation; no Auth role or money changes. Follow the [screen-only guide](./docs/officers/STAGING_COMMUNITY.md#link-a-fictional-receipt--719) only after native acceptance and fresh private test fixtures. Production and real membership rules remain unavailable. + **Use this page if Dave or the usual code owner is unavailable.** You do not need to know how to code. **Google Doc entry card:** a private native Google Doc titled **MPRC Website — Officer Start Here** has been connector-readback verified with the safe request and warnings. Its private edit link is intentionally not published here. It is currently owner-only; continuity is incomplete until the board moves it to the board-owned Drive and shares it with at least two backup officers. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index ca3436f..506ee31 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**#719 fictional linking — SOURCE PREPARED, NOT AVAILABLE YET:** reuse the same service, six-permission runtime and min0/max2/256MB/30s limits. Dave/specialist supplies only disposable verified Auth accounts, pending requests/revisions, expiring exact v3 association capabilities and six-field `communityAssociationTargets/{uid}` markers. Follow the [screen-only procedure](./docs/officers/STAGING_COMMUNITY.md#link-a-fictional-receipt--719) after native acceptance. Disable capabilities first; exact owned cleanup uses identity/update-time fences. No ordinary officer grants a role, edits records, deploys or runs a terminal. No migration/scheduler/provider/payment/production change. The existing release command's post-deploy reporting failure remains unresolved; inspect native state/archive before any retry, never blindly redeploy. + **Status:** Target operating procedure; several controls are not implemented yet **Last reviewed:** 2026-07-12 **Audience:** Engineers, race directors, merchandise/fulfillment leads, membership admins, treasurer/finance admins, and incident responders diff --git a/SECURITY.md b/SECURITY.md index 19b0aa2..6a232ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # MPRC Security, Privacy, and Risk Plan +**#719 staging linking — SOURCE PREPARED, NOT AVAILABLE YET:** live expiring v3 association permission is separate from recording/approval/event access. Require recent native verified TOTP/App Check, exact caller binding, explicit test confirmation, an owner-marked disposable UID, authoritative verified/enabled Auth lookup and a pending null-authority application/revision. One receipt and one pending account can be linked only once transactionally; saved retries recheck permission before replay. Receipt/term, profiles and Auth claims stay unchanged. Receipt review exposes no target/contact data. Both new collections stay default-denied to browser admins. The six-permission project-scoped runtime gains no rights. Source/tests do not qualify native deployment or production security. + **Status:** Pre-production security assessment **Assessment date:** 2026-07-12 **Scope:** This repository's React application, Firebase configuration/functions/rules, Stripe integration, Strava integration, CI/CD, operational data, and documented deployment model diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index a8e5502..e43de1b 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,5 +1,7 @@ # Stripe, Race Registration, and Merchandise Design +**#719 fictional activation — SOURCE PREPARED, NOT AVAILABLE YET:** a separately authorized disposable staging account may receive the unchanged test term from a fictional external-dues receipt. This does not verify a payment, reconcile Stripe, grant an Auth role, renew a term, issue a refund or approve real club policy. Receipt evidence stays immutable; association is a separate server-only record. Production and real payments remain untouched and unavailable. + **Status:** Required production design; current code is a test-mode prototype **Last reviewed:** 2026-07-13 **Related:** [SYSTEM_DESIGN.md](./SYSTEM_DESIGN.md), [SECURITY.md](./SECURITY.md), [OPERATIONS_RUNBOOK.md](./OPERATIONS_RUNBOOK.md) diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 3764e5f..ce69d07 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**#719 fictional account linking — SOURCE PREPARED, NOT AVAILABLE YET:** the isolated staging service adds `admin_dues_link`. A separate exact v3 `membershipAssociation` capability, recent native TOTP/App Check, authoritative verified/enabled Auth account, pending null-authority application with matching revision, and owner-supplied exact six-field disposable-target marker expiring within 24 hours are all required. One immutable fictional receipt supplies the unchanged approved current test term; an atomic transaction creates `communityDuesLinks/{membershipId}` and attaches its derived authority to `communityMemberships/{uid}`. Receipt and member uniqueness fences prevent double activation, reassignment and renewal. Saved retries recheck officer permission before replay. Recording remains unlinked; the bounded receipt view adds only a linked boolean, not a target UID. Cancellation preserves the original term; live clock expiry removes current-member status. No Auth claims/roles, real policy/data/payments, new dependency/service/schedule, IAM expansion, migration or production change. Existing default-deny browser Rules cover both new collections. Staging deployment and native acceptance remain required under #719. + **Status:** Target architecture and current-state assessment **Last reviewed:** 2026-07-13 **Owners:** MPRC product, engineering, operations, and finance leads diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index a7b614e..f1bc112 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -1,5 +1,7 @@ # Access Continuity +**#719 linking permission — SOURCE PREPARED / NOT AVAILABLE YET:** only Dave/platform specialist supplies an expiring association permission and exact disposable-target marker for the [supervised screen review](./STAGING_COMMUNITY.md#link-a-fictional-receipt--719). Existing recording/approval/event access grants no linking authority. Do not change a role, email-match an account or share passwords/codes. Stop if the supplied account/revision is uncertain or access survives revocation. Enrollment, fixture supply, cleanup and rollback remain specialist-only; no backup appointment or production permission changes. + **Purpose:** ensure at least two named officers can reach every important service if the usual owner is unavailable. **Status:** setup is **INCOMPLETE** until the entry document is in a board-owned Drive and two backup officers complete a sign-in drill. **Approver:** club president plus the officer responsible for the service. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 55eafab..5a8db41 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1,5 +1,7 @@ # Events, Shop, Members, and Money +**#719 fictional linking — SOURCE PREPARED / NOT AVAILABLE YET:** the [supervised staging procedure](./STAGING_COMMUNITY.md#link-a-fictional-receipt--719) will deliberately connect an immutable fictional receipt to a specialist-marked disposable pending account. Test access uses only the receipt's original term; cancellation preserves it until expiry. No real dues policy/payment/claim/renewal/refund/production launch is supplied. Stop on an uncertain ID/revision or changed term and contact Dave privately. + **Current status:** live commerce is **NOT AVAILABLE YET**. **Use this guide when:** a request touches registrations, members-only access, products, payments, refunds, waivers, or private data. diff --git a/docs/officers/GLOSSARY.md b/docs/officers/GLOSSARY.md index c74ed5e..0061811 100644 --- a/docs/officers/GLOSSARY.md +++ b/docs/officers/GLOSSARY.md @@ -2,6 +2,8 @@ | Word | Plain meaning | | --- | --- | +| Disposable target marker | A short-lived server record the specialist uses to identify a made-up account permitted for one staging linking test. It is not an officer role or real membership. | +| Linked test membership | A fictional receipt deliberately connected to one disposable account. Test access depends on its original current term; cancellation does not extend it and no Auth role changes. | | 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. | diff --git a/docs/officers/README.md b/docs/officers/README.md index bef21bd..3a7dcfa 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,5 +1,7 @@ # Officer Website Handbook +[#719 fictional account linking](./STAGING_COMMUNITY.md#link-a-fictional-receipt--719) is SOURCE PREPARED / NOT AVAILABLE YET. A separate permission and specialist-marked disposable account are required; do not use real members or receipts. Native deployment, screen review and exact cleanup remain required. This does not release real memberships/payments or production. + **Audience:** MPRC officers and backup maintainers with little or no coding experience **Last checked:** 2026-07-30 **Start page:** [OFFICER_START_HERE.md](../../OFFICER_START_HERE.md) diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index d61b050..f795cb3 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -10,6 +10,39 @@ ## What the actions mean +## Link a fictional receipt — #719 + +**Status:** SOURCE PREPARED / **NOT AVAILABLE YET** until #719 records native staging deployment and hosted acceptance. This supplements #716 recording; it does not change its immutable receipt or approve real club policy. + +**Purpose:** deliberately connect one fictional receipt to one disposable pending account and verify its unchanged test term. **Approver:** Dave Liu. **Prerequisites:** the specialist supplies a verified disposable account, exact account ID and pending revision, a current unlinked fictional receipt, short test term, expiring association permission and disposable-target marker. Officer setup is separate from member sign-in. No backup owner is appointed. + +1. Open the supplied staging `/community/admin` link. +2. Confirm **Staging test only** and the supplied disposable officer email. +3. Sign in and complete the authenticator prompt. +4. Find **Link fictional dues to a disposable account**. If absent, stop. +5. Select the supplied unlinked **Fictional receipt to link**. +6. Enter only the specialist-supplied **Disposable account ID**. +7. Enter the supplied **Pending membership revision**. +8. Check that both identifiers match the private test instructions. +9. Select the fictional-account confirmation checkbox. +10. Select **Link and activate test membership** once. +11. Confirm the current receipt review reports **Linked test term**. +12. If uncertain, use **Retry saved action**, not a new link. +13. Sign out of the officer account. +14. Sign in as the supplied disposable member on `/community`. +15. Select **Refresh status** and confirm **Test membership active** and the original term end. +16. Select **Cancel membership** and confirm cancellation requested while test access continues through that same end. +17. After the short term ends, select **Refresh status** and confirm test membership is not active. +18. Sign out and ask the specialist to confirm exact test cleanup. + +**Expected result:** one separate immutable link and one approved disposable test membership; the original receipt and term are unchanged. The test grants no Auth claim/role or real paid membership and moves no money. Recording permission alone cannot link. A linked row is not proof of current member access after expiry; use the member page. + +**Stop conditions:** real account/reference/member data; production URL; uncertain or wrong account/revision/receipt; missing permission/marker; unverified/disabled account; nonpending application; future/expired term; changed dates; conflicting duplicate; uncertain result that remains unresolved. Stop and contact Dave privately; do not edit records, guess another ID, renew or reassign a receipt. + +**Success proof:** #719 must separately record exact source/tests/merge, Function archive/resource/security/identity, Hosting marker, native attestation/MFA, actual officer/member screens, failed/duplicate/racing/retried requests, cancellation/original expiry and exact cleanup. **Undo:** sign out; the specialist disables the capability first and verifies old retries denied, then follows the reviewed staging rollback or owned-fixture cleanup. No unlink/refund/correction button is provided. **Escalation:** Dave/platform specialist. Enrollment, configuration, marker/permission supply, release and cleanup remain specialist-only; the backup-officer review needs no terminal, shared password or database editing. + +## Existing membership and free-event actions + - Request membership: records a pending request. It does not prove dues or grant membership. - Withdraw membership request: withdraws a pending application. - Cancel membership: records a cancellation request for an already-approved synthetic membership. Access lasts through its original paid term. No refund occurs. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index a9b5acc..9811927 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -2,6 +2,22 @@ These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. +## Fictional account linking — #719, source prepared only + +```mermaid +flowchart LR + Officer["Recent native TOTP + App Check"] --> Permission["Separate expiring v3 association permission"] + Permission --> Service["Same staging communityActions service"] + Receipt["Immutable fictional receipt and original current test term"] --> Service + Target["Owner-marked disposable UID; verified/enabled Auth; pending revision"] --> Service + Service --> Atomic["Atomic separate link + associated test authority; one receipt/account"] + Atomic --> Member["Member page: current clock; cancellation keeps original expiry"] + Browser["Browser including admin"] -. "Direct writes denied" .-> Atomic + Atomic -. "Never" .-> Real["Auth roles, real money or production"] +``` + +Text alternative: separately authorized recent-MFA staging officers may atomically connect one immutable fictional current term to one owner-marked verified disposable pending account, with no roles/payments/production change; native release acceptance remains required. + ## Fictional external dues — #716, staging verified 2026-10-12 ```mermaid diff --git a/functions/communityActions.appCheck.test.js b/functions/communityActions.appCheck.test.js index f9071e4..64f7277 100644 --- a/functions/communityActions.appCheck.test.js +++ b/functions/communityActions.appCheck.test.js @@ -70,4 +70,13 @@ describe('native authenticated boundary and context binding', () => { expect((await request({ ...app, authorization: 'Bearer synthetic-unverified-auth' }, dues)).status).toBe(403); expect(admin.firestore).not.toHaveBeenCalled(); }); + test('linking native boundary cannot be authorized by fields, another UID or single-factor/unverified Auth', async () => { + const link = { action: 'admin_dues_link', accountUid: 'synthetic-uid', requestId: '123e4567-e89b-42d3-a456-426614174000', expectedRevision: 1, reason: 'synthetic_staging_review', membershipId: `stage_dues_${'a'.repeat(64)}`, targetUid: 'disposable-target', associationConfirmed: true }; + expect((await request({ authorization: 'Bearer synthetic-auth' }, link)).status).toBe(401); + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { ...link, accountUid: 'other' })).status).toBe(403); + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, link)).status).toBe(403); + expect((await request({ ...app, authorization: 'Bearer synthetic-unverified-auth' }, link)).status).toBe(403); + expect((await request({ ...app, authorization: 'Bearer synthetic-auth' }, { ...link, role: 'admin' })).status).toBe(400); + expect(admin.firestore).not.toHaveBeenCalled(); + }); }); diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js index b63cf97..acddc31 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', 'communityDues']; + const roots = ['communityEvents', 'communityMemberships', 'communityConfig', 'communityCommands', 'communityLimits', 'communityOfficers', 'communityOfficerCommands', 'communityOfficerLimits', 'communityDues', 'communityDuesLinks', 'communityAssociationTargets']; 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(() => { @@ -100,4 +100,43 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', await db.doc(`communityOfficers/${uid}`).update({ enabled: false, revision: 2 }); await expect(officer.execute(uid, token, request)).rejects.toHaveProperty('code', 'permission-denied'); }, 45000); + test('two officers linking one receipt and two receipts linking one UID have one winner; term/cancel/expiry remain canonical', async () => { + const { createDuesReceipt } = require('./communityDues'); + const policy = { schemaVersion: 1, syntheticOnly: true, policyVersion: 'fictional-external-dues-v1', revision: 1, amountCents: 100, currency: 'USD', method: 'fictional_external', termId: 'fictional-link-term', startsAtMs: now - 1000, endsAtMs: now + 60000 }; + const token = { email_verified: true, auth_time: Math.floor(now / 1000), firebase: { sign_in_second_factor: 'totp' } }; + const caps = { schemaVersion: 3, enabled: true, revision: 1, expiresAtMs: now + 3600000, membershipApproval: false, freeEventManagement: false, duesRecording: false, membershipAssociation: true }; + const freshReceipt = async () => { + const value = createDuesReceipt('link-officer-0', caps, token, { requestId: randomUUID(), confirmationId: randomUUID(), policyRevision: 1 }, policy, now); + await db.doc(`communityDues/${value.membershipId}`).create(value); return value; + }; + const officer = createAdministrationService(db, { getUser: async uid => ({ uid, emailVerified: true, disabled: false }) }, { memberView, eventView }, () => now); + const commands = []; + const receipt = await freshReceipt(); + for (let n = 0; n < 4; n++) { + const uid = `link-officer-${n}`; const member = `link-member-${n}`; + await db.doc(`communityOfficers/${uid}`).create(caps); + await service.mutate(member, command('membership_join')); + await db.doc(`communityAssociationTargets/${member}`).create({ schemaVersion: 1, syntheticOnly: true, enabled: true, uid: member, revision: 1, expiresAtMs: now + 3600000 }); + commands.push(parseAdminRequest({ action: 'admin_dues_link', accountUid: uid, targetUid: member, expectedRevision: 1, requestId: randomUUID(), membershipId: receipt.membershipId, associationConfirmed: true, reason: 'synthetic_staging_review' })); + } + const raced = await Promise.allSettled(commands.map(request => officer.execute(request.accountUid, token, request))); + const winners = raced.flatMap((value, n) => value.status === 'fulfilled' ? [n] : []); expect(winners).toHaveLength(1); + const winner = commands[winners[0]]; + expect((await db.collection('communityDuesLinks').get()).size).toBe(1); + expect((await db.doc(`communityDues/${receipt.membershipId}`).get()).data()).toEqual(receipt); + const authority = (await db.doc(`communityMemberships/${winner.targetUid}`).get()).data().authority; + expect(authority.term).toEqual(receipt.authority.term); + await officer.execute(winner.accountUid, token, winner); + await service.mutate(winner.targetUid, command('membership_cancel', 2)); + expect((await service.status(winner.targetUid)).membership.entitlement).toBe('current_member'); + expect((await db.doc(`communityMemberships/${winner.targetUid}`).get()).data().authority).toEqual(authority); + const loser = commands[(winners[0] + 1) % 4]; const a = await freshReceipt(); const b = await freshReceipt(); + const both = await Promise.allSettled([a, b].map(value => officer.execute(loser.accountUid, token, { ...loser, requestId: randomUUID(), membershipId: value.membershipId }))); + expect(both.filter(value => value.status === 'fulfilled')).toHaveLength(1); + expect((await db.collection('communityDuesLinks').get()).size).toBe(2); + await db.doc(`communityOfficers/${winner.accountUid}`).update({ enabled: false, revision: 2 }); + await expect(officer.execute(winner.accountUid, token, winner)).rejects.toHaveProperty('code', 'permission-denied'); + now = policy.endsAtMs; + expect((await service.status(winner.targetUid)).membership.entitlement).toBe('not_entitled'); + }, 45000); }); diff --git a/functions/communityAdministration.js b/functions/communityAdministration.js index adea75c..82a6dc4 100644 --- a/functions/communityAdministration.js +++ b/functions/communityAdministration.js @@ -7,6 +7,7 @@ const { createHash } = require('node:crypto'); const { parseStrictObject } = require('./requestValidation'); const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('./membershipAuthority'); const { readDuesPolicy, createDuesReceipt, duesView, duesId } = require('./communityDues'); +const { readDisposableTarget, linkedReceipt, associateReceipt } = require('./communityDuesAssociation'); 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']); @@ -19,7 +20,7 @@ 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', 'duesCursor', 'confirmationId', 'externalConfirmed', 'policyRevision'] }); + const value = parseStrictObject(input, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['requestId', 'expectedRevision', 'targetUid', 'eventId', 'event', 'capacity', 'reason', 'cursor', 'duesCursor', 'confirmationId', 'externalConfirmed', 'policyRevision', 'membershipId', 'associationConfirmed'] }); check(identifier(value.accountUid), 'invalid-argument'); if (value.action === 'admin_status') { parseStrictObject(value, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['cursor', 'duesCursor'] }); @@ -27,13 +28,16 @@ function parseAdminRequest(input) { return value; } 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'] : []; + const linking = value.action === 'admin_dues_link'; + check(dues || linking || value.action === 'admin_membership_approve' || EVENTS.has(value.action), 'invalid-argument'); + const target = dues ? 'confirmationId' : linking || value.action === 'admin_membership_approve' ? 'targetUid' : 'eventId'; + const extra = linking ? ['membershipId', 'associationConfirmed'] : 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 (linking) check(typeof value.membershipId === 'string' && /^stage_dues_[a-f0-9]{64}$/.test(value.membershipId) + && value.associationConfirmed === true && value.expectedRevision > 0 && value.targetUid !== value.accountUid, '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) @@ -52,14 +56,17 @@ function assertOfficerToken(token, now) { && token.auth_time * 1000 <= now + 30000 && now - token.auth_time * 1000 <= 600000, 'permission-denied'); } function readCapabilities(record, now) { - const v2 = record?.schemaVersion === 2; - check((record?.schemaVersion === 1 || v2) && record.enabled === true && integer(record.revision) + const version = record?.schemaVersion; + const dues = version === 2 || version === 3; + check([1, 2, 3].includes(version) && 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 === (v2 ? 7 : 6) - && (!v2 || typeof record.duesRecording === 'boolean'), 'permission-denied'); + && Object.keys(record).length === 5 + version + && (!dues || typeof record.duesRecording === 'boolean') + && (version !== 3 || typeof record.membershipAssociation === 'boolean'), 'permission-denied'); return { membershipApproval: record.membershipApproval, freeEventManagement: record.freeEventManagement, - ...(v2 ? { duesRecording: record.duesRecording } : {}) }; + ...(dues ? { duesRecording: record.duesRecording } : {}), + ...(version === 3 ? { membershipAssociation: record.membershipAssociation } : {}) }; } function manifestIds(record) { const ids = record ? record.eventIds : []; @@ -106,20 +113,22 @@ 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')); + if (caps.duesRecording || caps.membershipAssociation) { + const policy = caps.duesRecording ? await tx.get(db.doc('communityConfig/duesTestTerm')) : null; 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; + const page = records.docs.slice(0, 20); + const links = page.length ? await tx.getAll(...page.map((snapshot) => db.doc(`communityDuesLinks/${snapshot.id}`))) : []; + const dues = page.map((snapshot, n) => ({ ...duesView(snapshot.data(), snapshot.id), linked: linkedReceipt(links[n].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_dues_record' ? 'duesRecording' : request.action === 'admin_membership_approve' ? 'membershipApproval' : 'freeEventManagement'; + const required = request.action === 'admin_dues_link' ? 'membershipAssociation' : 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}`); @@ -130,7 +139,7 @@ function createAdministrationService(db, auth, views, clock = Date.now) { const previous = rate.exists ? rate.data() : { window, count: 0 }; check(integer(previous.window, Number.MAX_SAFE_INTEGER) && integer(previous.count, 12)); const count = previous.window === window ? previous.count : 0; check(count < 12, 'resource-exhausted'); - let result; let before; let after; const writes = []; + let result; let before; let after; let associationGuard; const writes = []; if (required === 'duesRecording') { const ref = db.doc(`communityDues/${duesId(request.confirmationId)}`); const [snapshot, policy] = await tx.getAll(ref, db.doc('communityConfig/duesTestTerm')); @@ -139,6 +148,33 @@ function createAdministrationService(db, auth, views, clock = Date.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 === 'membershipAssociation') { + const linkRef = db.doc(`communityDuesLinks/${request.membershipId}`); + const memberRef = db.doc(`communityMemberships/${request.targetUid}`); + const [receipt, link, snapshot, targetMarker] = await tx.getAll( + db.doc(`communityDues/${request.membershipId}`), linkRef, memberRef, + db.doc(`communityAssociationTargets/${request.targetUid}`), + ); + check(!link.exists); + readDisposableTarget(targetMarker.data(), request.targetUid, now); + const member = snapshot.data(); const view = views.memberView(member, request.targetUid, now); + check(view.revision === request.expectedRevision && view.applicationStatus === 'pending' + && member.authority === null && view.revision < 1000000); + const target = await auth.getUser(request.targetUid); + check(target.uid === request.targetUid && target.emailVerified === true && target.disabled === false); + const associated = associateReceipt(receipt.data(), request, clock()); + readDisposableTarget(targetMarker.data(), request.targetUid, clock()); + associationGuard = () => { + readDisposableTarget(targetMarker.data(), request.targetUid, clock()); + check(associated.authority.term.startsAtMs <= clock() && associated.authority.term.endsAtMs > clock()); + }; + const next = { ...member, revision: member.revision + 1, applicationStatus: 'approved', + authority: associated.authority, updatedAtMs: now }; + before = { revision: member.revision, applicationStatus: member.applicationStatus, linked: false }; + after = { revision: next.revision, applicationStatus: next.applicationStatus, linked: true, membershipId: request.membershipId }; + writes.push([linkRef, associated.link, false], [memberRef, next, true]); + result = { membership: { revision: next.revision, applicationStatus: next.applicationStatus }, + association: { membershipId: request.membershipId, linked: true }, syntheticOnly: true }; } else if (required === 'membershipApproval') { const ref = db.doc(`communityMemberships/${request.targetUid}`); const [snapshot, policy] = await tx.getAll(ref, db.doc('communityConfig/approvalTestTerm')); @@ -189,9 +225,10 @@ function createAdministrationService(db, auth, views, clock = Date.now) { const policy = writes[0][1].policy; check(policy.startsAtMs <= clock() && policy.endsAtMs > clock()); } + if (associationGuard) associationGuard(); 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.action === 'admin_dues_record' ? after.membershipId : 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' || request.action === 'admin_dues_link' ? after.membershipId : request.targetUid || request.eventId, ...(required === 'membershipAssociation' ? { targetUid: request.targetUid } : {}), reason: request.reason, before, after, result, createdAtMs: now }); return result; }); } diff --git a/functions/communityDuesAssociation.js b/functions/communityDuesAssociation.js new file mode 100644 index 0000000..f8c5023 --- /dev/null +++ b/functions/communityDuesAssociation.js @@ -0,0 +1,44 @@ +'use strict'; + +// Isolated fictional staging only. Receipt evidence is immutable; the link is +// separate. No email matching, Auth mutation/claims, renewal or provider call. +const functions = require('firebase-functions'); +const { parseStrictObject } = require('./requestValidation'); +const { duesView } = require('./communityDues'); +const { applyMembershipAuthorityCommand, deriveMembershipEntitlement } = require('./membershipAuthority'); +const fail = () => { throw new functions.https.HttpsError('failed-precondition', 'Fictional membership linking unavailable. Refresh the staging status.'); }; +const check = (value) => { if (!value) fail(); }; +const integer = (value, max = 8640000000000000) => Number.isSafeInteger(value) && value >= 0 && value <= max; + +function readDisposableTarget(value, uid, now) { + try { + parseStrictObject(value, { requiredKeys: ['schemaVersion', 'syntheticOnly', 'enabled', 'uid', 'revision', 'expiresAtMs'], optionalKeys: [] }); + check(value.schemaVersion === 1 && value.syntheticOnly === true && value.enabled === true + && value.uid === uid && integer(value.revision, 1000000) && value.revision > 0 + && integer(value.expiresAtMs) && value.expiresAtMs > now && value.expiresAtMs - now <= 86400000); + return value; + } catch { fail(); } +} +function linkedReceipt(link, membershipId) { + if (link === undefined) return false; + try { + parseStrictObject(link, { requiredKeys: ['schemaVersion', 'syntheticOnly', 'membershipId', 'uid', 'revision', 'linkedAtMs'], optionalKeys: [] }); + check(link.schemaVersion === 1 && link.syntheticOnly === true && link.membershipId === membershipId + && typeof link.uid === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(link.uid) + && link.revision === 1 && integer(link.linkedAtMs)); + return true; + } catch { fail(); } +} +function associateReceipt(receipt, request, now) { + const view = duesView(receipt, request.membershipId); + check(view.startsAtMs <= now && now < view.endsAtMs); + const authority = applyMembershipAuthorityCommand(receipt.authority, { + membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', + commandId: request.requestId, expectedRevision: receipt.authority.revision, uid: request.targetUid, + }); + check(deriveMembershipEntitlement({ membershipAuthoritySchemaVersion: 1, record: authority, + uid: request.targetUid, asOfMs: now }).entitlement === 'current_member'); + return { authority, link: { schemaVersion: 1, syntheticOnly: true, membershipId: request.membershipId, + uid: request.targetUid, revision: 1, linkedAtMs: now } }; +} +module.exports = { readDisposableTarget, linkedReceipt, associateReceipt }; diff --git a/functions/communityDuesAssociation.test.js b/functions/communityDuesAssociation.test.js new file mode 100644 index 0000000..5d99801 --- /dev/null +++ b/functions/communityDuesAssociation.test.js @@ -0,0 +1,168 @@ +const { randomUUID } = require('node:crypto'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const { createCommunityService, parseRequest, memberView, eventView } = require('./communityActions'); +const { createAdministrationService, parseAdminRequest, readCapabilities } = require('./communityAdministration'); +const { createDuesReceipt, duesId } = require('./communityDues'); +const { readDisposableTarget, linkedReceipt } = require('./communityDuesAssociation'); +const now = 1800000000000; +const token = { email_verified: true, auth_time: now / 1000, firebase: { sign_in_second_factor: 'totp' } }; +const caps = { schemaVersion: 3, enabled: true, revision: 1, expiresAtMs: now + 3600000, + membershipApproval: false, freeEventManagement: false, duesRecording: false, membershipAssociation: true }; +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 marker = (uid = 'runner') => ({ schemaVersion: 1, syntheticOnly: true, enabled: true, uid, revision: 1, expiresAtMs: now + 3600000 }); +const pending = () => ({ schemaVersion: 1, revision: 1, applicationStatus: 'pending', authority: null }); +const receipt = () => { + const confirmationId = randomUUID(); + return createDuesReceipt('officer', caps, token, { requestId: randomUUID(), confirmationId, policyRevision: 1 }, policy, now); +}; +let docs; let db; let service; let members; let auth; let original; let membershipId; +beforeEach(() => { + ({ docs, db } = memoryDb()); original = receipt(); membershipId = original.membershipId; + docs.set(`communityDues/${membershipId}`, original); + docs.set('communityOfficers/officer', caps); + docs.set('communityMemberships/runner', pending()); + docs.set('communityAssociationTargets/runner', marker()); + auth = { getUser: jest.fn(async uid => ({ uid, emailVerified: true, disabled: false })) }; + service = createAdministrationService(db, auth, { memberView, eventView }, () => now); + members = createCommunityService(db, () => now); +}); +const command = (extra = {}) => parseAdminRequest({ action: 'admin_dues_link', accountUid: 'officer', + requestId: randomUUID(), expectedRevision: 1, reason: 'synthetic_staging_review', membershipId, + targetUid: 'runner', associationConfirmed: true, ...extra }); +const execute = (request = command(), credentials = token) => service.execute('officer', credentials, request); +const status = () => execute(parseAdminRequest({ action: 'admin_status', accountUid: 'officer' })); + +test('atomic link/activation preserves immutable evidence, term, profile and claims; replay never rewrites', async () => { + const request = command(); const result = await execute(request); + const after = JSON.stringify([...docs]); + expect(await execute(request)).toEqual(result); expect(JSON.stringify([...docs])).toBe(after); + expect(docs.get(`communityDues/${membershipId}`)).toEqual(original); + expect(docs.get('communityMemberships/runner').authority.term).toEqual(original.authority.term); + expect((await members.status('runner')).membership).toEqual({ revision: 2, applicationStatus: 'approved', entitlement: 'current_member', accessThroughMs: policy.endsAtMs }); + expect((await members.status('wrong-account')).membership.entitlement).toBe('not_entitled'); + expect(docs.get(`communityDuesLinks/${membershipId}`)).toMatchObject({ uid: 'runner', membershipId, revision: 1, syntheticOnly: true }); + expect(docs.get(`communityOfficerCommands/officer/requests/${request.requestId}`)).toMatchObject({ capability: 'membershipAssociation', targetUid: 'runner', targetId: membershipId, before: { linked: false }, after: { linked: true } }); + expect(auth.getUser).toHaveBeenCalledTimes(1); + expect([...docs.keys()].some(key => key.startsWith('members/'))).toBe(false); + expect(JSON.stringify(result)).not.toMatch(/email|stripe|claim|targetUid|runner/i); +}); +test('member cancellation preserves the original term; expiry and replay never renew or relink', async () => { + const request = command(); await execute(request); + const authority = docs.get('communityMemberships/runner').authority; + await members.mutate('runner', parseRequest({ action: 'membership_cancel', accountUid: 'runner', requestId: randomUUID(), expectedRevision: 2 })); + expect(docs.get('communityMemberships/runner').authority).toEqual(authority); + expect((await members.status('runner')).membership).toMatchObject({ applicationStatus: 'cancellation_requested', entitlement: 'current_member', accessThroughMs: policy.endsAtMs }); + const expired = createCommunityService(db, () => policy.endsAtMs); + expect((await expired.status('runner')).membership).toMatchObject({ entitlement: 'not_entitled', accessThroughMs: null }); + await execute(request); expect(docs.get('communityMemberships/runner').authority).toEqual(authority); + await expect(execute(command())).rejects.toBeDefined(); +}); +test.each([1, 2])('old capability v%s keeps old permissions without acquiring association', async (version) => { + const old = { ...caps, schemaVersion: version }; delete old.membershipAssociation; + if (version === 1) delete old.duesRecording; + docs.set('communityOfficers/officer', old); + expect(readCapabilities(old, now).membershipAssociation).toBeUndefined(); + await expect(execute()).rejects.toHaveProperty('code', 'permission-denied'); + expect((await status()).dues).toBeUndefined(); +}); +test.each([null, { ...caps, enabled: false }, { ...caps, membershipAssociation: false }, { ...caps, expiresAtMs: now }, { ...caps, forged: true }, { ...caps, membershipAssociation: 'true' }])('live permission gate denies new links and old successful 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.each([{ ...token, email_verified: false }, { ...token, auth_time: now / 1000 - 601 }, { ...token, firebase: {} }])('requires verified recent native TOTP %#', async credentials => { + await expect(execute(command(), credentials)).rejects.toHaveProperty('code', 'permission-denied'); +}); +test.each([{ amountCents: 100 }, { email: 'fictional@invalid.test' }, { role: 'member' }, { startsAtMs: now }, { associationConfirmed: false }, { targetUid: 'officer' }, { membershipId: 'email@example.invalid' }, { membershipId: [] }, { membershipId: 42 }, { expectedRevision: '1' }, { expectedRevision: 0 }])('strict link request rejects forged/coerced/unconfirmed data %#', extra => { + expect(() => command(extra)).toThrow(); +}); +test.each([undefined, null, { ...marker(), syntheticOnly: false }, { ...marker(), enabled: false }, { ...marker(), uid: 'other' }, { ...marker(), expiresAtMs: now }, { ...marker(), expiresAtMs: now + 86400001 }, { ...marker(), extra: true }, { ...marker(), revision: 0 }])('only a current exact owner-marked disposable target can link %#', async value => { + if (value === undefined) docs.delete('communityAssociationTargets/runner'); else docs.set('communityAssociationTargets/runner', value); + await expect(execute()).rejects.toBeDefined(); + expect(auth.getUser).not.toHaveBeenCalled(); expect(docs.get('communityMemberships/runner')).toEqual(pending()); + expect(docs.has(`communityDuesLinks/${membershipId}`)).toBe(false); +}); +test.each([{ uid: 'different', emailVerified: true, disabled: false }, { uid: 'runner', emailVerified: false, disabled: false }, { uid: 'runner', emailVerified: true, disabled: true }])('authoritative Auth state must match verified enabled target %#', async value => { + auth.getUser.mockResolvedValue(value); await expect(execute()).rejects.toBeDefined(); + expect(docs.get('communityMemberships/runner')).toEqual(pending()); +}); +test('missing Auth account creates no partial membership/link/command/rate', async () => { + auth.getUser.mockRejectedValue(new Error('synthetic-not-found')); + await expect(execute()).rejects.toBeDefined(); + expect(docs.has(`communityDuesLinks/${membershipId}`)).toBe(false); + expect(docs.has('communityOfficerLimits/officer')).toBe(false); +}); +test.each(['none', 'withdrawn', 'approved', 'cancellation_requested'])('nonpending member %s cannot be restored or replaced', async applicationStatus => { + docs.set('communityMemberships/runner', { ...pending(), applicationStatus }); await expect(execute()).rejects.toBeDefined(); +}); +test('absent/stale/already linked member rejects without consuming receipt', async () => { + docs.delete('communityMemberships/runner'); await expect(execute()).rejects.toBeDefined(); + docs.set('communityMemberships/runner', pending()); await expect(execute(command({ expectedRevision: 2 }))).rejects.toBeDefined(); + docs.set('communityMemberships/runner', { ...pending(), authority: original.authority }); await expect(execute()).rejects.toBeDefined(); + expect(docs.has(`communityDuesLinks/${membershipId}`)).toBe(false); +}); +test.each([false, true])('missing/nonfictional evidence never links %#', async nonfictional => { + docs.set(`communityDues/${membershipId}`, nonfictional ? { ...original, syntheticOnly: false } : null); + await expect(execute()).rejects.toBeDefined(); +}); +test.each(['suspended', 'ended', 'decision_pending'])('term state %s never activates', async state => { + docs.set(`communityDues/${membershipId}`, { ...original, authority: { ...original.authority, term: { ...original.authority.term, state } } }); + await expect(execute()).rejects.toBeDefined(); +}); +test.each([now - 2000, now + 86400001])('tampered term bounds fail closed %#', async endsAtMs => { + docs.set(`communityDues/${membershipId}`, { ...original, policy: { ...policy, endsAtMs } }); await expect(execute()).rejects.toBeDefined(); +}); +test('future/expired term and target marker crossing the final write fence never activate', async () => { + for (const end of [policy.startsAtMs - 1, policy.endsAtMs]) { + const scoped = createAdministrationService(db, auth, { memberView, eventView }, () => end); + await expect(scoped.execute('officer', { ...token, auth_time: Math.floor(end / 1000) }, command())).rejects.toBeDefined(); + } + const times = [now, now, now, policy.endsAtMs]; + const late = createAdministrationService(db, auth, { memberView, eventView }, () => times.shift() ?? policy.endsAtMs); + await expect(late.execute('officer', token, command())).rejects.toBeDefined(); + expect(docs.has(`communityDuesLinks/${membershipId}`)).toBe(false); +}); +test('same-command conflicting target or receipt rejects without rewriting', async () => { + const request = command(); await execute(request); + await expect(execute({ ...request, targetUid: 'other' })).rejects.toHaveProperty('code', 'already-exists'); + await expect(execute({ ...request, membershipId: duesId(randomUUID()) })).rejects.toHaveProperty('code', 'already-exists'); +}); +test('target admission expiry during authoritative Auth lookup leaves no partial activation', async () => { + let time = now; + docs.set('communityAssociationTargets/runner', { ...marker(), expiresAtMs: now + 100 }); + auth.getUser.mockImplementation(async uid => { time += 100; return { uid, emailVerified: true, disabled: false }; }); + const scoped = createAdministrationService(db, auth, { memberView, eventView }, () => time); + await expect(scoped.execute('officer', token, command())).rejects.toBeDefined(); + expect(docs.has(`communityDuesLinks/${membershipId}`)).toBe(false); + expect(docs.get('communityMemberships/runner')).toEqual(pending()); +}); +test('two officers cannot use one receipt for two member accounts', async () => { + docs.set('communityOfficers/second-officer', caps); docs.set('communityMemberships/second', pending()); docs.set('communityAssociationTargets/second', marker('second')); + const requests = [command(), command({ accountUid: 'second-officer', targetUid: 'second' })]; + const outcomes = await Promise.allSettled(requests.map(request => service.execute(request.accountUid, token, request))); + expect(outcomes.filter(value => value.status === 'fulfilled')).toHaveLength(1); + expect([...docs.keys()].filter(key => key.startsWith('communityDuesLinks/'))).toHaveLength(1); + expect([...docs].filter(([key, value]) => key.startsWith('communityMemberships/') && value.authority !== null)).toHaveLength(1); +}); +test('two receipts cannot activate or replace one member account', async () => { + const other = receipt(); docs.set(`communityDues/${other.membershipId}`, other); + const outcomes = await Promise.allSettled([execute(), execute(command({ membershipId: other.membershipId }))]); + expect(outcomes.filter(value => value.status === 'fulfilled')).toHaveLength(1); + expect([...docs.keys()].filter(key => key.startsWith('communityDuesLinks/'))).toHaveLength(1); +}); +test('withdrawal and association share the same member revision fence', async () => { + const outcomes = await Promise.allSettled([execute(), members.mutate('runner', parseRequest({ action: 'membership_cancel', accountUid: 'runner', requestId: randomUUID(), expectedRevision: 1 }))]); + expect(outcomes.filter(value => value.status === 'fulfilled')).toHaveLength(1); +}); +test('association-only permission sees bounded link flags, no target or policy/profile/audit data', async () => { + await execute(); const view = await status(); + expect(view.dues).toHaveLength(1); expect(view.dues[0].linked).toBe(true); + expect(view.duesPolicy).toBeNull(); expect(view.pending).toEqual([]); expect(view.events).toEqual([]); + expect(JSON.stringify(view.dues)).not.toMatch(/uid|runner|authority|audit|email|profile/i); +}); +test('invalid stored link/marker fail closed without invoking accessor', () => { + expect(() => linkedReceipt({}, membershipId)).toThrow(); expect(linkedReceipt(undefined, membershipId)).toBe(false); + const getter = jest.fn(); const target = marker(); Object.defineProperty(target, 'enabled', { enumerable: true, get: getter }); + expect(() => readDisposableTarget(target, 'runner', now)).toThrow(); expect(getter).not.toHaveBeenCalled(); +}); diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index 86201f9..bf7ed53 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', 'communityDues.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; +const FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'communityDuesAssociation.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/Community.css b/src/pages/community/Community.css index 1be8a5b..bc7ad4b 100644 --- a/src/pages/community/Community.css +++ b/src/pages/community/Community.css @@ -13,7 +13,8 @@ .community-staging a { text-decoration: underline; } .community-staging form { margin-block: 1.5rem; } .community-staging form label { display: block; margin-block: 0.75rem; } -.community-staging form input { display: block; box-sizing: border-box; width: 100%; max-width: 420px; min-height: 44px; padding: 0.5rem; color: #17241d; background: #fff; border: 1px solid #53675b; } +.community-staging form input, +.community-staging form select { display: block; box-sizing: border-box; width: 100%; max-width: 420px; min-height: 44px; padding: 0.5rem; color: #17241d; background: #fff; border: 1px solid #53675b; } .community-staging [role="note"] { padding: 1rem; diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx index cf0f237..4da4ad9 100644 --- a/src/pages/community/Community.test.tsx +++ b/src/pages/community/Community.test.tsx @@ -101,6 +101,32 @@ test('unverified accounts cannot start a mutation', async () => { await screen.findByRole('button', { name: 'Request membership' }); expect(screen.getByRole('button', { name: 'Request membership' })).toBeDisabled(); }); +test('linked test access is visible, cancellation preserves the term and refreshed expiry removes active state', async () => { + const termEnd = 1800000060000; + const active = { + membership: { + applicationStatus: 'approved', revision: 2, entitlement: 'current_member', accessThroughMs: termEnd +}, +events: [] +}; + call.mockResolvedValue(active); render(show()); await screen.findByText('Test membership active.'); + expect(screen.getByRole('button', { name: 'Request membership' })).toBeDisabled(); + call.mockResolvedValueOnce({ membership: { revision: 3, applicationStatus: 'cancellation_requested' } }); + call.mockResolvedValueOnce({ ...active, membership: { ...active.membership, revision: 3, applicationStatus: 'cancellation_requested' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel membership' })); + await screen.findByText('Request status: cancellation requested.'); + expect(screen.getByText('Test membership active.')).toBeInTheDocument(); + expect(call.mock.calls[1][1]).toMatchObject({ action: 'membership_cancel', expectedRevision: 2, accountUid: 'runner-a' }); + call.mockResolvedValueOnce({ + ...active, +membership: { + ...active.membership, revision: 3, applicationStatus: 'cancellation_requested', entitlement: 'not_entitled', accessThroughMs: null +} +}); + fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); + await screen.findByText('Test membership is not active.'); + expect(screen.queryByText(/Access continues through/)).not.toBeInTheDocument(); +}); test('an obsolete account status completion is ignored', async () => { let finish!: (value: unknown) => void; call.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 646016f..52e140d 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -128,6 +128,7 @@ export default function Community() {
Signup requests await officer confirmation of dues. They do not grant paid membership.
+{status.membership.entitlement === 'current_member' ? 'Test membership active.' : 'Test membership is not active.'}
{status.membership.entitlement === 'current_member' && status.membership.accessThroughMs && (diff --git a/src/pages/community/CommunityAdmin.test.tsx b/src/pages/community/CommunityAdmin.test.tsx index d2b1c86..626a524 100644 --- a/src/pages/community/CommunityAdmin.test.tsx +++ b/src/pages/community/CommunityAdmin.test.tsx @@ -87,6 +87,79 @@ duesRecordingOpen: true, nextDuesCursor: null, ...extra }); +const linkId = `stage_dues_${'a'.repeat(64)}`; +const linkReceipt = { + membershipId: linkId, amountCents: 100, currency: 'USD', method: 'fictional_external', termId: 'fictional-term', startsAtMs: 1800000000000, endsAtMs: 1800000060000, linked: false +}; +const linkState = (extra = {}) => duesState({ + capabilities: { + membershipApproval: false, + freeEventManagement: false, + duesRecording: false, + membershipAssociation: true, +}, + dues: [linkReceipt], +duesPolicy: null, +duesRecordingOpen: false, +...extra, +}); +async function fillLink() { + fireEvent.change(await screen.findByLabelText('Fictional receipt to link'), { target: { value: linkId } }); + fireEvent.change(screen.getByLabelText('Disposable account ID'), { target: { value: 'disposable-member' } }); + fireEvent.change(screen.getByLabelText('Pending membership revision'), { target: { value: '3' } }); + fireEvent.click(screen.getByLabelText('I confirm this fictional receipt belongs to this disposable test account.')); +} +test('separate linking permission sends only explicit IDs, pending revision and test confirmation then rereads', async () => { + call.mockResolvedValue(linkState()); render(show()); await fillLink(); + expect(screen.queryByText('Record fictional external dues')).not.toBeInTheDocument(); + call.mockResolvedValue(linkState({ dues: [{ ...linkReceipt, linked: true }] })); + fireEvent.click(screen.getByRole('button', { name: 'Link and activate test membership' })); + await screen.findByText('Action confirmed. Current server status is shown.'); + expect(call.mock.calls[1][1]).toEqual({ + action: 'admin_dues_link', accountUid: 'officer', requestId: '123e4567-e89b-42d3-a456-426614174000', membershipId: linkId, targetUid: 'disposable-member', expectedRevision: 3, associationConfirmed: true, reason: 'synthetic_staging_review' +}); + expect(call.mock.calls[2][1]).toEqual({ action: 'admin_status', accountUid: 'officer' }); + expect(screen.getByRole('button', { name: 'Link and activate test membership' })).toBeDisabled(); + expect(screen.getByText('Linked test term; verify current access on the member page.', { exact: false })).toBeInTheDocument(); +}); +test('dues recording or approval does not grant linking or show target controls', async () => { + call.mockResolvedValue(duesState()); render(show()); await screen.findByText('Record fictional external dues'); + expect(screen.queryByLabelText('Disposable account ID')).not.toBeInTheDocument(); +}); +test('offline link uncertainty hides the target/receipt controls; exact saved retry is reused', async () => { + call.mockResolvedValue(linkState()); render(show()); await fillLink(); + call.mockRejectedValueOnce(new Error('synthetic offline')); + fireEvent.click(screen.getByRole('button', { name: 'Link and activate test membership' })); + await screen.findByText(/Action could not be confirmed/); + expect(screen.queryByLabelText('Disposable account ID')).not.toBeInTheDocument(); + const request = call.mock.calls[1][1]; fireEvent.click(screen.getByRole('button', { name: 'Retry saved action' })); + await screen.findByText('Action confirmed. Current server status is shown.'); + expect(call.mock.calls[2][1]).toEqual(request); +}); +test('revoked status clears linked receipt and target controls', async () => { + call.mockResolvedValue(linkState({ dues: [{ ...linkReceipt, linked: true }] })); render(show()); + await screen.findByText(/Linked test term; verify current access/); + call.mockRejectedValueOnce(new Error('synthetic revoked')); + fireEvent.click(screen.getByRole('button', { name: 'Refresh officer status' })); + await screen.findByText(/Officer access is unavailable/); + expect(screen.queryByText(/Linked test term; verify current access/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Disposable account ID')).not.toBeInTheDocument(); +}); +test('account switch while linking discards obsolete status and saved command without leaking target', async () => { + call.mockResolvedValue(linkState()); const rendered = render(show()); await fillLink(); + let resolve: (value: any) => void = () => undefined; + call.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + fireEvent.click(screen.getByRole('button', { name: 'Link and activate test membership' })); + mockUser = { uid: 'other' }; + call.mockResolvedValue(state({ + capabilities: { membershipApproval: false, freeEventManagement: false }, pending: [], + })); + rendered.rerender(show()); + await act(async () => resolve(linkState({ dues: [{ ...linkReceipt, linked: true }] }))); + expect(screen.queryByLabelText('Disposable account ID')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry saved action' })).not.toBeInTheDocument(); + expect(call.mock.calls.filter(([, request]) => request.action === 'admin_dues_link')).toHaveLength(1); +}); 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)'); diff --git a/src/pages/community/CommunityAdmin.tsx b/src/pages/community/CommunityAdmin.tsx index 5dff655..c43338e 100644 --- a/src/pages/community/CommunityAdmin.tsx +++ b/src/pages/community/CommunityAdmin.tsx @@ -169,7 +169,7 @@ closesAtMs: Date.parse(String(values.get('closes'))),
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. + links no account and grants no Auth role. Linking is a separate fictional test action.
Member signup and events {isLoading &&Loading account…
} @@ -190,7 +190,8 @@ closesAtMs: Date.parse(String(values.get('closes'))),- Test only. No money moves. Receipts remain unlinked and grant no account access. + Test only. No money moves. Recording alone leaves receipts unlinked + and grants no account access. Do not enter real payment references or member details.
{status.duesPolicy ? ( @@ -260,6 +261,69 @@ policyRevision: status.duesPolicy.revision )} > ) :No test dues configuration. Ask the staging specialist.
} ++ Test only. Use the exact disposable account ID and pending revision + supplied by the specialist. The server checks the account and preserves + the receipt term. No Auth role or money changes. +
+ ++ Refresh the member page afterward to verify test access. + Do not relink or change the term. +
+No fictional dues receipts.
} {(status.dues || []).map((receipt) => (