diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 1e4e7a7..1a955fd 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,6 +1,6 @@ # GitHub Issue Backlog: Secure Commerce Program -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target source packaging, pinned test-secret/runtime checks and backend-first Hosting guards are prepared for review. Restricted sandbox keys are supported. Club sign-in/provider setup and actual hosted checkout verification remain pending; no cloud/provider mutation is supplied. [Specialist release handoff](./docs/TEST_DUES_RELEASE.md). **Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index af9efd8..f840c63 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # MPRC Secure Commerce Implementation Plan -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target source packaging, pinned test-secret/runtime checks and backend-first Hosting guards are prepared for review. Restricted sandbox keys are supported. Club sign-in/provider setup and actual hosted checkout verification remain pending; no cloud/provider mutation is supplied. [Specialist release handoff](./docs/TEST_DUES_RELEASE.md). **Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 71ea3be..a2e81f4 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,6 +1,6 @@ # MPRC Website: Officer Start Here -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target packaging, restricted sandbox keys and backend-first publication checks are prepared for review. Club sign-in, private provider setup and hosted checkout proof remain pending. [Specialist handoff](./docs/TEST_DUES_RELEASE.md). **Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index a89d440..bfd2984 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,6 +1,6 @@ # MPRC Platform Operations Runbook -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target source packaging, pinned test-secret/runtime checks and backend-first Hosting guards are prepared for review. Restricted sandbox keys are supported. Club sign-in/provider setup and actual hosted checkout verification remain pending; no cloud/provider mutation is supplied. [Specialist release handoff](./docs/TEST_DUES_RELEASE.md). **Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). diff --git a/SECURITY.md b/SECURITY.md index 07a6a58..8480cb7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # MPRC Security, Privacy, and Risk Plan -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target source packaging, pinned test-secret/runtime checks and backend-first Hosting guards are prepared for review. Restricted sandbox keys are supported. Club sign-in/provider setup and actual hosted checkout verification remain pending; no cloud/provider mutation is supplied. [Specialist release handoff](./docs/TEST_DUES_RELEASE.md). **Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 0c39292..f40c62f 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,6 +1,6 @@ # Stripe, Race Registration, and Merchandise Design -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target source packaging, pinned test-secret/runtime checks and backend-first Hosting guards are prepared for review. Restricted sandbox keys are supported. Club sign-in/provider setup and actual hosted checkout verification remain pending; no cloud/provider mutation is supplied. [Specialist release handoff](./docs/TEST_DUES_RELEASE.md). **#731 fictional native acceptance — 2026-10-14:** actual signup, full fictional credit return, same-bib recipient transfer, revoke and original credit ownership passed in staging. Native committed/lost-response retry preserved one seat/receipt/audit, did not resurrect cancellation and could not bypass current authority. [Proof and supervised gate](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). These integer-credit simulations are not provider-backed payment/refund evidence; no real policy, finance authorization, Stripe object or production launch changed. Complete acceptance/cleanup receipt and fresh fake fixtures remain prerequisites; earlier #731/#732 source-only notes below are historical. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 14d48b0..fa89255 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,6 +1,6 @@ # MPRC Platform System Design -**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). **October 17 release preparation:** fixed-target source packaging, pinned test-secret/runtime checks and backend-first Hosting guards are prepared for review. Restricted sandbox keys are supported. Club sign-in/provider setup and actual hosted checkout verification remain pending; no cloud/provider mutation is supplied. [Specialist release handoff](./docs/TEST_DUES_RELEASE.md). **Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). diff --git a/docs/TEST_DUES_ENGINEERING.md b/docs/TEST_DUES_ENGINEERING.md index 067b673..977929f 100644 --- a/docs/TEST_DUES_ENGINEERING.md +++ b/docs/TEST_DUES_ENGINEERING.md @@ -1,6 +1,6 @@ # Test-only annual dues runtime — #742 -This bounded staging integration consumes `membershipDuesCheckout`, `membershipDuesPayment` and `membershipAuthority`. It is isolated in `duesTestOnly.js`; neither legacy `index.js` nor `communityOnly.js` imports it. No provider connection or deployment is supplied. Existing member/event records remain compatible; no migration or roster import. +This bounded staging integration consumes `membershipDuesCheckout`, `membershipDuesPayment` and `membershipAuthority`. It is isolated in `duesTestOnly.js`; neither legacy `index.js` nor `communityOnly.js` imports it. No provider connection or deployment is supplied. The separate [October 17 release preparation](./TEST_DUES_RELEASE.md) adds fixed-target source/secret/runtime/Hosting checks for review; actual native setup remains pending. Existing member/event records remain compatible; no migration or roster import. ## Trust and transitions @@ -8,7 +8,7 @@ Native callable Auth/App Check, matching caller/default tenant, recent verified Server offers fix 2500/3000 integer USD cents, LA calendar boundaries and a synthetic consent version/hash. No future term, automatic renewal or public external-payment instructions. One account lock and request-derived order ID are committed with immutable expectation, nonce, price, consent, expiration and provider idempotency key before any Checkout creation. Parallel commands/plans conflict; an exact request reuses byte-identical provider parameters. Unbound creation retries stop after five minutes, well within the provider's idempotency retention. Past that horizon the unresolved order stays held for private reconciliation; never guess that it is safe to charge again. Bound open Sessions are retrieved for resume. Verified empty expired Sessions may release the purchase lock; paid/held/pending orders cannot be discarded for a replacement purchase in the same year. -The adapter verifies the actual Stripe account for each operation, accepts only test credentials and objects, binds each Checkout URL to its verified exact Session, rejects malformed refund counters, and projects no customer/contact data. Raw signed webhook bodies are bounded and verified before a minimal durable event receipt. SDK retrieval checks Session, nonce/order/client reference, test realm, amount/currency and current PaymentIntent/Charge, including refunds/disputes before an older paid callback. Charge reversals resolve the exact Session with a bounded PaymentIntent query. Invalid or foreign observations grant nothing. Duplicate/out-of-order deliveries re-read current provider state and cannot regress an active/held term. Cancellation return is a read, never a payment or refund command. +The adapter verifies the actual Stripe account for each operation, accepts only test server credentials (`rk_test_` or `sk_test_`) and objects, binds each Checkout URL to its verified exact Session, rejects malformed refund counters, and projects no customer/contact data. Raw signed webhook bodies are bounded and verified before a minimal durable event receipt. SDK retrieval checks Session, nonce/order/client reference, test realm, amount/currency and current PaymentIntent/Charge, including refunds/disputes before an older paid callback. Charge reversals resolve the exact Session with a bounded PaymentIntent query. Invalid or foreign observations grant nothing. Duplicate/out-of-order deliveries re-read current provider state and cannot regress an active/held term. Cancellation return is a read, never a payment or refund command. Verified paid evidence, immutable term receipt, canonical `communityMemberships/{uid}` activation, optional private family root and transition audit are atomic. Changed revision/withdrawal/suspension/association, invalid current account/participant or an expired original term leaves durable paid-pending evidence. No silent rebase, renewal, unsuspension or refund. A defensive test reversal holds the order permanently and suspends only the authority still bound to that payment; it cannot revoke a later unrelated term. This is deliberately not real refund policy. Auth metadata is rechecked immediately before the transaction but cannot be atomically locked with Firestore; native distributed revocation guarantees are not claimed. @@ -16,6 +16,6 @@ Server-only collections: `membershipTestOrders`, `membershipTestAccounts`, `memb ## Release still required -A separate reviewed release must package the isolated entrypoint, bind only the test Secret Manager references and club-owned account, qualify minimum runtime/IAM/secret access, webhook subscription and retry/reconciliation operation, prove raw-body signature handling and normal Auth/App Check on the hosted Function, then publish matching test-only Hosting. No secrets belong in tickets/chat/logs. Existing community release scripts do not deploy this entrypoint. Use a fresh fictional marker/account/event and exact-owned cleanup; never reuse #731. Actual sandbox/live evidence, provider changes, real policy approval and production rollout remain separate. +A separate reviewed release must package the isolated entrypoint, bind only the test Secret Manager references and club-owned account, qualify minimum runtime/IAM/secret access, webhook subscription and retry/reconciliation operation, prove raw-body signature handling and normal Auth/App Check on the hosted Function, then publish matching test-only Hosting. No secrets belong in tickets/chat/logs. The separate `staging-test-dues-release.js` path packages only this entrypoint and requires existing verified private setup. Ordinary community release scripts do not deploy it; the explicit test-dues Hosting mode requires matching dues backend/archive/authority readback before enabling its flag. Use a fresh fictional marker/account/event and exact-owned cleanup; never reuse #731. Actual sandbox/live evidence, provider changes, real policy approval and production rollout remain separate. Local tests use transactional doubles and the installed SDK's real signature verifier, with no outside network. Hosted Node22 CI runs `testMembershipDues.emulator.test.js` under native Firestore/Auth emulators and a synthetic provider: it proves native order/activation races and free-event admission, not Stripe API behavior. Frontend tests cover consent, account/session fences, unknown responses, expiry without polling, saved retry/reload and verified test URL navigation. diff --git a/docs/TEST_DUES_RELEASE.md b/docs/TEST_DUES_RELEASE.md new file mode 100644 index 0000000..e950e5b --- /dev/null +++ b/docs/TEST_DUES_RELEASE.md @@ -0,0 +1,67 @@ +# Connect and qualify hosted test dues — #742 + +**October 17, 2026: release preparation only. NOT DEPLOYED.** Stripe sign-in is waiting for the club owner. Native read-only inspection found only the existing three staging Functions; Secret Manager is disabled. No Stripe account, key, webhook or hosted dues behavior has been qualified. This document is a specialist handoff, not an officer terminal procedure. + +## Authority and scope + +Dave authorizes this isolated staging outcome. Genuine Opus 5.5 medium/max APPROVE on the exact head plus all six CI jobs and every step green supplies delegated code approval. Provider/account ownership and privately entered credentials remain separate evidence. Use only `run-mprc-staging` (native project number `384996399199`) and the club-owned Stripe sandbox. Never activate a live Stripe account, add real bank/card details, enable `COMMERCE_ENABLED`, use real member records, or rerun sealed #731. + +The new release path ships only `duesTestOnly.js` and its closed dependency list. It creates or updates exactly `membershipTestDues` and `membershipTestDuesWebhook`, each Node 22, min 0/max 2, 256 MB, 30 seconds, in `us-central1`. Existing profile and community Functions must remain identical. No mail, worker, scheduler, new package, Rules allowance or migration is introduced. Builds, secret use and requests can incur staging usage; the existing $10 warning is not a spending cap. + +## Private provider setup — owner and specialist + +1. The owner finishes Google/Stripe sign-in and MFA in the club's browser session. Never put passwords, codes or keys in chat, GitHub, logs or screenshots. +2. Identify the club account. Open its dedicated general sandbox and verify the sandbox banner. Stop if account ownership or isolation is uncertain. Stripe recommends a separate sandbox for new integrations because its data and settings are isolated from live mode: [Stripe keys](https://docs.stripe.com/keys), [sandbox management](https://docs.stripe.com/sandboxes/dashboard/manage). +3. Record only the sandbox's nonsecret `acct_…` identifier and its dashboard location in the private release receipt. No account creation, agreement acceptance or broader access is implied by reaching sign-in. The owner performs new credential creation privately; browser security grants require confirmation at action time. +4. Prepare a service-specific restricted sandbox key. The runtime supports `rk_test_` and the existing `sk_test_` format, never live, publishable or organization keys. Prefer the restricted key. The adapter's required operations are `GET /v1/account`, create/retrieve/list Checkout Sessions, retrieve PaymentIntents with expanded latest Charge, and retrieve Charges. Configure the matching minimum Dashboard permissions and qualify each native operation; do not guess success from a permission label. No refund, payout, subscription, webhook-management or general account-write permission is needed by the runtime. See [restricted keys](https://docs.stripe.com/keys/restricted-api-keys). +5. Configure one sandbox-only webhook destination at `https://us-central1-run-mprc-staging.cloudfunctions.net/membershipTestDuesWebhook`. Use API version `2023-10-16`, ordinary account events, and exactly: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `charge.refunded`, `charge.dispute.created`, `charge.dispute.updated`, `charge.dispute.closed`. Do not subscribe to all events or a live account. Creation can precede deployment; delivery is not qualified until the receiver is live. See [Stripe webhooks](https://docs.stripe.com/webhooks). +6. The owner privately stores the test API key and that endpoint's signing secret in the two staging secrets below. The release scripts inspect metadata only and never fetch secret payloads. A successful signature test using another endpoint's secret is not proof for this endpoint. + +## Minimum Google setup — specialist only, after code approval + +Enable only Secret Manager in the existing staging project. Create the keyless service account `mprc-test-dues-runtime@run-mprc-staging.iam.gserviceaccount.com`. Bind only the custom project role `mprcTestDuesRuntime` with these six permissions: + +- `datastore.databases.get` +- `datastore.entities.get` +- `datastore.entities.create` +- `datastore.entities.update` +- `datastore.entities.list` +- `firebaseauth.users.get` + +Create exactly `MPRC_TEST_STRIPE_KEY` and `MPRC_TEST_STRIPE_WEBHOOK_SECRET`, labeled `managed-by=mprc-test-dues-stage`. Each secret's own IAM policy grants `roles/secretmanager.secretAccessor` only to the new runtime. Do not grant that permission at project level or to existing profile/community/build identities. Keep secret payload entry private. Retain the existing build account/repository and platform service-agent arrangement; verify the required deploy/act-as path rather than adding broad roles. No user-managed service-account key is permitted. + +Create a small **nonsecret**, task-owned plan file outside Git with exactly: + +```json +{"stripeAccount":"acct_REPLACE_WITH_VERIFIED_SANDBOX","keyVersion":"1","webhookVersion":"1"} +``` + +Replace all three values with observed account and enabled numeric secret-version identifiers. `latest`, extra keys and foreign projects fail closed. The plan never contains a credential. The fixed native project identity, exact runtime role/project bindings, zero user-managed keys, per-secret access, labels and pinned enabled versions are checked before release and again after verification. Explicit project-wide Secret Manager accessor/admin grants also stop the release, including grants to other accounts. Existing owner/platform authority remains; these checks are not a complete organization-wide IAM audit. + +## Reviewed release and verification — specialist only + +Keep the checkout clean at exact current green `main`. Preserve any preexisting ignored build directory with its hash manifest and restore it after the staging build; do not overwrite another task's artifact. Run these commands only after provider setup and exact-head approval: + +```text +node scripts/staging-test-dues-release.js deploy /absolute/path/to/nonsecret-plan.json +node scripts/staging-test-dues-release.js verify /absolute/path/to/nonsecret-plan.json +node scripts/staging-community-hosting.js test-dues /absolute/path/to/nonsecret-plan.json +``` + +The deploy path uploads one deterministic, bounded source archive per target, validates native settings and public transport IAM, and downloads both actual source archives to compare hashes. Public transport is necessary for Firebase callable Auth/App Check and the Stripe signature boundary; it grants no Firestore access. Every mutation rechecks current-main CI. Verification accepts ancestor source only with identical current reviewed archive bytes. Missing setup is a failure, not an automatic repair. A partial deployment leaves the screen disabled. An existing ACTIVE target may be preserved while completing the same reviewed release. A failed or inactive target stops inventory validation: preserve the native receipts and prepare a separate reviewed specialist repair. Do not delete a Function or its evidence as an automatic recovery step. + +The Hosting command verifies both dues archives/settings/account/secret bindings and provenance before building, then rechecks before publication. Only this explicit mode sets the frontend dues flag. The ordinary Hosting command clears any inherited dues flag. The public release marker adds the dues bundle hash, not credentials or account details. Existing profile/community release paths can recognize and preserve these exact isolated dues Functions; they cannot deploy dues through their older targets. + +## Native acceptance, cleanup and recovery + +Use fresh, uniquely inventoried fictional accounts, current private `membershipTestParticipants` markers and one free event. Do not reuse deleted fixtures. A specialist supplies these through an exact-owned, fenced operation; ordinary officers do not change database records or grant roles. + +Prove native Auth/App Check, caller/current-account/marker denials and actual invalid raw-body signature rejection. Use genuine hosted Stripe test Checkout for both server amounts, unpaid/cancel-return, interrupted/resumed checkout, duplicate/retried requests, webhook-before-return, missed-callback reconciliation and automatic canonical payer activation/free-event admission. Verify phone and desktop behavior. The selected card-only mode does not supply an asynchronous payment-method UI; distinguish native provider event/delivery evidence from synthetic async lifecycle coverage. No redirect or browser field proves payment. Preserve the existing failed-card/expired-session hold for specialist reconciliation. + +Before reporting completion, record exact reviewed/merged source, all CI jobs/steps, deployed Function versions/archive hashes, runtime/secret metadata, sandbox account/endpoint/version/event-delivery state, matching Hosting version/asset hashes, actual screen results and owned cleanup. Source/emulator proof is not hosted or live payment proof. A provider failure stays unresolved; never replace it with a synthetic success claim. + +Disable disposable participant markers first. Resolve or expire only the exact owned open sandbox Sessions through the provider owner before removing owned test fixtures; never blindly create replacement charges. Preserve unresolved purchase and immutable payment evidence for reconciliation, and use identity/update-time fences plus absence checks for authorized cleanup. Clean temporary source bundles automatically and keep bounded, redacted release receipts in the named task artifact directory. + +**Undo:** stop new test access, publish the reviewed no-dues Hosting mode, then disable the isolated dues feature through a compatible reviewed release. Keep the endpoint, pinned secrets and unresolved records until the owner confirms no owed webhook/reconciliation work remains. Do not delete secrets or financial evidence to hide a partial release. Real refund issuance, live activation and policy decisions remain separate. + +**Backup-officer walkthrough:** the officer opens the supplied test screen and follows [Test annual dues](./officers/TEST_DUES.md). Sign-in, sandbox/secret/IAM setup, release, fixtures, failed-payment reconciliation and cleanup still require Dave and the specialist. No step asks a backup officer for a terminal, credential, role grant or member record. diff --git a/docs/officers/README.md b/docs/officers/README.md index 7ee2463..3fe60e7 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,6 +1,6 @@ # Officer Website Handbook -[Test annual dues](./TEST_DUES.md) is source prepared and **NOT AVAILABLE YET**. The club-owned sandbox and isolated dues release are still required; real payments remain separate. +[Test annual dues](./TEST_DUES.md) is source prepared and **NOT AVAILABLE YET**. The club-owned sandbox and isolated dues release are still required; real payments remain separate. October 17 release checks are source prepared; club Stripe sign-in, private setup and hosted checkout proof are still pending. **October 16 member-screen preparation:** [Review membership and free events](./MEMBER_SCREEN.md). Native staging behavior, final navigation correction and cleanup passed at Hosting `d08a6a3`. No production or payment release. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 6930cc2..88ee0b7 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -444,3 +444,19 @@ flowchart LR ``` Text alternative: a marked fictional account prepares one saved test purchase; only verified sandbox payment can atomically activate the payer for free events, and family invitations remain separate. Source and emulator proof do not prove provider configuration or deployment. + + +### Planned isolated test-dues release — October 17, NOT DEPLOYED + +```mermaid +flowchart LR + A["Owner selects club sandbox"] --> S["Two private pinned staging secrets"] + S --> R["Separate keyless dues runtime"] + C["Exact source, Opus approval and green CI"] --> R + R --> F["Two isolated test dues Functions"] + F --> V["Native settings, archives and sandbox proof"] + V --> H["Explicit test dues Hosting publication"] + H --> M["Fictional payer activation and free-event review"] +``` + +Text alternative: the owner connects only the club sandbox; the specialist verifies the isolated backend and test secrets before publishing the test screen. Existing profile/community services remain separate. Real payments are not included. [Officer procedure](./TEST_DUES.md); [specialist handoff](../TEST_DUES_RELEASE.md). diff --git a/docs/officers/TEST_DUES.md b/docs/officers/TEST_DUES.md index 362cf7e..d101ee4 100644 --- a/docs/officers/TEST_DUES.md +++ b/docs/officers/TEST_DUES.md @@ -1,6 +1,6 @@ # Review annual dues in test mode -**October 16, 2026: source preparation only; NOT AVAILABLE YET.** #742 adds a separate, disabled test-only checkout and payer activation flow. The club's Stripe sandbox has not been identified or connected. No dues Function, webhook, secret access or dues screen is deployed by this change. The existing member/free-event staging release remains qualified by [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). +**October 17, 2026: source and release preparation only; NOT AVAILABLE YET.** #742 adds a separate, disabled test-only checkout and payer activation flow. The club's Stripe sandbox has not been identified or connected; club sign-in is waiting for the owner. The [specialist release handoff](../TEST_DUES_RELEASE.md) now describes isolated packaging, minimum access, private key entry and hosted acceptance. This is preparation, not a working review link. No dues Function, webhook, secret access or dues screen is deployed by this change. The existing member/free-event staging release remains qualified by [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). **Purpose:** confirm that one fictional purchase activates the payer automatically and allows free-event signup, without a second officer membership approval. diff --git a/functions/testDuesStripe.js b/functions/testDuesStripe.js index e2ffc1c..945caec 100644 --- a/functions/testDuesStripe.js +++ b/functions/testDuesStripe.js @@ -16,7 +16,7 @@ function checkoutParameters(o) { }; } function createTestStripe({ key, webhookSecret, account }) { - check(typeof key === 'string' && key.startsWith('sk_test_') && typeof webhookSecret === 'string' && webhookSecret.startsWith('whsec_') && ref(account, 'acct')); + check(typeof key === 'string' && (key.startsWith('rk_test_') || key.startsWith('sk_test_')) && typeof webhookSecret === 'string' && webhookSecret.startsWith('whsec_') && ref(account, 'acct')); const stripe = new Stripe(key, { apiVersion: '2023-10-16', timeout: 5000, maxNetworkRetries: 0 }); async function own() { const a = await stripe.accounts.retrieve(); check(a.id === account); } async function project(s, includeUrl = false) { diff --git a/functions/testDuesStripe.test.js b/functions/testDuesStripe.test.js index d12b366..d5c32fd 100644 --- a/functions/testDuesStripe.test.js +++ b/functions/testDuesStripe.test.js @@ -41,6 +41,16 @@ test('wrong actual Stripe account and live secret rejected', async () => { sdk.accounts.retrieve.mockResolvedValue({ id: 'acct_foreign' }); await expect(adapter.retrieve('cs_test_synthetic')).rejects.toThrow(); expect(() => createTestStripe({ key: 'sk_live_synthetic', webhookSecret: secret, account })).toThrow(); }); +test('restricted sandbox key works with the same account and object fences', async () => { + const restricted = createTestStripe({ key: 'rk_test_synthetic', webhookSecret: secret, account }); + expect(Stripe).toHaveBeenLastCalledWith('rk_test_synthetic', expect.objectContaining({ apiVersion: '2023-10-16' })); + expect(await restricted.retrieve('cs_test_synthetic')).toMatchObject({ paid: true, providerAccountRef: account, livemode: false }); + sdk.accounts.retrieve.mockResolvedValue({ id: 'acct_foreign' }); + await expect(restricted.retrieve('cs_test_synthetic')).rejects.toThrow(); +}); +test.each(['rk_live_synthetic', 'sk_live_synthetic', 'pk_test_synthetic', 'sk_org_synthetic', ''])('non-sandbox server credential rejected: %s', key => { + expect(() => createTestStripe({ key, webhookSecret: secret, account })).toThrow(); +}); test('raw signed webhook verified; invalid, stale, live, wrong-account and oversized payloads reject', () => { const event = { id: 'evt_synthetic', type: 'checkout.session.completed', livemode: false, data: { object: session() } }; const signed = e => { const raw = Buffer.from(JSON.stringify(e)); const signature = sdk.webhooks.generateTestHeaderString({ payload: raw.toString(), secret }); return [raw, signature]; }; diff --git a/scripts/staging-community-hosting.js b/scripts/staging-community-hosting.js index cc610aa..8d645df 100644 --- a/scripts/staging-community-hosting.js +++ b/scripts/staging-community-hosting.js @@ -7,6 +7,8 @@ const { execFileSync } = require('node:child_process'); const { verifyCurrentSource } = require('./staging-profile-authority'); const { createBundle, functionSpec, validateHostedFunction, jsonRequest } = require('./staging-community-release'); const { verifyExecutableArtifact } = require('./firebase-hosting-contract'); +const dues = require('./staging-test-dues-contract'); +const { verifyFunctions: verifyDues, verifyAuthority: verifyDuesAuthority } = require('./staging-test-dues-release'); const PROJECT = 'run-mprc-staging'; const ACCOUNT = 'runmprc@gmail.com'; const check = (value) => { if (!value) throw new Error('staging_community_hosting_failed'); }; @@ -14,6 +16,16 @@ function validateBundle(native, digest) { check(`${native.labels?.['bundle-a']}${native.labels?.['bundle-b']}` === digest); validateHostedFunction(native, functionSpec(native.labels?.['source-sha'], digest, 'https://storage.googleapis.com/synthetic')); } +function duesEnvironment(input, plan) { + const env = { ...input }; + delete env.REACT_APP_TEST_DUES_STAGING; + if (plan) { + dues.validatePlan(plan); + check(env.REACT_APP_FIREBASE_ENVIRONMENT === 'staging' && env.REACT_APP_FIREBASE_PROJECT_ID === PROJECT && env.REACT_APP_COMMUNITY_STAGING === 'true'); + env.REACT_APP_TEST_DUES_STAGING = 'true'; + } + return env; +} async function ownerToken() { const token = execFileSync('/opt/homebrew/bin/gcloud', ['auth', 'print-access-token', `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim(); const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { @@ -22,7 +34,9 @@ async function ownerToken() { check(response.ok && (await response.json()).email === ACCOUNT); return token; // No ADC fallback, persistent login, credential file or printed token. } -async function publish() { +async function publish(args = []) { + check(args.length === 0 || (args.length === 2 && args[0] === 'test-dues')); + const plan = args.length ? dues.readPlan(args[1]) : null; const root = path.resolve(__dirname, '..'); check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); @@ -40,6 +54,14 @@ async function publish() { check(archiveUrl.protocol === 'https:' && archiveUrl.hostname === 'storage.googleapis.com'); const archive = await fetch(download.downloadUrl, { redirect: 'error', signal: AbortSignal.timeout(30000) }); check(archive.ok && createHash('sha256').update(Buffer.from(await archive.arrayBuffer())).digest('hex') === digest); + let duesDigest; + if (plan) { + await verifyDuesAuthority(token, plan); + duesDigest = dues.createBundle(root).digest; + const inventory = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); + await verifyDues({ inventory, plan, digest: duesDigest, token, sha, + verifyProvenance: async nativeSha => { execFileSync('git', ['merge-base', '--is-ancestor', nativeSha, sha], { cwd: root }); return true; } }); + } const configResponse = await fetch(`https://${PROJECT}.web.app/__/firebase/init.json`, { redirect: 'error', signal: AbortSignal.timeout(15000) }); check(configResponse.ok); const config = await configResponse.json(); check(config.projectId === PROJECT && typeof config.appId === 'string'); @@ -50,31 +72,37 @@ async function publish() { }); check(appCheckResponse.ok); const appCheck = await appCheckResponse.json(); check(typeof appCheck.siteKey === 'string' && /^[A-Za-z0-9_-]{30,128}$/.test(appCheck.siteKey)); - const env = { + const env = duesEnvironment({ ...process.env, CI: 'true', DISABLE_ESLINT_PLUGIN: 'true', REACT_APP_FIREBASE_ENVIRONMENT: 'staging', REACT_APP_COMMUNITY_STAGING: 'true', REACT_APP_FIREBASE_API_KEY: config.apiKey, REACT_APP_FIREBASE_AUTH_DOMAIN: config.authDomain, REACT_APP_FIREBASE_PROJECT_ID: PROJECT, REACT_APP_FIREBASE_STORAGE_BUCKET: config.storageBucket, REACT_APP_FIREBASE_MESSAGING_SENDER_ID: config.messagingSenderId, REACT_APP_FIREBASE_APP_ID: config.appId, REACT_APP_RECAPTCHA_SITE_KEY: appCheck.siteKey, - }; + }, plan); delete env.REACT_APP_FIREBASE_MEASUREMENT_ID; delete env.REACT_APP_SENTRY_DSN; execFileSync(path.join(root, 'node_modules/.bin/react-scripts'), ['build'], { cwd: root, env, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 }); verifyExecutableArtifact(path.join(root, 'build'), env); - fs.writeFileSync(path.join(root, 'build/community-release.json'), JSON.stringify({ schemaVersion: 1, project: PROJECT, sourceSha: sha, functionBundle: digest }) + '\n'); + fs.writeFileSync(path.join(root, 'build/community-release.json'), JSON.stringify({ schemaVersion: 1, project: PROJECT, sourceSha: sha, functionBundle: digest, ...(plan ? { testDuesBundle: duesDigest } : {}) }) + '\n'); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-community-hosting-owned-')); try { const filename = path.join(directory, 'firebase.json'); fs.writeFileSync(filename, JSON.stringify({ hosting: { site: PROJECT, public: path.join(root, 'build'), ignore: ['firebase.json', '**/.*', '**/node_modules/**'], rewrites: [{ source: '**', destination: '/index.html' }] } })); await verifyCurrentSource(source); + if (plan) { + await verifyDuesAuthority(token, plan); + await verifyDues({ inventory: await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token), plan, digest: duesDigest, token, sha, + verifyProvenance: async nativeSha => { execFileSync('git', ['merge-base', '--is-ancestor', nativeSha, sha], { cwd: root }); return true; } }); + } check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); const deployEnv = { ...env, FIREBASE_TOKEN: await ownerToken(), GOOGLE_CLOUD_QUOTA_PROJECT: PROJECT }; execFileSync(path.join(root, 'node_modules/.bin/firebase'), ['deploy', '--only', 'hosting', '--config', filename, '--project', PROJECT, '--non-interactive'], { cwd: root, env: deployEnv, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 }); const response = await fetch(`https://${PROJECT}.web.app/community-release.json`, { cache: 'no-store', redirect: 'error' }); check(response.ok); const marker = await response.json(); - check(marker.sourceSha === sha && marker.project === PROJECT && marker.functionBundle === digest); + check(marker.sourceSha === sha && marker.project === PROJECT && marker.functionBundle === digest + && (plan ? marker.testDuesBundle === duesDigest : marker.testDuesBundle === undefined)); process.stdout.write(`staging_community_hosting_published_verified ${sha}\n`); } finally { fs.rmSync(directory, { recursive: true }); } } -if (require.main === module) publish().catch(() => { process.stderr.write('staging_community_hosting_failed\n'); process.exitCode = 1; }); -module.exports = { validateBundle }; +if (require.main === module) publish(process.argv.slice(2)).catch(() => { process.stderr.write('staging_community_hosting_failed\n'); process.exitCode = 1; }); +module.exports = { validateBundle, duesEnvironment }; diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index 0fb3dfc..863c2ba 100644 --- a/scripts/staging-community-release.js +++ b/scripts/staging-community-release.js @@ -10,6 +10,7 @@ const { createHash } = require('node:crypto'); const { isDeepStrictEqual } = require('node:util'); const { jsonRequest: providerRequest, waitForOperation } = require('./staging-profile-release'); const { verifyCurrentSource } = require('./staging-profile-authority'); +const { TARGETS: DUES_TARGETS, validateKnownDues } = require('./staging-test-dues-contract'); const PROJECT = 'run-mprc-staging'; const TARGET = 'communityActions'; const REGION = 'us-central1'; @@ -30,13 +31,14 @@ function failureMessage(error) { return `staging_community_release_failed phase=${failures.get(error) || 'unknown'}`; } function validateInventory(inventory) { - check(Array.isArray(inventory?.functions) && inventory.functions.length >= 2 && inventory.functions.length <= 3 + check(Array.isArray(inventory?.functions) && inventory.functions.length >= 2 && inventory.functions.length <= 5 && !inventory.nextPageToken && !inventory.unreachable?.length); const names = inventory.functions.map((fn) => fn.entryPoint); check(new Set(names).size === names.length && names.includes('createMemberOnSignUp') && names.includes('ensureMemberProfile')); - check(inventory.functions.every((fn) => ['createMemberOnSignUp', 'ensureMemberProfile', TARGET].includes(fn.entryPoint) + check(inventory.functions.every((fn) => ['createMemberOnSignUp', 'ensureMemberProfile', TARGET, ...DUES_TARGETS].includes(fn.entryPoint) && fn.name === `projects/${PROJECT}/locations/${REGION}/functions/${fn.entryPoint}` && typeof fn.versionId === 'string' && /^[1-9][0-9]*$/.test(fn.versionId))); + inventory.functions.filter(fn => DUES_TARGETS.includes(fn.entryPoint)).forEach(validateKnownDues); } // Owner tokens do not inherit the CLI's --project as their API quota project. @@ -161,13 +163,14 @@ async function verifyDeployment({ token, digest, sourceSha, inventory, verifyPro check(bytes > 0 && hash.digest('hex') === digest); phase = 'final_inventory'; const final = await request(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); - validateInventory(final); check(final.functions.length === 3); + validateInventory(final); check(final.functions.length === 3 + inventory.functions.filter(fn => DUES_TARGETS.includes(fn.entryPoint)).length); const finalNative = final.functions.find((fn) => fn.entryPoint === TARGET); validateHostedFunction(finalNative, expected); check(finalNative.versionId === native.versionId); for (const original of inventory.functions.filter((fn) => fn.entryPoint !== TARGET)) { const unchanged = final.functions.find((fn) => fn.name === original.name); check(unchanged.versionId === original.versionId && isDeepStrictEqual(unchanged.labels, original.labels)); + if (DUES_TARGETS.includes(original.entryPoint)) check(isDeepStrictEqual(unchanged, original)); } return { sourceSha: native.labels['source-sha'], digest, bytes, version: native.versionId }; } catch (error) { throw phaseFailure(phase, error); } diff --git a/scripts/staging-profile-release.js b/scripts/staging-profile-release.js index 414d0d3..8ae0d38 100644 --- a/scripts/staging-profile-release.js +++ b/scripts/staging-profile-release.js @@ -4,6 +4,8 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { createHash } = require('node:crypto'); +const { isDeepStrictEqual } = require('node:util'); +const { TARGETS: DUES_TARGETS, validateKnownDues } = require('./staging-test-dues-contract'); const { execFileSync } = require('node:child_process'); const { validateContext, verifyCurrentSource, verifyCloudAuthority } = require('./staging-profile-authority'); @@ -163,11 +165,13 @@ async function release(env) { const inventory = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, env.GCP_ACCESS_TOKEN); check(!inventory.nextPageToken && !inventory.unreachable?.length); const existing = inventory.functions || []; - check(Array.isArray(existing) && existing.length <= 3); + check(Array.isArray(existing) && existing.length <= 5 && new Set(existing.map(fn => fn.name)).size === existing.length); + const dues = existing.filter(fn => DUES_TARGETS.includes(fn.entryPoint)); + dues.forEach(validateKnownDues); const community = existing.filter((fn) => fn.entryPoint === 'communityActions'); check(community.length <= 1); community.forEach(validateKnownCommunity); - for (const fn of existing.filter((item) => item.entryPoint !== 'communityActions')) { + for (const fn of existing.filter((item) => item.entryPoint !== 'communityActions' && !DUES_TARGETS.includes(item.entryPoint))) { const target = fn.name?.split('/').at(-1); validateHostedFunction(fn, functionSpec(target, env.GITHUB_SHA, bundle.digest, 'https://storage.googleapis.com/synthetic'), { ready: false, previousInitialRelease: true }); } @@ -192,7 +196,8 @@ async function release(env) { process.stdout.write(`staging_profile_${target}_deployed_verified\n`); } const final = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, env.GCP_ACCESS_TOKEN); - check(final.functions?.length === 2 + community.length && !final.nextPageToken && !final.unreachable?.length); + check(final.functions?.length === 2 + community.length + dues.length && !final.nextPageToken && !final.unreachable?.length); + for (const original of dues) check(isDeepStrictEqual(final.functions.find(fn => fn.name === original.name), original)); if (community.length) { const unchanged = final.functions.find((fn) => fn.name === community[0].name); validateKnownCommunity(unchanged); diff --git a/scripts/staging-test-dues-contract.js b/scripts/staging-test-dues-contract.js new file mode 100644 index 0000000..67993e3 --- /dev/null +++ b/scripts/staging-test-dues-contract.js @@ -0,0 +1,107 @@ +'use strict'; +// Fixed staging resources only. This module never reads credentials or deploys. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { isDeepStrictEqual } = require('node:util'); +const PROJECT = 'run-mprc-staging'; +const PROJECT_NUMBER = '384996399199'; // Native project identity checked October 17, 2026. +const BASE = `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/us-central1`; +const TARGETS = Object.freeze(['membershipTestDues', 'membershipTestDuesWebhook']); +const RUNTIME = `mprc-test-dues-runtime@${PROJECT}.iam.gserviceaccount.com`; +const ROLE = `projects/${PROJECT}/roles/mprcTestDuesRuntime`; +const SECRETS = Object.freeze(['MPRC_TEST_STRIPE_KEY', 'MPRC_TEST_STRIPE_WEBHOOK_SECRET']); +const PERMISSIONS = Object.freeze(['datastore.databases.get', 'datastore.entities.get', 'datastore.entities.create', 'datastore.entities.update', 'datastore.entities.list', 'firebaseauth.users.get']); +const FILES = Object.freeze(['duesTestOnly.js', 'testDuesHandlers.js', 'testDuesPolicy.js', 'testDuesStripe.js', 'testMembershipDues.js', 'membershipDuesCheckout.js', 'membershipDuesPayment.js', 'membershipAuthority.js', 'requestValidation.js', 'package.json', 'package-lock.json']); +const check = (v) => { if (!v) throw new Error('staging_test_dues_failed'); }; +function validatePlan(plan) { + check(plan && Object.getPrototypeOf(plan) === Object.prototype + && isDeepStrictEqual(Object.keys(plan).sort(), ['keyVersion', 'stripeAccount', 'webhookVersion']) + && typeof plan.stripeAccount === 'string' && /^acct_[A-Za-z0-9]{1,120}$/.test(plan.stripeAccount) + && ['keyVersion', 'webhookVersion'].every(k => typeof plan[k] === 'string' && /^[1-9][0-9]{0,9}$/.test(plan[k]))); + return plan; +} +function readPlan(filename) { + check(typeof filename === 'string' && fs.lstatSync(filename).isFile() && !fs.lstatSync(filename).isSymbolicLink() && fs.statSync(filename).size < 4096); + return validatePlan(JSON.parse(fs.readFileSync(filename, 'utf8'))); +} +function createBundle(root) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-test-dues-owned-')); + try { + for (const file of FILES) { + const source = path.join(root, 'functions', file); + check(fs.lstatSync(source).isFile() && !fs.lstatSync(source).isSymbolicLink()); + let bytes = fs.readFileSync(source); check(bytes.length < 1_000_000); + if (file === 'package.json') { + const manifest = JSON.parse(bytes); check(manifest.main === 'index.js' && manifest.engines.node === '22'); + manifest.main = 'duesTestOnly.js'; bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + } + fs.writeFileSync(path.join(directory, file), bytes, { flag: 'wx', mode: 0o600 }); + fs.utimesSync(path.join(directory, file), new Date('1980-01-01T00:00:00Z'), new Date('1980-01-01T00:00:00Z')); + } + const zip = execFileSync('zip', ['-X', '-q', '-', ...FILES], { cwd: directory, maxBuffer: 2_000_000 }); + check(zip.length > 0 && zip.length < 1_000_000); + return { zip, digest: createHash('sha256').update(zip).digest('hex') }; + } finally { fs.rmSync(directory, { recursive: true }); } +} +function functionSpec(target, sha, digest, uploadUrl, plan) { + validatePlan(plan); check(TARGETS.includes(target) && /^[a-f0-9]{40}$/.test(sha) && /^[a-f0-9]{64}$/.test(digest)); + const url = new URL(uploadUrl); + check(url.protocol === 'https:' && url.hostname === 'storage.googleapis.com' && !url.username && !url.password && !url.port); + return { + name: `projects/${PROJECT}/locations/us-central1/functions/${target}`, entryPoint: target, + runtime: 'nodejs22', serviceAccountEmail: RUNTIME, + buildServiceAccount: `projects/${PROJECT}/serviceAccounts/mprc-profile-build@${PROJECT}.iam.gserviceaccount.com`, + dockerRegistry: 'ARTIFACT_REGISTRY', dockerRepository: `projects/${PROJECT}/locations/us-central1/repositories/mprc-profile-artifacts`, + sourceUploadUrl: uploadUrl, availableMemoryMb: 256, timeout: '30s', minInstances: 0, maxInstances: 2, + ingressSettings: 'ALLOW_ALL', httpsTrigger: { securityLevel: 'SECURE_ALWAYS' }, + labels: { 'managed-by': 'mprc-test-dues-stage', 'source-sha': sha, 'bundle-a': digest.slice(0, 32), 'bundle-b': digest.slice(32) }, + environmentVariables: { FIREBASE_CONFIG: JSON.stringify({ projectId: PROJECT }), GCLOUD_PROJECT: PROJECT, + COMMERCE_ENABLED: 'false', MPRC_TEST_DUES_ENABLED: 'true', MPRC_TEST_STRIPE_ACCOUNT: plan.stripeAccount }, + secretEnvironmentVariables: SECRETS.map((key, index) => ({ key, projectId: PROJECT_NUMBER, secret: key, version: index ? plan.webhookVersion : plan.keyVersion })), + }; +} +function validateHostedFunction(actual, expected) { + check(actual && actual.status === 'ACTIVE' && /^[1-9][0-9]*$/.test(actual.versionId || '') + && !actual.eventTrigger && !actual.vpcConnector && !actual.secretVolumes?.length + && (actual.minInstances ?? 0) === 0 && actual.httpsTrigger?.securityLevel === 'SECURE_ALWAYS'); + for (const k of ['name', 'entryPoint', 'runtime', 'serviceAccountEmail', 'buildServiceAccount', 'dockerRegistry', 'dockerRepository', 'availableMemoryMb', 'timeout', 'maxInstances', 'ingressSettings']) check(actual[k] === expected[k]); + for (const k of ['labels', 'environmentVariables']) check(isDeepStrictEqual(actual[k], expected[k])); + const sortSecrets = values => [...values].sort((a, b) => a.key.localeCompare(b.key)); + check(Array.isArray(actual.secretEnvironmentVariables) && isDeepStrictEqual(sortSecrets(actual.secretEnvironmentVariables), sortSecrets(expected.secretEnvironmentVariables))); +} +// Used by older profile/community release paths only to recognize and preserve +// these exact isolated resources. It does not authorize their deployment. +function validateKnownDues(actual) { + const refs = actual?.secretEnvironmentVariables; + check(Array.isArray(refs)); + const plan = { stripeAccount: actual.environmentVariables?.MPRC_TEST_STRIPE_ACCOUNT, + keyVersion: refs.find(s => s.key === SECRETS[0])?.version, + webhookVersion: refs.find(s => s.key === SECRETS[1])?.version }; + validateHostedFunction(actual, functionSpec(actual.entryPoint, actual.labels?.['source-sha'], `${actual.labels?.['bundle-a']}${actual.labels?.['bundle-b']}`, 'https://storage.googleapis.com/synthetic', plan)); +} +function validateRuntimeAuthority({ role, policy, keys }) { + check(role.name === ROLE && !role.deleted && Array.isArray(role.includedPermissions) + && isDeepStrictEqual([...role.includedPermissions].sort(), [...PERMISSIONS].sort())); + check(Array.isArray(policy.bindings) && Array.isArray(keys) && keys.length === 0); + // Secret access belongs on the two individual resources, never a newly + // introduced project-wide accessor/admin grant. Existing owner/platform + // authority is retained; this is not an organization-wide IAM audit. + check(!policy.bindings.some(b => ['roles/secretmanager.secretAccessor', 'roles/secretmanager.admin'].includes(b.role))); + const grants = policy.bindings.filter(b => b.members?.includes(`serviceAccount:${RUNTIME}`)); + check(grants.length === 1 && grants[0].role === ROLE && !grants[0].condition); +} +function validateSecretMetadata(secret, version, policy, name, number) { + check(SECRETS.includes(name) && [PROJECT, PROJECT_NUMBER].some(project => secret.name === `projects/${project}/secrets/${name}`) + && secret.labels?.['managed-by'] === 'mprc-test-dues-stage' + && version.name === `${secret.name}/versions/${number}` && version.state === 'ENABLED'); + check(policy.bindings?.length === 1 && policy.bindings[0].role === 'roles/secretmanager.secretAccessor' + && !policy.bindings[0].condition && isDeepStrictEqual(policy.bindings[0].members, [`serviceAccount:${RUNTIME}`])); +} +function validateInvoker(policy) { + check(policy.bindings?.length === 1 && policy.bindings[0].role === 'roles/cloudfunctions.invoker' + && !policy.bindings[0].condition && isDeepStrictEqual(policy.bindings[0].members, ['allUsers'])); +} +module.exports = { PROJECT, PROJECT_NUMBER, BASE, TARGETS, RUNTIME, ROLE, SECRETS, PERMISSIONS, FILES, check, validatePlan, readPlan, createBundle, functionSpec, validateHostedFunction, validateKnownDues, validateRuntimeAuthority, validateSecretMetadata, validateInvoker }; diff --git a/scripts/staging-test-dues-release.js b/scripts/staging-test-dues-release.js new file mode 100644 index 0000000..71a111c --- /dev/null +++ b/scripts/staging-test-dues-release.js @@ -0,0 +1,157 @@ +'use strict'; +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const { isDeepStrictEqual } = require('node:util'); +const { verifyCurrentSource } = require('./staging-profile-authority'); +const { waitForOperation, functionSpec: profileSpec, validateHostedFunction: validateProfile } = require('./staging-profile-release'); +const community = require('./staging-community-release'); +const c = require('./staging-test-dues-contract'); +const { PROJECT, PROJECT_NUMBER, BASE, TARGETS, RUNTIME, SECRETS, check } = c; +const ACCOUNT = 'runmprc@gmail.com'; +const INVENTORY = `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`; +const UPDATE_FIELDS = 'entryPoint,runtime,serviceAccountEmail,buildServiceAccount,dockerRegistry,dockerRepository,sourceUploadUrl,availableMemoryMb,timeout,minInstances,maxInstances,ingressSettings,httpsTrigger,labels,environmentVariables,secretEnvironmentVariables'; +// Exact API/resource/method boundary. No secret payload access, production, +// arbitrary IAM mutation, Auth data, financial records or provider calls. +function allowedRequest(url, method, body, verifyOnly) { + const empty = body && Object.getPrototypeOf(body) === Object.prototype && Object.keys(body).length === 0; + const get = method === 'GET' && body === undefined; + if (get && (url === INVENTORY || url === `https://cloudresourcemanager.googleapis.com/v3/projects/${PROJECT}`)) return true; + if (get && /^https:\/\/cloudfunctions\.googleapis\.com\/v1\/operations\/[A-Za-z0-9_\-/]+$/.test(url)) return true; + for (const name of SECRETS) { + const secret = `https://secretmanager.googleapis.com/v1/projects/${PROJECT}/secrets/${name}`; + if (get && (url === secret || url === `${secret}:getIamPolicy` || new RegExp(`^${secret.replaceAll('.', '\\.')}\/versions\/[1-9][0-9]{0,9}$`).test(url))) return true; + } + for (const target of TARGETS) { + const fn = `${BASE}/functions/${target}`; + if (get && (url === fn || url === `${fn}:getIamPolicy`)) return true; + if (method === 'POST' && url === `${fn}:generateDownloadUrl` && empty) return true; + if (!verifyOnly && method === 'POST' && url === `${fn}:setIamPolicy`) { + check(body && Object.keys(body).length === 1 && body.policy?.version === 1 + && Object.keys(body.policy).every(k => ['version', 'etag', 'bindings'].includes(k)) + && (body.policy.etag === undefined || (typeof body.policy.etag === 'string' && body.policy.etag.length <= 512))); + c.validateInvoker(body.policy); return true; + } + if (!verifyOnly && ((method === 'POST' && url === `${BASE}/functions`) || (method === 'PATCH' && url === `${fn}?updateMask=${UPDATE_FIELDS}`))) { + if (body?.entryPoint !== target) continue; + // Reconstruct and compare the entire request; arbitrary fields fail closed. + const refs = body.secretEnvironmentVariables || []; + const expected = c.functionSpec(target, body.labels?.['source-sha'], `${body.labels?.['bundle-a']}${body.labels?.['bundle-b']}`, body.sourceUploadUrl, + { stripeAccount: body.environmentVariables?.MPRC_TEST_STRIPE_ACCOUNT, keyVersion: refs.find(s => s.key === SECRETS[0])?.version, webhookVersion: refs.find(s => s.key === SECRETS[1])?.version }); + return isDeepStrictEqual(body, expected); + } + } + return !verifyOnly && method === 'POST' && url === `${BASE}/functions:generateUploadUrl` && empty; +} +function requestFor(verifyOnly, fetchImpl = fetch) { + return async (url, token, method = 'GET', body) => { + check(allowedRequest(url, method, body, verifyOnly)); + const response = await fetchImpl(url, { method, redirect: 'error', signal: AbortSignal.timeout(15000), + headers: { Authorization: `Bearer ${token}`, 'x-goog-user-project': PROJECT, 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + check(response.ok); return response.json(); + }; +} +function validateInventory(inventory, complete = false) { + check(Array.isArray(inventory?.functions) && inventory.functions.length >= 3 && inventory.functions.length <= 5 + && !inventory.nextPageToken && !inventory.unreachable?.length); + const names = inventory.functions.map(f => f.entryPoint); + check(new Set(names).size === names.length && ['createMemberOnSignUp', 'ensureMemberProfile', 'communityActions'].every(n => names.includes(n))); + if (complete) check(TARGETS.every(n => names.includes(n))); + for (const fn of inventory.functions) { + check(/^[1-9][0-9]*$/.test(fn.versionId || '')); + if (TARGETS.includes(fn.entryPoint)) c.validateKnownDues(fn); + else if (fn.entryPoint === 'communityActions') community.validateHostedFunction(fn, community.functionSpec(fn.labels?.['source-sha'], `${fn.labels?.['bundle-a']}${fn.labels?.['bundle-b']}`, 'https://storage.googleapis.com/synthetic')); + else { + check(['createMemberOnSignUp', 'ensureMemberProfile'].includes(fn.entryPoint)); + validateProfile(fn, profileSpec(fn.entryPoint, fn.labels?.['source-sha'], `${fn.labels?.['bundle-a']}${fn.labels?.['bundle-b']}`, 'https://storage.googleapis.com/synthetic')); + } + } +} +function validateUnchanged(before, after) { + for (const original of before.functions.filter(f => !TARGETS.includes(f.entryPoint))) { + const same = after.functions.find(f => f.name === original.name); + check(same && isDeepStrictEqual(same, original)); + } +} +async function verifyArchive(native, digest, token, request, fetchImpl = fetch) { + const download = await request(`${BASE}/functions/${native.entryPoint}:generateDownloadUrl`, token, 'POST', {}); + const url = new URL(download.downloadUrl); + check(url.protocol === 'https:' && url.hostname === 'storage.googleapis.com' && !url.username && !url.password && !url.port); + const response = await fetchImpl(download.downloadUrl, { redirect: 'error', signal: AbortSignal.timeout(30000) }); + check(response.ok && response.body); + const reader = response.body.getReader(); const hash = createHash('sha256'); let bytes = 0; let done = false; + try { + while (true) { const part = await reader.read(); if (part.done) { done = true; break; } bytes += part.value.byteLength; check(bytes <= 1_000_000); hash.update(part.value); } + } finally { try { if (!done) await reader.cancel(); } finally { reader.releaseLock(); } } + check(bytes > 0 && hash.digest('hex') === digest); return bytes; +} +async function verifyFunctions({ inventory, plan, digest, token, sha, request = requestFor(true), verifyProvenance, fetchImpl = fetch }) { + validateInventory(inventory, true); const receipts = []; + for (const target of TARGETS) { + const native = inventory.functions.find(f => f.entryPoint === target); + c.validateHostedFunction(native, c.functionSpec(target, native.labels?.['source-sha'], digest, 'https://storage.googleapis.com/synthetic', plan)); + check(native.labels['source-sha'] === sha || (typeof verifyProvenance === 'function' && await verifyProvenance(native.labels['source-sha']) === true)); + c.validateInvoker(await request(`${BASE}/functions/${target}:getIamPolicy`, token)); + const bytes = await verifyArchive(native, digest, token, request, fetchImpl); + receipts.push({ target, version: native.versionId, sourceSha: native.labels['source-sha'], digest, bytes }); + } + return receipts; +} +const gcloud = (...args) => execFileSync('/opt/homebrew/bin/gcloud', [...args, `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +async function ownerToken() { + const token = gcloud('auth', 'print-access-token'); + const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${token}` }, redirect: 'error', signal: AbortSignal.timeout(15000) }); + check(response.ok && (await response.json()).email === ACCOUNT); return token; +} +async function verifyAuthority(token, plan, request = requestFor(true)) { + const project = await request(`https://cloudresourcemanager.googleapis.com/v3/projects/${PROJECT}`, token); + check(project.projectId === PROJECT && project.name === `projects/${PROJECT_NUMBER}` && project.state === 'ACTIVE'); + c.validateRuntimeAuthority({ + role: JSON.parse(gcloud('iam', 'roles', 'describe', 'mprcTestDuesRuntime', '--format=json')), + policy: JSON.parse(gcloud('projects', 'get-iam-policy', PROJECT, '--format=json')), + keys: JSON.parse(gcloud('iam', 'service-accounts', 'keys', 'list', `--iam-account=${RUNTIME}`, '--managed-by=user', '--format=json')), + }); + for (const [i, name] of SECRETS.entries()) { + const url = `https://secretmanager.googleapis.com/v1/projects/${PROJECT}/secrets/${name}`; + const number = i ? plan.webhookVersion : plan.keyVersion; + c.validateSecretMetadata(await request(url, token), await request(`${url}/versions/${number}`, token), await request(`${url}:getIamPolicy`, token), name, number); + } +} +async function run(args, root = path.resolve(__dirname, '..')) { + check(args.length === 2 && ['verify', 'deploy'].includes(args[0])); + const plan = c.readPlan(args[1]); const verifyOnly = args[0] === 'verify'; + check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + const source = { GITHUB_SHA: sha, GH_TOKEN: execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() }; + await verifyCurrentSource(source); + const token = await ownerToken(); const request = requestFor(verifyOnly); + await verifyAuthority(token, plan, request); + const inventory = await request(INVENTORY, token); validateInventory(inventory); + community.validateStagingMfa(await community.verificationRequest(`https://identitytoolkit.googleapis.com/admin/v2/projects/${PROJECT}/config`, token)); + const bundle = c.createBundle(root); + if (!verifyOnly) { + for (const target of TARGETS) { + await verifyCurrentSource(source); + const uploaded = await request(`${BASE}/functions:generateUploadUrl`, token, 'POST', {}); + const spec = c.functionSpec(target, sha, bundle.digest, uploaded.uploadUrl, plan); + const put = await fetch(uploaded.uploadUrl, { method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'application/zip', 'x-goog-content-length-range': '0,104857600' }, body: bundle.zip }); + check(put.ok); await verifyCurrentSource(source); + const exists = inventory.functions.some(f => f.entryPoint === target); + const operation = await request(exists ? `${BASE}/functions/${target}?updateMask=${UPDATE_FIELDS}` : `${BASE}/functions`, token, exists ? 'PATCH' : 'POST', spec); + await waitForOperation(operation, token, request); + c.validateHostedFunction(await request(`${BASE}/functions/${target}`, token), spec); + const prior = await request(`${BASE}/functions/${target}:getIamPolicy`, token); + check(!prior.bindings?.length || (c.validateInvoker(prior), true)); + await verifyCurrentSource(source); + await request(`${BASE}/functions/${target}:setIamPolicy`, token, 'POST', { policy: { version: 1, ...(prior.etag ? { etag: prior.etag } : {}), bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }] } }); + } + } + const final = await request(INVENTORY, token); validateInventory(final, true); validateUnchanged(inventory, final); + const receipts = await verifyFunctions({ inventory: final, plan, digest: bundle.digest, token, sha, request: requestFor(true), verifyProvenance: async nativeSha => { execFileSync('git', ['merge-base', '--is-ancestor', nativeSha, sha], { cwd: root }); return true; } }); + const last = await request(INVENTORY, token); validateInventory(last, true); check(isDeepStrictEqual(final, last)); + await verifyAuthority(token, plan); await verifyCurrentSource(source); + process.stdout.write(JSON.stringify({ status: verifyOnly ? 'staging_test_dues_verified_without_deploy' : 'staging_test_dues_deployed_verified', sha, receipts }) + '\n'); +} +if (require.main === module) run(process.argv.slice(2)).catch(() => { process.stderr.write('staging_test_dues_failed\n'); process.exitCode = 1; }); +module.exports = { allowedRequest, requestFor, validateInventory, validateUnchanged, verifyArchive, verifyFunctions, ownerToken, verifyAuthority, run }; diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx index c871e23..21521fd 100644 --- a/src/pages/community/Community.test.tsx +++ b/src/pages/community/Community.test.tsx @@ -8,6 +8,7 @@ import Community from './Community'; import { callCommunity } from '../../services/community/communityService'; let mockUser: any = { uid: 'runner-a' }; +let mockDuesEnabled = false; const mockApp = { options: { projectId: 'run-mprc-staging' } }; let mockFunctions: any = { app: mockApp }; let mockAuth: any; @@ -21,6 +22,7 @@ jest.mock('firebase/auth', () => ({ jest.mock('../../services/hooks/useAuth', () => ({ useAuth: () => ({ user: mockUser, isLoading: false }) })); jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: () => ({ services: mockServices }) })); jest.mock('../../services/community/communityService', () => ({ COMMUNITY_ENABLED: true, callCommunity: jest.fn() })); +jest.mock('../../services/community/testDuesService', () => ({ get TEST_DUES_ENABLED() { return mockDuesEnabled; } })); const call = callCommunity as jest.Mock; const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); afterAll(() => { @@ -63,12 +65,24 @@ const show = () => ( ); beforeEach(() => { jest.clearAllMocks(); mockUser = { uid: 'runner-a' }; + mockDuesEnabled = false; mockFunctions = { app: mockApp }; mockAuth = { app: mockApp, tenantId: null, currentUser: { uid: 'runner-a', emailVerified: true } }; mockServices = { firebaseResources: { functions: mockFunctions, auth: mockAuth } }; Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { randomUUID: () => '123e4567-e89b-42d3-a456-426614174000' } }); call.mockResolvedValue(state()); }); +test.each([false, true])('membership card and dues link agree with the connected test flag: %s', async (enabled) => { + mockDuesEnabled = enabled; + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + if (enabled) { + expect(screen.getByRole('link', { name: /Test annual dues/ })).toBeInTheDocument(); + expect(screen.getByText(/Verified test payment activates your test membership automatically/)).toBeInTheDocument(); + } else { + expect(screen.queryByRole('link', { name: /Test annual dues/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Online dues and family invitations are not available here yet/)).toBeInTheDocument(); + } +}); test('signed-out page makes no backend call', () => { mockUser = null; render(show()); expect(screen.getByRole('link', { name: 'Sign in to continue' })).toBeInTheDocument(); diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 62d2b97..4efd662 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -207,7 +207,7 @@ export default function Community() { {status.membership.applicationStatus.replace(/_/g, ' ')} .
-This test screen records a membership request only. Online dues and family invitations are not available here yet.
+{TEST_DUES_ENABLED ? 'Use Test annual dues to pay in the Stripe sandbox. Verified test payment activates your test membership automatically. Family invitations are not available yet.' : 'This test screen records a membership request only. Online dues and family invitations are not available here yet.'}
{status.membership.entitlement === 'current_member' ? 'Test membership active.' : 'Test membership is not active.'}
{status.membership.entitlement === 'current_member' && status.membership.accessThroughMs && ( diff --git a/tests/staging-profile-authority.test.js b/tests/staging-profile-authority.test.js index 2167807..b806cea 100644 --- a/tests/staging-profile-authority.test.js +++ b/tests/staging-profile-authority.test.js @@ -1,6 +1,7 @@ 'use strict'; require('./staging-community-release.test.js'); +require('./staging-test-dues-release.test.js'); const assert = require('node:assert/strict'); const { createHash } = require('node:crypto'); diff --git a/tests/staging-test-dues-release.test.js b/tests/staging-test-dues-release.test.js new file mode 100644 index 0000000..02ff211 --- /dev/null +++ b/tests/staging-test-dues-release.test.js @@ -0,0 +1,114 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const c = require('../scripts/staging-test-dues-contract'); +const r = require('../scripts/staging-test-dues-release'); +const community = require('../scripts/staging-community-release'); +const profile = require('../scripts/staging-profile-release'); +const ROOT = path.resolve(__dirname, '..'); +const plan = { stripeAccount: 'acct_synthetic', keyVersion: '1', webhookVersion: '2' }; +const sha = 'a'.repeat(40); const digest = 'b'.repeat(64); +const spec = target => c.functionSpec(target, sha, digest, 'https://storage.googleapis.com/synthetic', plan); +const native = target => ({ ...spec(target), status: 'ACTIVE', versionId: '1' }); +const inventory = () => ({ functions: [ + ...['createMemberOnSignUp', 'ensureMemberProfile'].map(n => ({ ...profile.functionSpec(n, sha, digest, 'https://storage.googleapis.com/synthetic'), status: 'ACTIVE', versionId: '1' })), + { ...community.functionSpec(sha, digest, 'https://storage.googleapis.com/synthetic'), status: 'ACTIVE', versionId: '7' }, + ...c.TARGETS.map(native), +] }); +test('plan allows only an explicit account and immutable versions; secrets never belong in it', () => { + assert.equal(c.validatePlan(plan), plan); + for (const p of [null, {}, { ...plan, keyVersion: 'latest' }, { ...plan, keyVersion: '0' }, { ...plan, webhookVersion: 2 }, { ...plan, stripeAccount: 'sk_test_synthetic' }, { ...plan, key: 'sk_test_synthetic' }, { ...plan, project: 'production' }]) assert.throws(() => c.validatePlan(p)); +}); +test('source archive is deterministic and includes every relative runtime dependency, never legacy entrypoints', () => { + const a = c.createBundle(ROOT); const b = c.createBundle(ROOT); + assert.equal(a.digest, b.digest); assert.ok(a.zip.equals(b.zip) && a.zip.length < 1_000_000); + assert.ok(c.FILES.includes('duesTestOnly.js') && !c.FILES.includes('index.js') && !c.FILES.includes('communityOnly.js')); + for (const name of c.FILES.filter(n => n.endsWith('.js'))) { + const source = fs.readFileSync(path.join(ROOT, 'functions', name), 'utf8'); + for (const match of source.matchAll(/require\(['"]\.\/([^'"]+)['"]\)/g)) assert.ok(c.FILES.includes(`${match[1]}.js`), `missing relative import from ${name}`); + } +}); +test('exact two-function contract rejects live flags, other identities, secret drift and extra authority', () => { + for (const target of c.TARGETS) { + const expected = spec(target); const valid = native(target); + assert.doesNotThrow(() => c.validateHostedFunction(valid, expected)); + assert.doesNotThrow(() => c.validateKnownDues(valid)); + for (const change of [{ status: 'DEPLOYING' }, { versionId: '' }, { serviceAccountEmail: 'default' }, { minInstances: 1 }, { maxInstances: 3 }, { runtime: 'nodejs20' }, { secretVolumes: [{}] }, { vpcConnector: 'other' }, { eventTrigger: {} }, { environmentVariables: { ...valid.environmentVariables, COMMERCE_ENABLED: 'true' } }, { environmentVariables: { ...valid.environmentVariables, GCLOUD_PROJECT: 'production' } }, { secretEnvironmentVariables: valid.secretEnvironmentVariables.map(s => ({ ...s, version: 'latest' })) }, { secretEnvironmentVariables: [...valid.secretEnvironmentVariables, { key: 'LIVE_KEY' }] }, { labels: { ...valid.labels, extra: 'unknown' } }]) assert.throws(() => c.validateHostedFunction({ ...valid, ...change }, expected)); + } + assert.throws(() => c.functionSpec('index', sha, digest, 'https://storage.googleapis.com/synthetic', plan)); + assert.throws(() => c.functionSpec(c.TARGETS[0], sha, digest, 'https://storage.googleapis.com.attacker.invalid/upload', plan)); +}); +test('runtime has exactly six data/auth permissions, no project secret grant and no managed user key', () => { + const data = { role: { name: c.ROLE, includedPermissions: [...c.PERMISSIONS] }, policy: { bindings: [{ role: c.ROLE, members: [`serviceAccount:${c.RUNTIME}`] }] }, keys: [] }; + assert.doesNotThrow(() => c.validateRuntimeAuthority(data)); + for (const change of [{ keys: [{}] }, { role: { ...data.role, deleted: true } }, { role: { ...data.role, includedPermissions: [...c.PERMISSIONS, 'datastore.entities.delete'] } }, { policy: { bindings: [...data.policy.bindings, { role: 'roles/secretmanager.secretAccessor', members: [`serviceAccount:${c.RUNTIME}`] }] } }, { policy: { bindings: [{ ...data.policy.bindings[0], condition: {} }] } }]) assert.throws(() => c.validateRuntimeAuthority({ ...data, ...change })); +}); +test('secret metadata checks only pinned enabled versions and per-secret runtime access', () => { + for (const project of [c.PROJECT, c.PROJECT_NUMBER]) { + const secret = { name: `projects/${project}/secrets/${c.SECRETS[0]}`, labels: { 'managed-by': 'mprc-test-dues-stage' } }; + const version = { name: `${secret.name}/versions/1`, state: 'ENABLED' }; + const policy = { bindings: [{ role: 'roles/secretmanager.secretAccessor', members: [`serviceAccount:${c.RUNTIME}`] }] }; + assert.doesNotThrow(() => c.validateSecretMetadata(secret, version, policy, c.SECRETS[0], '1')); + for (const [s, v, p] of [[{ ...secret, name: 'projects/other/secrets/MPRC_TEST_STRIPE_KEY' }, version, policy], [secret, { ...version, state: 'DISABLED' }, policy], [secret, { ...version, name: `${secret.name}/versions/latest` }, policy], [secret, version, { bindings: [{ ...policy.bindings[0], members: ['allUsers'] }] }], [secret, version, { bindings: [...policy.bindings, policy.bindings[0]] }]]) assert.throws(() => c.validateSecretMetadata(s, v, p, c.SECRETS[0], '1')); + } +}); +test('project-wide secret access for other accounts is not accepted as minimum setup', () => { + for (const role of ['roles/secretmanager.secretAccessor', 'roles/secretmanager.admin']) { + const authority = { role: { name: c.ROLE, includedPermissions: [...c.PERMISSIONS] }, policy: { bindings: [{ role: c.ROLE, members: [`serviceAccount:${c.RUNTIME}`] }, { role, members: ['serviceAccount:other@run-mprc-staging.iam.gserviceaccount.com'] }] }, keys: [] }; + assert.throws(() => c.validateRuntimeAuthority(authority)); + } +}); +test('transport blocks secret payloads, production, arbitrary fields and all verification writes before fetch', async () => { + const calls = []; const request = r.requestFor(true, async (...args) => { calls.push(args); return { ok: true, json: async () => ({}) }; }); + for (const [url, method, body] of [ + ['https://secretmanager.googleapis.com/v1/projects/run-mprc-staging/secrets/MPRC_TEST_STRIPE_KEY/versions/1:access', 'GET'], + ['https://cloudfunctions.googleapis.com/v1/projects/production/locations/us-central1/functions/membershipTestDues', 'GET'], + [`${c.BASE}/functions/membershipTestDues:setIamPolicy`, 'POST', { policy: { version: 1, bindings: [] } }], + [`${c.BASE}/functions`, 'POST', spec(c.TARGETS[0])], + [`${c.BASE}/functions:generateUploadUrl`, 'POST', {}], + ['https://iam.googleapis.com/v1/projects/run-mprc-staging/serviceAccounts/other:setIamPolicy', 'POST', {}], + ]) await assert.rejects(request(url, 'synthetic', method, body)); + assert.equal(calls.length, 0); + await request(`${c.BASE}/functions/membershipTestDues`, 'synthetic'); + await request(`${c.BASE}/functions/membershipTestDues:generateDownloadUrl`, 'synthetic', 'POST', {}); + assert.equal(calls.length, 2); assert.ok(calls.every(([, opts]) => opts.redirect === 'error' && opts.headers['x-goog-user-project'] === c.PROJECT)); + assert.equal(r.allowedRequest(`${c.BASE}/functions`, 'POST', spec(c.TARGETS[0]), false), true); + assert.equal(r.allowedRequest(`${c.BASE}/functions`, 'POST', { ...spec(c.TARGETS[0]), buildEnvironmentVariables: { OTHER: 'value' } }, false), false); +}); +test('inventory requires exact existing services; missing, duplicate, foreign or changed resources stop', () => { + assert.doesNotThrow(() => r.validateInventory(inventory(), true)); + const base = inventory(); base.functions = base.functions.slice(0, 3); assert.doesNotThrow(() => r.validateInventory(base)); + assert.throws(() => r.validateInventory(base, true)); + for (const mutate of [i => { i.nextPageToken = 'more'; }, i => { i.functions.push(i.functions[0]); }, i => { i.functions[3].name = i.functions[3].name.replace(c.PROJECT, 'production'); }, i => { i.functions[2].entryPoint = 'unknown'; }, i => { i.functions[4].environmentVariables.COMMERCE_ENABLED = 'true'; }]) { + const changed = inventory(); mutate(changed); assert.throws(() => r.validateInventory(changed)); + } + const changed = inventory(); changed.functions[0].versionId = '2'; assert.throws(() => r.validateUnchanged(inventory(), changed)); + assert.doesNotThrow(() => r.validateUnchanged(inventory(), inventory())); +}); +test('native verification requires both actual archives, exact account/versions, provenance and public transport only', async () => { + const bytes = Buffer.from('synthetic archive'); const hash = createHash('sha256').update(bytes).digest('hex'); + const data = inventory(); for (const fn of data.functions.slice(3)) fn.labels = { ...fn.labels, 'bundle-a': hash.slice(0, 32), 'bundle-b': hash.slice(32) }; + const args = { inventory: data, plan, digest: hash, sha, token: 'synthetic', request: async url => url.endsWith(':getIamPolicy') ? { bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }] } : { downloadUrl: 'https://storage.googleapis.com/synthetic' }, fetchImpl: async (_url, opts) => { assert.equal(opts.headers, undefined); return new Response(bytes); } }; + assert.equal((await r.verifyFunctions(args)).length, 2); + await assert.rejects(r.verifyFunctions({ ...args, digest })); + await assert.rejects(r.verifyFunctions({ ...args, plan: { ...plan, stripeAccount: 'acct_other' } })); + await assert.rejects(r.verifyFunctions({ ...args, sha: 'c'.repeat(40) })); + await assert.rejects(r.verifyFunctions({ ...args, request: async () => ({ bindings: [{ role: 'roles/editor', members: ['allUsers'] }] }) })); + await assert.rejects(r.verifyFunctions({ ...args, fetchImpl: async () => new Response(Buffer.from('wrong archive')) })); +}); +test('CLI rejects malformed invocation without reading credentials and prints only a fixed error', () => { + const result = spawnSync(process.execPath, [path.join(ROOT, 'scripts/staging-test-dues-release.js'), 'deploy', 'private-arg', 'extra'], { encoding: 'utf8' }); + assert.equal(result.status, 1); assert.equal(result.stdout, ''); assert.equal(result.stderr, 'staging_test_dues_failed\n'); +}); +test('ordinary Hosting removes inherited dues availability; explicit mode requires exact staging', () => { + const { duesEnvironment } = require('../scripts/staging-community-hosting'); + const env = { REACT_APP_TEST_DUES_STAGING: 'true', REACT_APP_FIREBASE_ENVIRONMENT: 'staging', REACT_APP_FIREBASE_PROJECT_ID: c.PROJECT, REACT_APP_COMMUNITY_STAGING: 'true' }; + assert.equal(duesEnvironment(env, null).REACT_APP_TEST_DUES_STAGING, undefined); + assert.equal(env.REACT_APP_TEST_DUES_STAGING, 'true'); + assert.equal(duesEnvironment(env, plan).REACT_APP_TEST_DUES_STAGING, 'true'); + for (const change of [{ REACT_APP_FIREBASE_ENVIRONMENT: 'production' }, { REACT_APP_FIREBASE_PROJECT_ID: 'other' }, { REACT_APP_COMMUNITY_STAGING: 'false' }]) assert.throws(() => duesEnvironment({ ...env, ...change }, plan)); +});