diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 7fe924d..d6d41b6 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**COMMUNITY-004 / [#737](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/737) — SOURCE IN REVIEW:** October 15 owner rule requires current paid membership plus existing verified email and waiver for new free-event entries. The transaction checks current authority after reads; cancellation after expiry/suspension and immutable retry remain safe. Unit/UI and emulator regressions cover nonmembers, exact expiry, suspension, wrong UID, forgery, final seats and no-write denials. $25 individual/$30 family calendar-year policy is recorded, not activated; online dues and separate family accounts under a paid Family ID are selected for first launch; minor/privacy/retention and actual payment integration remain separate. Requested [policy drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) are unapproved and unused. No deployed/prod/real-payment claim; #731 stays closed with final6186640177. + **RACE-SIM-001C / #731 — NATIVE TESTS VERIFIED, HANDOFF GATED:** actual fictional lifecycle/privacy/expiry/mobile and 38 native evaluations/27 distinct checks passed on 2026-10-14, including real Auth/App Check/caller/current-authority boundaries and actual UI committed/lost-response retry. Exact retry changed none of six protected record update times; recipient-loss replay/revoke and terminal/current-revocation guards passed. Two fresh accounts/26 owned docs cleaned with fences/late absence; private browser SDK state/cache cleared, unused receiver closed/zero captures/port refused. Human permission-OFF confirmation and reviewed/current-CI handoff remain before closure or supervised use with fresh fixtures. Officer impact: verified fictional flow only; no real policy/payment/production readiness. Officer documentation: STAGING_COMMUNITY, entrypoints, EVENTS_SHOP_MEMBERS, SYSTEM_MAPS and root checkpoints. Deployment evidence: existing v6/669e2fb/archive94380430 and Hosting e21be35 unchanged, [native receipt6186206758](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186206758); no new deploy or full backend completion. Earlier #731/#732 source/anonymous-only entries below are historical. **RACE-SIM-001C / #731 — STAGING DEPLOYED, OPEN:** PR734/merge `669e2fb`, exact PR38455181292/main38455775086 six jobs/every step, native v6/downloaded archive/resource/owner/MFA/invoker/unchanged profiles and dependent Hosting/public assets verified. Anonymous 390×844 screen passes, not signed-in lifecycle/security/cleanup. Officer impact: planned supervised fiction remains NOT AVAILABLE YET until that acceptance. Officer documentation: dated STAGING_COMMUNITY/entrypoints/maps/root checkpoint. Deployment evidence: [native receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731); earlier source-only/not-deployed entries are historical. No production/real payments or broader goal completion. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9e02749..c7af2f1 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -29,6 +29,8 @@ RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) pr ## 1. Program outcome +**October 15 scope / #737 source:** free-event admission additionally requires canonical current membership inside the booking transaction. A pending request or verified email alone is insufficient. Expired/suspended membership still permits cancelling an existing signup; paid-term voluntary cancellation preserves access through the original end. Dave supplied $25 individual/$30 family calendar-year dues and no extra discretionary approval after verified identity, waiver and confirmed payment. Online dues and shared paid Family ID enrollment are now first-launch scope; final legal/data/guardian wording and ordinary production activation remain separate; the [drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) are not policy approval. Existing #707 receipts below describe their older behavior; #737 is not yet deployed. + **#707 staging slice:** implement and test pending membership signup/withdrawal, paid-term cancellation intent and free-event signup/cancellation using one isolated service. No payment/privileged membership activation is prerequisite for these nonfinancial requests; those workflows remain separate. Fixed owner-approved staging release must verify exact merged source and all six main CI jobs, native source archive, explicit runtime/build identities and resource limits before staging Hosting. No production release is authorized. **Membership cancellation policy, 2026-10-11 — #705:** Dave approved retaining access until the already-paid annual term ends, without an automatic refund. Versioned backend source configuration and boundary regressions are a small dependency-clearing slice only. A member-owned durable cancellation request, retries/audit, UI and disposable hosted staging proof remain separate implementation work; do not route this request through legacy admin race cancellation or mark the membership ended early. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 927e0c6..947bc36 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,6 +1,8 @@ # MPRC Website: Officer Start Here -**#731 latest checkpoint — NATIVE STAGING TESTS VERIFIED 2026-10-14:** the [supervised handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) now has actual signup, fictional credit return, recipient-accepted same-bib transfer, privacy, expiry, native security and committed/lost-response retry proof. Ask Dave/platform specialist for the complete dated acceptance/cleanup receipt and fresh fake fixtures before using it. The issue remains open until those handoff gates are recorded; production and real payments are **NOT AVAILABLE YET**. Older #731/#732 source-only or anonymous-only notes below are historical. +**Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./docs/officers/STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. + +**#731 final checkpoint — CLOSED October 15:** [native acceptance and cleanup](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186640177) are complete, including the human-confirmed local-access Block and final tab/kernel cleanup. No deleted fixture or resolved permission question is reopened. Production and real payments remain **NOT AVAILABLE YET**. **#731 fictional race screen — SOURCE PREPARED, NOT AVAILABLE YET:** the [planned review](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) covers signup, fictional credit return and private recipient-accepted bib transfer. Local screen tests are not hosted proof. Dave/platform specialist must verify staging release, security, real screens and cleanup first; production and real payments remain untouched. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index ce71526..b08d4a2 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -745,6 +745,8 @@ CI/staging must eventually include: ## 7. Change and release process +**#737 source-only member admission change:** new free-event entries read membership with event/registration in one transaction and evaluate current entitlement after reads. Keep cancellation available after expiry/suspension and preserve exact command replay. No schema migration, new dependency/service/IAM/cost setting or production guard change. Run the new unit/UI and existing demo emulator transaction suite before a later exact-source release; record Function archive and Hosting publication separately. The requested [privacy/waiver drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) must not become an accepted document until approved. #731 acceptance/cleanup remains closed and its deleted fixtures must not be reused. + ### Before merge 1. Link one issue from [GITHUB_ISSUES.md](./GITHUB_ISSUES.md) or the corresponding GitHub issue. diff --git a/SECURITY.md b/SECURITY.md index c0f3cb7..f3c105d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -264,6 +264,8 @@ This is source-only attack-surface reduction. The component had no runtime route ### Authorization +**#737 source-only free-event admission:** verified email and a waiver cannot substitute for current membership. Every fresh join reads the caller-bound canonical authority in the same transaction as capacity and checks its term after reads. Missing/expired/future/suspended/wrong-UID/malformed authority denies without business, rate or command writes. Saved success is immutable; cancellation after expiry/suspension remains possible. Browser entitlement/payment/role fields never grant access. No new claim, permission, production fallback or deployed enforcement is asserted. + **#716 fictional dues boundary — staging verified 2026-10-12:** `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 verified native Function v3/archive and Hosting d212fab, twenty-two hosted negative cases, retry/race/pagination, actual UI/offline/mobile/revocation and exact fixture cleanup. Rules430 checks passed. Fresh specialist-supplied fixtures and separate real-policy/production approval remain necessary. **#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. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index f071771..d9629d0 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -26,6 +26,8 @@ The repository already contains a substantial prototype: a public React site, Fi ## 1. Goals and non-goals +**October 15 member-launch policy / #737 source:** $25 individual and $30 family dues cover the calendar year; online dues and separate verified family accounts sharing a paid Family ID are selected for first launch; final privacy/waiver/retention and guardian handling remain undecided. Verified email, waiver acceptance and confirmed current dues suffice without another member-approval step. New free-event admission now reads canonical membership in the same transaction as capacity and samples the server clock after reads; expiry/suspension deny fresh joins but do not block existing cancellation. Exact saved success remains read-only. The staging/production environment guards and synthetic activation limits are unchanged. This is source, not a new deployment. See [the minimum launch scope](./docs/MEMBER_LAUNCH_SCOPE.md), [the officer procedure](./docs/officers/STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only) and [review-only policy drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md). + **Fictional external dues — #716, staging verified 2026-10-12:** 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 PR717/merge d212fab and all six exact PR/main jobs passed; native Function v3/archive, staging Hosting, genuine attestation/TOTP, UI/offline/race/pagination/revocation and exact 51-document/two-account cleanup passed. See the staging guide for release-command caveat and fresh-fixture handoff; 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. diff --git a/docs/MEMBER_LAUNCH_SCOPE.md b/docs/MEMBER_LAUNCH_SCOPE.md new file mode 100644 index 0000000..7418602 --- /dev/null +++ b/docs/MEMBER_LAUNCH_SCOPE.md @@ -0,0 +1,58 @@ +# Minimum member launch + +**October 15, 2026 decisions.** Dave approved building and deploying the agreed release. Online membership dues are included; paid MPRC race checkout, race refunds and bib transfers follow a separate release. This document records scope and technical requirements, not deployed payment capability or legal-policy approval. + +## Member experience + +1. Read public running, joining and contact information without an account. +2. Create a personal account and verify its email address. +3. Read and accept the club-approved waiver when it becomes available. +4. Choose individual membership at $25 or family membership at $30 for the full calendar year. +5. Pay once through hosted Checkout, or join an already-paid family using its private invitation. +6. Receive current-member status automatically when identity, waiver and confirmed paid-term requirements are satisfied. +7. Sign up for and cancel free events within their capacity and signup windows. + +No additional discretionary membership-approval queue follows satisfied requirements. Officers retain a narrowly authorized, audited dues-confirmation control for the in-person convenience; the public site must not advertise an external-payment option or its instructions. Payment confirmation must never be a browser-editable Paid field or a role toggle. + +The approved voluntary cancellation rule preserves access through the paid term without an automatic refund. No automatic renewal subscription, prorating, surcharge, grace period or future-year advance purchase is inferred. Use the club's local calendar year in America/Los_Angeles: January 1 through the exclusive start of January 1 the following year. Renewal is a separate one-time purchase for its own year; no automatic subscription or grace period is selected. Persist the year and exact server-owned boundaries before payment activation. + +## Family membership + +Participating family members use separate verified accounts under one stable opaque Family ID and share its paid calendar-year term. They do not each pay dues. The price is per family membership, not per family account. + +The Family ID identifies the family; it is not sufficient authorization to join one. Proposed enrollment uses a privately shared, expiring, single-use invitation tied to the family. A signed-in verified recipient deliberately redeems it; the server checks the paid term and required consent, binds only that UID and records one immutable result. Retrying the same redemption creates no second relationship. Wrong, reused, expired or revoked invitations deny without revealing another family's details. Public search and automatic email matching are excluded. No custom invitation email service is required; the existing member can share the private invitation. + +Keep a shared family term/payment reference and separate per-person account associations/waiver evidence. Do not weaken the existing one-UID membership authority by assigning one account to everybody. One family payment must not be represented as several independent payments. Family lapse or a provider reversal must not leave a child association granting access indefinitely. Do not grant eligibility from a Family ID stored only in a browser profile or an old Auth claim. + +Guardian consent, minor accounts, any invitation age requirements and treatment when someone leaves a family still need approved handling. Do not collect children's data or allow one adult to sign another adult's waiver by inference. No household-size limit or relationship test has been invented. + +## Payment and activation boundary + +Use the existing Stripe direction and a one-time hosted payment flow. Reuse reviewed dues checkout/activation/reversal contracts where compatible; their presence in source is not a complete provider integration. + +- The server chooses the plan, integer USD cents (2500/3000), exact year/term, consent version and account/family binding. +- Persist a caller-scoped command and immutable expected payment before the provider call. Fence concurrent duplicate purchases and retain the same provider idempotency key across uncertain retries. Do not automatically issue a new purchase after an ambiguous result. +- A browser return, email match or unchecked provider field never proves payment. Verify provider signature, exact account, test/live realm, paid state, amount, currency, metadata/schema and immutable order/term binding. +- Process webhook events durably and idempotently, including webhook-before-redirect, duplicate/out-of-order events, wrong/failed/unpaid events, late payment after calendar rollover and committed/lost-response retry. +- Derive the member/family term and personal entitlement automatically from confirmed payment and required identity/consent. A stale revision must not cause a paid order to be silently lost; retain a bounded reconciliation/recovery path. +- A successful family invitation consumes existing payment evidence and never creates a new charge. A suspended or expired family cannot issue active-member access. +- Provider refunds/disputes and exceptional/manual corrections require a safe reviewed response even though self-service refund issuance is outside this minimum release. Do not invent the club's refund or partial-payment/reversal policy. +- Test with fictional data and Stripe test mode in isolated staging first. Live provider activation requires the final policy, protected release and provider/finance evidence. Never test a charge, refund, role change or email against production. + +Any required tax treatment or change to advertised totals needs the club's approved decision. No unapproved promotion, tax, shipping, processing surcharge, stored card credential or recurring subscription is introduced. + +## Low-cost operation + +Keep Firebase and runmprc@gmail.com as the club identity. Use named officer accounts with scoped permissions, MFA and a safe recovery procedure. Dave remains sole backend release/recovery owner; that accepted continuity risk does not appoint a new backup. + +Prefer static public information, bounded event/member reads, no polling and the existing small zero-minimum Functions. Use built-in Auth verification/reset/recovery and Stripe-hosted payment UI; custom marketing/event/invitation mail can wait. Do not add a subscription service, database rewrite or general importer. + +Check current Firebase trial/usage and the club's actual Stripe pricing privately before deciding ongoing operating spend. Existing $10 usage alerts are not a bill cap or an approved recurring budget. Stripe's [published pricing](https://stripe.com/pricing) is a reference, not proof of this account's terms; validate the provider account and approved fees before live release. + +## First engineering slice and release evidence + +[#737](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/737) changes new free-event admission only: current membership and capacity are read in one transaction; after-read expiry/suspension blocks new entry, while existing cancellation and exact saved retry remain safe. It does not add annual activation, Checkout, family enrollment, IAM or deployment. See the [officer source procedure](./officers/STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). + +Next, complete the real account-recovery qualification and one coherent test-mode membership purchase/activation/family-enrollment workflow. Keep generic commerce/race/shop work out unless it is an actual dependency. Qualify current Rules/App Check, narrow runtime identities, exact backend-first publication, compatible rollback/isolated restoration and an officer screen-only walkthrough for the selected release. + +The [privacy, retention and waiver drafts](./legal/MEMBER_LAUNCH_DRAFTS.md) remain for club review. Source changed, tests passed, merge, staging deployment, production deployment, provider configuration and real behavior must be evidenced separately. #731's accepted fictional race evidence remains closed; no deleted fixture or sealed harness is reused. diff --git a/docs/legal/MEMBER_LAUNCH_DRAFTS.md b/docs/legal/MEMBER_LAUNCH_DRAFTS.md new file mode 100644 index 0000000..4cff50a --- /dev/null +++ b/docs/legal/MEMBER_LAUNCH_DRAFTS.md @@ -0,0 +1,80 @@ +# Member launch privacy notice and waiver drafts + +**DRAFT FOR CLUB REVIEW — NOT APPROVED OR PUBLISHED.** These proposed words are for the minimum membership/free-event release. They are not used by the website, a checkbox, or a server policy. The club must approve the facts, retention choices and final waiver with its legal/insurance adviser before real people accept them. No claim of legal compliance or insurance coverage is made. + +## Membership decisions already supplied + +See the [minimum launch scope](../MEMBER_LAUNCH_SCOPE.md) for the selected online-dues and family workflow. + +On October 15, 2026, Dave specified $25 per individual and $30 per family for the full calendar year. A verified account with accepted waiver and confirmed current dues may join free events without another discretionary membership approval. Officers may confirm dues through the admin panel. Do not advertise an external payment option or add payment instructions to public copy. + +Dave subsequently selected online dues for the first launch and separate verified accounts for participating family members under a shared Family ID, without paying again. The Family ID identifies the shared paid calendar-year membership; a private, single-use family invitation is the proposed enrollment proof, rather than treating knowledge of the stable ID as authorization. Use the club's America/Los_Angeles calendar year, with separate one-time annual renewal. Guardian/minor handling and final privacy/retention/waiver wording still need approval. Use one-time hosted Checkout, not an automatic subscription; keep paid-race checkout/refunds/transfers separate. This document enables no live charge. The existing cancellation rule retains access through the paid term without an automatic refund. + +## Proposed privacy notice + +*Effective date and version: [club to approve]. Contact: runmprc@gmail.com [confirm monitored privacy contact].* + +Mid-Peninsula Running Club uses this website to manage club accounts, membership and free-event signups. + +**Information we use.** We use your name, email address, account identifier and email-verification status to maintain your account. We keep your membership plan, calendar-year term, confirmed payment reference or authorized dues confirmation, and the related audit record. Family accounts also have a Family ID and their own account association; invitations are private access credentials, not public roster information. For an event, we keep your signup/cancellation status and the version and time of your waiver acceptance. Security and operational records help us protect accounts, prevent duplicate requests and investigate errors. + +**Service providers.** Stripe-hosted Checkout collects payment details for online dues; the club keeps a minimal provider payment reference and verified result rather than card information. Google Firebase provides account authentication and club records. Google App Check helps assess requests from the website. These providers may process network, browser and device information to operate and protect their services. The final notice will identify the actual production hosting provider, any monitoring service and any additional processing that is enabled. This draft does not authorize analytics, advertising, member tracking or new providers. + +**Who can see information.** You can see your own account and signup status. Authorized officers receive only the information needed for their assigned club duties. Member records and participant lists are not public. The club will document any other required disclosure and its purpose before enabling it. + +**Your choices.** You may correct your account details and cancel your own event signup through the available website controls. Contact the club to ask about access, correction or account deletion. Do not email a password, authentication code, payment credentials or private action link. Account deletion is separate from records the club must retain for an approved purpose or legal hold; the club will explain what is retained and why. + +**How long records remain.** The club retains each category according to the approved schedule below, then deletes or anonymizes it through its documented process. Backups and service-provider copies may follow a different deletion cycle, which the final schedule must explain. No duration or immediate-erasure promise is established by this draft. + +**Children and families.** Participating family members use separate verified accounts associated with the same Family ID and paid term. [Insert the approved guardian-consent and minor-account approach. Do not enable collection from children or let one adult accept another adult's waiver by inference.] + +**Updates and browser privacy.** The final notice will state how material changes are announced, how to find the current version, which cookies/storage and monitoring are used, and how the site responds to browser privacy signals. Fill these from verified production behavior before publication. + +## Retention decisions for club approval + +| Category | Purpose | Duration and trigger to approve | Access and deletion/hold treatment | +| --- | --- | --- | --- | +| Account/profile | Sign-in and own-account service | [duration after closure/inactivity] | Owner; minimum authorized identity support; provider deletion limits documented | +| Membership/dues-confirmation history | Current term and correction/accounting evidence | [duration after term or other approved trigger] | Scoped membership/finance officers; preserve holds and audit integrity | +| Event signup/cancellation | Capacity and participation administration | [duration after event] | Participant and scoped event officers; no public roster | +| Waiver/version/acceptance | Evidence of the accepted participation terms | [insurance/legal-approved trigger and duration] | Restricted access; retention/deletion and guardian treatment approved | +| Audit/idempotency/abuse records | Explain changes, prevent duplicate effects and protect service | [per-category duration and expiry/tombstone design] | Server-only writes; do not erase duplicate barriers or evidence accidentally | +| Technical logs and security incidents | Diagnose failures and respond privately | [separate routine-log and incident durations] | Minimum responders; redaction and legal-hold procedure | +| Backups and provider copies | Restore a compatible service | [backup lifetime and deletion cycle] | Dave's approved recovery path; no restoration into personal workspaces | + +These are decision fields, not default legal periods. Prefer not collecting phone numbers, dates of birth, emergency contacts, photos or addresses for this limited release unless the club establishes a need and approves their handling. + +## Proposed adult participation waiver + +*Version: [club to approve]. Applies to: [specific club activities/events].* + +I understand that running and club activities involve risks, including falls, collisions, traffic, uneven surfaces, weather, illness, injury and death. I choose to participate voluntarily and will consider my ability and the conditions before taking part. I will follow reasonable safety instructions and stop if I cannot participate safely. I understand that other participants and the public may share the route. + +To the extent permitted by applicable law, I agree to release Mid-Peninsula Running Club and its authorized officers and volunteers from claims arising from my participation, including claims based on ordinary negligence. This wording does not purport to waive rights or claims that cannot lawfully be waived. **The club's legal/insurance adviser must approve the covered parties, activities and release language before this paragraph is used.** + +I have had the opportunity to read this waiver and ask the club questions before accepting it. My acceptance applies to me, not another adult. It does not give permission to use my image for marketing or authorize additional collection of health information. + +Proposed checkbox: **I have read and accept the displayed participation waiver.** + +A separate minor/guardian version and verified acceptance process remain undecided. Do not treat this adult draft as a guardian waiver or assume an electronic checkbox alone satisfies every insurance requirement. + +## Review and activation procedure + +**Purpose:** approve truthful minimum-launch wording without publishing a draft. **Approver:** club officer and the club's chosen privacy/legal/insurance owner. Dave retains backend release/recovery authority. **Prerequisites:** selected launch fields/providers, family/minor decisions and actual operating/recovery procedures. + +1. Review the proposed notice, field list and waiver with the responsible club advisers. +2. Fill each decision field and remove any statement that does not match the selected service. +3. Approve explicit durations, deletion/hold behavior and service-provider/backup limitations. +4. Approve family/minor participation and whose acceptance is required. +5. Record the final text, version, effective date, named approver and approval date in the issue without private advice or member records. +6. Ask the maintainer to wire only that approved version and its immutable acceptance evidence. +7. Observe synthetic staging acceptance, refusal, version-change, retry and account-binding checks. +8. Confirm exact production publication separately through the protected release process. + +**Expected result and success proof:** approved versioned wording and matching synthetic technical proof; a drafted file or green test does not prove policy approval. **Stop conditions:** unfilled decisions, unverified claims, real-data examples, missing adviser review, or a checkbox referencing this draft. **Undo:** hold publication and keep real enrollment unavailable; use a reviewed compatible correction after any approved release, preserving prior acceptance evidence. **Escalation:** club privacy/insurance owner for wording and Dave for technical evidence/release. + +## Primary references checked October 15, 2026 + +- [FTC: Protecting Personal Information](https://www.ftc.gov/business-guidance/resources/protecting-personal-information-guide-business) supports minimizing collection, controlling access and establishing a written retention/disposal policy. It does not choose MPRC's durations. +- [California Attorney General: privacy-policy guidance](https://oag.ca.gov/privacy/facts/online-privacy/privacy-policy) is background for an accurate public explanation; the club's advisers determine applicable obligations. +- [RRCA: Manage Club Members and Waivers](https://www.rrca.org/education/for-club-leaders/club-members-and-waivers/) provides running-club waiver guidance. Its insurer-specific requirements are not evidence that MPRC belongs to that program, is insured by it, or has approved this draft. The wording above is an original review draft, not a copy of RRCA's waiver. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 8b683b2..5947c4b 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1,6 +1,8 @@ # Events, Shop, Members, and Money -**#731 latest checkpoint — NATIVE STAGING TESTS VERIFIED 2026-10-14:** [current handoff](./STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) records actual fictional signup/credit return/same-bib transfer/revoke, privacy, expiry/mobile and native security/committed lost-response retry. Use only fresh fake fixtures after Dave/specialist supplies the complete dated acceptance/cleanup receipt. #731 stays open until final handoff gates are recorded; real refunds, payments, club policy and production remain **NOT AVAILABLE YET**. Older #731/#732 source/anonymous-only notes below are historical; never use legacy controls or real details for these tests. +**Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](../legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. + +**#731 final checkpoint — CLOSED October 15:** [native acceptance and cleanup](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186640177) are complete, including the human-confirmed local-access Block and final tab/kernel cleanup. No deleted fixture or resolved permission question is reopened. Production and real payments remain **NOT AVAILABLE YET**. **#731 member screen — SOURCE PREPARED, NOT AVAILABLE YET:** the [planned screen review](./STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) covers fictional signup, credit return and privately named bib offers/acceptance/revocation. Native staging release/security/screens/cleanup still require the specialist; local service doubles do not prove hosted behavior. No real refund, payment, club policy or production change is available. diff --git a/docs/officers/README.md b/docs/officers/README.md index 20f853b..a12338b 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,6 +1,8 @@ # Officer Website Handbook -**#731 latest checkpoint — NATIVE STAGING TESTS VERIFIED 2026-10-14:** [supervised fictional race review](./STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) has actual lifecycle/privacy/expiry/mobile and genuine security/committed-retry proof. A complete dated acceptance/cleanup receipt and fresh specialist-supplied fake fixtures are required before use. #731 stays open until the final handoff gates are recorded; production and real payments remain **NOT AVAILABLE YET**. Older #731/#732 source/anonymous-only notes below are historical. +**Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](../legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. + +**#731 final checkpoint — CLOSED October 15:** [native acceptance and cleanup](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186640177) are complete, including the human-confirmed local-access Block and final tab/kernel cleanup. No deleted fixture or resolved permission question is reopened. Production and real payments remain **NOT AVAILABLE YET**. [#731's fictional race screen](./STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) is source prepared, **NOT AVAILABLE YET**. A separate dated native release/security/screen/cleanup receipt is required before the planned review. No real payment, policy or production change is supplied. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 9c73087..70ac70a 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -27,6 +27,28 @@ ## What the actions mean +## Current membership for free events — #737 source only + +**Status:** source changed; **NOT AVAILABLE YET** on the deployed staging website or in production. Earlier #707/#711/#724/#731 receipts describe their dated releases, not this change. #731's final native acceptance and cleanup are [complete](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186640177); do not reopen that test or reuse its deleted fixtures. + +**Purpose:** require current membership for a new free-event signup while allowing cancellation after membership ends. **Approver and escalation:** Dave, sole release/recovery owner. **Prerequisites:** a later exact reviewed release and fresh specialist-supplied fictional accounts, current/expired terms and a synthetic waiver/event. No real data or payment is used. + +1. Ask the specialist for the exact source, test, Function and website proof. +2. Open the supplied staging link and confirm the test-only notice. +3. Sign in with the supplied verified nonmember account. +4. Confirm the new-signup control and waiver checkbox are unavailable. +5. Use the supplied current test member and accept the displayed test waiver. +6. Sign up and confirm one place is booked without another officer approval. +7. After the test term ends, refresh status and confirm a new signup is unavailable. +8. Cancel an existing signup and confirm its place is released once. +9. Sign out and ask the specialist for owned-fixture cleanup proof. + +**Expected result and success proof:** the server reads membership in the seat transaction and checks the time after reads. Pending, missing, future, expired, suspended, wrong-account or malformed authority cannot create a signup. Voluntary cancellation preserves eligibility through the original term. Saved success can be replayed without another booking; a fresh command needs current entitlement. Membership expiry does not remove the existing cancellation control. Unit/screen tests and local emulator or CI transaction tests are separate from hosted proof. + +**Stop conditions:** a nonmember can book, a former member cannot cancel, counts change on a saved retry, a real record/payment appears, or deployed-source proof is absent. **Undo:** hold publication; Dave/specialist uses a compatible reviewed correction or rollback after any later release. No officer changes records or permissions. Production/legal-policy activation remains separate. + +The [requested privacy/waiver drafts](../legal/MEMBER_LAUNCH_DRAFTS.md) are for club review only. Dave supplied $25 individual/$30 family calendar-year dues and current-member free-event eligibility. Those values are not loaded into this synthetic service; online dues and separate family accounts sharing one paid Family ID are selected for launch, while final privacy/waiver/retention and guardian handling still require decisions. Public payment instructions are not added. + ## Fictional race member screen — #731 source prepared **Status:** NATIVE STAGING TESTS VERIFIED 2026-10-14. The heading is retained for existing links. This is a supervised fictional test, not club launch. Use it only after Dave/platform specialist supplies the complete dated acceptance and cleanup receipt plus fresh fake fixtures. #731 remains open until its final handoff gates are recorded; production and real payments are **NOT AVAILABLE YET**. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 3944008..8de0f2d 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -113,6 +113,20 @@ Text alternative: a recent authenticated officer with a separate expiring dues p ## Staging community lifecycle — #707, first hosted release verified 2026-10-11 +### New free-event eligibility — #737 source only + +```mermaid +flowchart LR + Account["Verified own account + native App Check"] --> Request["New signup + current displayed waiver"] + Request --> Transaction["One transaction: current membership + event + own signup"] + Transaction --> Gate{"Current term after reads; event open; place available?"} + Gate -- Yes --> Seat["Book once; save waiver and retry receipt"] + Gate -- No --> Deny["No signup, capacity or command writes"] + Former["Existing signup; membership expired or suspended"] --> Cancel["Cancel once; release place; preserve waiver evidence"] +``` + +Text alternative: the new source requires current server membership and waiver acceptance in the same transaction as event capacity; denied signup writes nothing, while expiry or suspension still allows cancelling an existing place. This #737 source is not deployed proof; production remains disabled. + 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**. ```mermaid diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js index f6c2893..1702cb7 100644 --- a/functions/communityActions.emulator.test.js +++ b/functions/communityActions.emulator.test.js @@ -27,6 +27,7 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', if (app) await app.delete(); }); test('twenty concurrent final-seat requests confirm one; parallel cancellation releases once', async () => { + await Promise.all(Array.from({ length: 20 }, (_, n) => db.doc(`communityMemberships/fixture-${n}`).create(approvedMember(`fixture-${n}`, now)))); const result = await Promise.allSettled(Array.from({ length: 20 }, (_, n) => service.mutate(`fixture-${n}`, join()))); const winners = result.flatMap((entry, index) => entry.status === 'fulfilled' ? [index] : []); expect(winners).toHaveLength(1); @@ -55,6 +56,7 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', }, 45000); // Concurrent native transactions may outlast Jest's five-second unit default. test('cancelled-event availability is a read-only projection and preserves own confirmed signup evidence', async () => { const uid = 'fixture-status-member'; + await db.doc(`communityMemberships/${uid}`).create(approvedMember(uid, now)); await service.mutate(uid, join()); const event = db.doc('communityEvents/fixture-event'); const registration = event.collection('registrations').doc(uid); @@ -67,6 +69,41 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', expect(after.map(doc => doc.data())).toEqual(before.map(doc => doc.data())); expect(after.map(doc => doc.updateTime.toMillis())).toEqual(before.map(doc => doc.updateTime.toMillis())); }); + test('current membership gates admission; expiry preserves retry and cancellation without new writes', async () => { + const uid = 'fixture-membership-required'; + const event = db.doc('communityEvents/fixture-event'); + const membership = db.doc(`communityMemberships/${uid}`); + await expect(service.mutate(uid, join())).rejects.toBeDefined(); + expect((await event.collection('registrations').get()).empty).toBe(true); + expect((await db.doc(`communityLimits/${uid}`).get()).exists).toBe(false); + await membership.create(approvedMember(uid, now)); + const saved = join(); const success = await service.mutate(uid, saved); + now += 60000; + const before = await db.getAll(event, event.collection('registrations').doc(uid)); + await expect(service.mutate(uid, saved)).resolves.toEqual(success); + await expect(service.mutate(uid, join(1))).rejects.toBeDefined(); + const after = await db.getAll(event, event.collection('registrations').doc(uid)); + expect(after.map(doc => doc.updateTime.toMillis())).toEqual(before.map(doc => doc.updateTime.toMillis())); + await service.mutate(uid, command('event_cancel', 1, { eventId: 'fixture-event' })); + expect((await event.get()).data().confirmedCount).toBe(0); + }, 45000); + test('concurrent suspension and signup serialize; no new signup survives a committed suspension', async () => { + const uid = 'fixture-suspension-race'; + const membership = db.doc(`communityMemberships/${uid}`); + const record = approvedMember(uid, now); + await membership.create(record); + const outcomes = await Promise.allSettled([ + service.mutate(uid, join()), + membership.update({ 'authority.term.state': 'suspended' }), + ]); + expect(outcomes[1].status).toBe('fulfilled'); + const registered = outcomes[0].status === 'fulfilled'; + const event = db.doc('communityEvents/fixture-event'); + expect((await event.get()).data().confirmedCount).toBe(registered ? 1 : 0); + await expect(service.mutate(uid, join(registered ? 1 : 0))).rejects.toBeDefined(); + if (registered) await service.mutate(uid, command('event_cancel', 1, { eventId: 'fixture-event' })); + expect((await event.get()).data().confirmedCount).toBe(0); + }, 45000); test('officer approval/withdrawal, capacity/closure concurrency and live revocation use real transactions', async () => { const uid = 'fixture-officer'; const runner = 'fixture-member'; const caps = { schemaVersion: 1, enabled: true, revision: 1, expiresAtMs: now + 3600000, membershipApproval: true, freeEventManagement: true }; diff --git a/functions/communityActions.js b/functions/communityActions.js index 748462c..f164f1d 100644 --- a/functions/communityActions.js +++ b/functions/communityActions.js @@ -175,25 +175,29 @@ function createCommunityService(db, clock = Date.now) { } else { const ref = db.doc(`communityEvents/${request.eventId}`); const regRef = ref.collection('registrations').doc(uid); - const [eventSnap, regSnap] = await tx.getAll(ref, regRef); + const joining = request.action === 'event_join'; + const [eventSnap, regSnap, membershipSnap] = await tx.getAll(ref, regRef, ...(joining ? [memberRef(uid)] : [])); + const decisionNow = clock(); check(integer(decisionNow, 8_640_000_000_000_000)); const event = eventView(eventSnap.data(), request.eventId); const record = regSnap.exists ? regSnap.data() : null; const registration = registrationView(record); check(registration.revision === request.expectedRevision, 'failed-precondition'); - const joining = request.action === 'event_join'; + // Membership and capacity share one transaction. Expired/suspended + // members may still cancel; a saved success never books a second place. + if (joining) check(memberView(membershipSnap.exists ? membershipSnap.data() : emptyMember, uid, decisionNow).entitlement === 'current_member', 'failed-precondition'); const target = joining ? 'confirmed' : 'cancelled'; check(joining || registration.status !== 'none', 'failed-precondition'); if (registration.status !== target) { check(registration.revision < 1_000_000); if (joining) { - check(event.status === 'open' && now >= event.opensAtMs && now < event.closesAtMs + check(event.status === 'open' && decisionNow >= event.opensAtMs && decisionNow < event.closesAtMs && request.waiverVersion === event.waiverVersion, 'failed-precondition'); check(event.confirmedCount < event.capacity, 'resource-exhausted'); } else check(event.confirmedCount > 0); const next = { schemaVersion: 1, revision: registration.revision + 1, status: target, - paymentStatus: 'not_required', updatedAtMs: now, - ...(joining ? { waiverVersion: event.waiverVersion, waiverHash: event.waiverHash, waiverAcceptedAtMs: now } : { + paymentStatus: 'not_required', updatedAtMs: decisionNow, + ...(joining ? { waiverVersion: event.waiverVersion, waiverHash: event.waiverHash, waiverAcceptedAtMs: decisionNow } : { waiverVersion: record.waiverVersion, waiverHash: record.waiverHash, waiverAcceptedAtMs: record.waiverAcceptedAtMs, }), }; diff --git a/functions/communityActions.test.js b/functions/communityActions.test.js index aac12a2..939ea5b 100644 --- a/functions/communityActions.test.js +++ b/functions/communityActions.test.js @@ -94,11 +94,13 @@ describe('transactional membership and free-event lifecycle', () => { await expect(service.mutate('runner-a', { ...command, action: 'membership_cancel' })).rejects.toHaveProperty('code', 'already-exists'); }); test('20 concurrent final-seat attempts confirm exactly one', async () => { + for (let n = 0; n < 20; n++) docs.set(`communityMemberships/runner-${n}`, approvedMember(`runner-${n}`, now)); const results = await Promise.allSettled(Array.from({ length: 20 }, (_, n) => service.mutate(`runner-${n}`, joining()))); expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); expect(docs.get('communityEvents/one-seat').confirmedCount).toBe(1); }); test('cancel releases once, even when closed, and cannot undo a later rejoin', async () => { + docs.set('communityMemberships/runner-a', approvedMember('runner-a', now)); await service.mutate('runner-a', joining()); const cancel = request('event_cancel', 1, { eventId: 'one-seat' }); docs.get('communityEvents/one-seat').status = 'closed'; @@ -113,17 +115,20 @@ describe('transactional membership and free-event lifecycle', () => { await expect(service.mutate('runner-a', request('event_cancel', 1, { eventId: 'one-seat' }))).rejects.toHaveProperty('code', 'failed-precondition'); }); test.each([{ amountCents: 1 }, { status: 'closed' }, { closesAtMs: 1_800_000_000_000 }, { waiverHash: 'forged' }, { confirmedCount: -1 }])('denies paid/closed/expired/corrupt events without writes %#', async (extra) => { + docs.set('communityMemberships/runner-a', approvedMember('runner-a', now)); docs.set('communityEvents/one-seat', freeEvent(now, extra)); await expect(service.mutate('runner-a', joining())).rejects.toBeDefined(); expect(docs.has('communityEvents/one-seat/registrations/runner-a')).toBe(false); expect(docs.has('communityLimits/runner-a')).toBe(false); }); test('requires exact server waiver version and never creates paid state', async () => { + docs.set('communityMemberships/runner-a', approvedMember('runner-a', now)); await expect(service.mutate('runner-a', { ...joining(), waiverVersion: 'old-version' })).rejects.toHaveProperty('code', 'failed-precondition'); await service.mutate('runner-a', joining()); expect(docs.get('communityEvents/one-seat/registrations/runner-a')).toMatchObject({ paymentStatus: 'not_required', waiverVersion: 'synthetic-only-v1' }); }); test('status includes only the caller registration, not another runner', async () => { + docs.set('communityMemberships/runner-a', approvedMember('runner-a', now)); await service.mutate('runner-a', joining()); expect((await service.status('runner-b')).events[0].registration.status).toBe('none'); }); @@ -142,6 +147,7 @@ describe('transactional membership and free-event lifecycle', () => { expect(JSON.stringify([...docs])).toBe(before); }); test('cancelled event status retains confirmed signup history and another UID sees none', async () => { + docs.set('communityMemberships/runner-a', approvedMember('runner-a', now)); await service.mutate('runner-a', joining()); docs.get('communityEvents/one-seat').status = 'cancelled'; const before = JSON.stringify([...docs]); diff --git a/functions/communityAdministration.test.js b/functions/communityAdministration.test.js index 155d94c..53563d4 100644 --- a/functions/communityAdministration.test.js +++ b/functions/communityAdministration.test.js @@ -110,14 +110,17 @@ test('changing same UUID nested event payload is a conflict', async () => { await expect(execute({ ...command, event: { ...draft, capacity: 2 } })).rejects.toHaveProperty('code', 'already-exists'); }); test('capacity changes preserve participant updates and cannot undercut bookings', async () => { + docs.set('communityMemberships/runner', approvedMember('runner', now)); await members.mutate('runner', join()); await expect(execute(request('admin_event_edit', { eventId: 'event', event: draft }))).rejects.toBeDefined(); await execute(request('admin_event_capacity', { eventId: 'event', capacity: 2 })); + docs.set('communityMemberships/second', approvedMember('second', now)); await members.mutate('second', join()); await expect(execute(request('admin_event_capacity', { eventId: 'event', expectedRevision: 1, capacity: 1 }))).rejects.toBeDefined(); expect(docs.get('communityEvents/event').confirmedCount).toBe(2); }); test('close prevents entry but member cancellation remains possible after final event cancellation', async () => { + docs.set('communityMemberships/runner', approvedMember('runner', now)); await members.mutate('runner', join()); await execute(request('admin_event_cancel', { eventId: 'event' })); expect(docs.get('communityEvents/event').confirmedCount).toBe(1); diff --git a/functions/communityEventMembership.test.js b/functions/communityEventMembership.test.js new file mode 100644 index 0000000..4b097ef --- /dev/null +++ b/functions/communityEventMembership.test.js @@ -0,0 +1,75 @@ +const { randomUUID } = require('node:crypto'); +const { createCommunityService, parseRequest } = require('./communityActions'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const { approvedMember, freeEvent } = require('./testSupport/communityFixtures'); + +const began = 1_800_000_000_000; +const command = (action = 'event_join', revision = 0) => parseRequest({ + action, accountUid: 'runner', requestId: randomUUID(), expectedRevision: revision, + eventId: 'event', ...(action === 'event_join' ? { waiverVersion: 'synthetic-only-v1', waiverAccepted: true } : {}), +}); +let memory; let now; let service; +beforeEach(() => { + memory = memoryDb(); now = began; + memory.docs.set('communityEvents/event', freeEvent(now)); + service = createCommunityService(memory.db, () => now); +}); + +test.each(['missing', 'pending', 'expired', 'future', 'suspended', 'ended', 'wrong_uid', 'malformed'])('new signup denies %s membership without writes', async state => { + const record = JSON.parse(JSON.stringify(approvedMember('runner', now))); + if (state === 'pending') { record.applicationStatus = 'pending'; record.authority = null; } + if (state === 'expired') record.authority.term.endsAtMs = now; + if (state === 'future') record.authority.term.startsAtMs = now + 1; + if (['suspended', 'ended'].includes(state)) record.authority.term.state = state; + if (state === 'wrong_uid') record.authority.association.uid = 'someone-else'; + if (state === 'malformed') record.authority.term.endsAtMs = 'invalid'; + if (state !== 'missing') memory.docs.set('communityMemberships/runner', record); + const before = JSON.stringify([...memory.docs]); + await expect(service.mutate('runner', command())).rejects.toBeDefined(); + expect(JSON.stringify([...memory.docs])).toBe(before); +}); + +test.each(['approved', 'cancellation_requested'])('current %s term permits signup without another approval', async applicationStatus => { + memory.docs.set('communityMemberships/runner', { ...approvedMember('runner', now), applicationStatus }); + await expect(service.mutate('runner', command())).resolves.toHaveProperty('registration.status', 'confirmed'); + expect(memory.docs.get('communityEvents/event').confirmedCount).toBe(1); +}); + +test('expiry blocks fresh commands but preserves saved success and permits cancellation', async () => { + memory.docs.set('communityMemberships/runner', approvedMember('runner', now)); + const join = command(); const result = await service.mutate('runner', join); + now += 60000; + const before = JSON.stringify([...memory.docs]); + await expect(service.mutate('runner', join)).resolves.toEqual(result); + await expect(service.mutate('runner', command('event_join', 1))).rejects.toBeDefined(); + expect(JSON.stringify([...memory.docs])).toBe(before); + const cancel = command('event_cancel', 1); + await service.mutate('runner', cancel); await service.mutate('runner', cancel); + expect(memory.docs.get('communityEvents/event').confirmedCount).toBe(0); + expect(memory.docs.get('communityEvents/event/registrations/runner')).toMatchObject({ status: 'cancelled', revision: 2 }); +}); + +test('suspension does not trap an existing registration', async () => { + const member = JSON.parse(JSON.stringify(approvedMember('runner', now))); + memory.docs.set('communityMemberships/runner', member); + await service.mutate('runner', command()); + member.authority.term.state = 'suspended'; + await expect(service.mutate('runner', command('event_cancel', 1))).resolves.toHaveProperty('registration.status', 'cancelled'); +}); + +test('term expiry while transactional reads complete denies signup', async () => { + memory.docs.set('communityMemberships/runner', approvedMember('runner', now)); + const transaction = memory.db.runTransaction; + memory.db.runTransaction = fn => transaction(tx => fn({ ...tx, getAll: async (...refs) => { + const result = await tx.getAll(...refs); now = began + 60000; return result; + } })); + const before = JSON.stringify([...memory.docs]); + await expect(service.mutate('runner', command())).rejects.toBeDefined(); + expect(JSON.stringify([...memory.docs])).toBe(before); +}); + +test('forged client membership and payment fields never enter the request', () => { + for (const field of ['entitlement', 'paid', 'role', 'membership']) { + expect(() => parseRequest({ ...command(), [field]: 'current_member' })).toThrow(); + } +}); diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx index de9a669..994a35d 100644 --- a/src/pages/community/Community.test.tsx +++ b/src/pages/community/Community.test.tsx @@ -168,13 +168,14 @@ confirmedCount: 0, waiverVersion: 'synthetic-v1', registration: { revision: 0, status: 'none' }, }; - call.mockResolvedValueOnce({ ...state(), events: [event] }); + const active = { ...state('approved', 2), membership: { ...state('approved', 2).membership, entitlement: 'current_member', accessThroughMs: 1800000060000 } }; + call.mockResolvedValueOnce({ ...active, events: [event] }); render(show()); await screen.findByRole('button', { name: 'Sign up for Synthetic event' }); expect(screen.getByRole('button', { name: 'Sign up for Synthetic event' })).toBeDisabled(); fireEvent.click(screen.getByRole('checkbox')); call.mockResolvedValueOnce({ registration: { status: 'confirmed', revision: 1 } }); - call.mockResolvedValueOnce({ ...state(), events: [{ ...event, confirmedCount: 1, registration: { status: 'confirmed', revision: 1 } }] }); + call.mockResolvedValueOnce({ ...active, events: [{ ...event, confirmedCount: 1, registration: { status: 'confirmed', revision: 1 } }] }); fireEvent.click(screen.getByRole('button', { name: 'Sign up for Synthetic event' })); await screen.findByRole('button', { name: 'Cancel signup for Synthetic event' }); expect(call.mock.calls[1][1]).toMatchObject({ @@ -182,7 +183,7 @@ waiverVersion: 'synthetic-v1', }); await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel signup for Synthetic event' })).toBeEnabled()); call.mockResolvedValueOnce({ registration: { status: 'cancelled', revision: 2 } }); - call.mockResolvedValueOnce({ ...state(), events: [{ ...event, registration: { status: 'cancelled', revision: 2 } }] }); + call.mockResolvedValueOnce({ ...active, events: [{ ...event, registration: { status: 'cancelled', revision: 2 } }] }); fireEvent.click(screen.getByRole('button', { name: 'Cancel signup for Synthetic event' })); await screen.findByText(/Your signup: cancelled/); expect(call.mock.calls[3][1]).toMatchObject({ action: 'event_cancel', expectedRevision: 1 }); @@ -254,3 +255,14 @@ test('successful action followed by failed status read hides stale state but ret await screen.findByText('Request status: pending.'); expect(call.mock.calls[3][1]).toEqual(command); }); +test.each(['not_entitled', 'decision_pending', 'forged'])('noncurrent membership %s cannot prepare signup even for an open event', async (entitlement) => { + call.mockResolvedValue({ + ...state(), membership: { ...state().membership, entitlement }, events: [testEvent()] + }); + render(show()); + await screen.findByText('Current membership is required for a new free-event signup. You can still cancel an existing signup.'); + expect(screen.getByRole('checkbox')).toBeDisabled(); + const signup = screen.getByRole('button', { name: 'Sign up for Synthetic event' }); + expect(signup).toBeDisabled(); fireEvent.click(signup); + expect(call).toHaveBeenCalledTimes(1); +}); diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 80fd7b3..bc11033 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -107,6 +107,7 @@ export default function Community() { ); } const verified = services?.firebaseResources.auth.currentUser?.emailVerified === true; const blocked = busy || pending.current !== null || !verified; + const currentMember = status?.membership.entitlement === 'current_member'; return (
@@ -166,6 +167,13 @@ export default function Community() {

Free events

+ {!currentMember && ( +

+ Current membership is required for a new free-event signup. + {' '} + You can still cancel an existing signup. +

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

No free events are available.

} {status.events.map((event) => (
@@ -194,14 +202,14 @@ export default function Community() { <>

{event.waiverText}