From 65608e5e5118399d5bccb6a32576ff08e2ecf780 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Wed, 22 Jul 2026 02:44:14 -0700 Subject: [PATCH] fix: prepare App Check after Strava callback cleanup --- SECURITY.md | 9 +- SYSTEM_DESIGN.md | 38 +- docs/officers/EVENTS_SHOP_MEMBERS.md | 71 +- src/pages/account/Account.test.tsx | 557 ++++++++++++++- src/pages/account/StravaCallback.tsx | 149 +++- .../firebase/FirebaseResources.test.ts | 643 +++++++++++++++++- src/services/firebase/FirebaseResources.ts | 160 ++++- src/services/monitoring/capabilityCallback.ts | 80 ++- 8 files changed, 1616 insertions(+), 91 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 0787289..b29d722 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,7 +46,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-006 | `main` source now removes the recursive browser-admin rule, but deployment repeatedly skipped Firebase and the live Rules revision is unproven. | A stale deployed ruleset may still expose secrets or financial/audit writes to a compromised admin session. | Use #105 to stage and deploy the exact tested #100 Rules revision, verify deny behavior with synthetic accounts, and record rollback. | | RISK-007 | Repository source rejects unverified targets at the two existing role-grant endpoints. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) adds exact verified-email checks to role-based Firestore Rules, and AUTH-001C [#209](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/209) tracks the matching gate at current Functions role consumers. Firebase deployment, verification-mirror refresh, revocation, authoritative membership, scoped capability/recent-auth controls, and replacement of the legacy static-key endpoint remain unproven. | Older deployed code or a remaining role-only server guard can let an unverified known-email account use member/admin authority. CSV export also retains separate minimization, recent-auth, and audit risks. | Deploy and verify the exact Rules/Functions through #105, complete the remaining AUTH-001 mirror/revocation work, require authoritative membership and scoped privileged access, harden roster export under #116, and retire the static-key endpoint. | | RISK-008 | `requireAppCheck` is controlled by optional `ENFORCE_APP_CHECK`; repository/deployment config does not prove it is true. Missing site key also disables client App Check. | Public callable functions may be scripted directly, enabling abuse and cloud/Stripe cost amplification. | Use Firebase runtime `enforceAppCheck: true` for sensitive callables, reCAPTCHA Enterprise, staged metrics, and no environment fail-open. | -| RISK-009 | #99 source preserves and validates the complete same-origin Pages callback route, applies `strict-origin` before subresources, and suppresses initial callback App Check/telemetry startup; #126 adds that standalone suite as a blocking hosted CI step. No production Stripe/Strava callback has been rehearsed and the live custom domain uses a different host. | Hosting drift or an untested deployment can still break return links even when source tests pass. Enforcing App Check too early on the three callback callables would also break confirmation or Strava exchange. | Keep the hosted callback gate blocking; keep `lookupRegistration`/`lookupOrder` deferred until DATA-001A and `stravaExchangeCode` deferred until OAUTH-001C within canonical tracker [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); verify test-mode callbacks only after provider gates; consolidate hosting under WEB-001. | +| RISK-009 | #99 source preserves and validates the complete same-origin Pages callback route, applies `strict-origin` before subresources, and suppresses initial callback App Check/telemetry startup; #126 adds that standalone suite as a blocking hosted CI step. #443 adds only the source/test Strava same-document handoff after the native and Router callback locations are clean. No production Stripe/Strava callback, provider-backed App Check token behavior, or live custom-domain handoff has been rehearsed, and the live custom domain uses a different host. | Hosting, provider, configuration, or release drift can still break return links or the App Check handoff even when source tests pass. Enforcing a callback callable without its matching released client handoff and staged provider evidence can also break confirmation or Strava exchange. | Keep the hosted callback gate blocking; keep `lookupRegistration`/`lookupOrder` deferred until DATA-001A; keep native enforcement for `stravaExchangeCode` deferred to ABUSE-001A2 after the exact #443 website source and provider-backed Enterprise token behavior are proven in protected staging; verify test-mode callbacks only after provider gates; consolidate hosting under WEB-001. | | RISK-010 | Development/test now use synthetic loopback Auth, Firestore, and Functions, but optimized previews still target production Firebase and emulated Functions can call outside providers. | A maintainer can mistake a preview or Firebase emulator for complete staging and affect production/provider systems. | Prohibit private actions in previews; add dedicated staging/config under #105/CONFIG; prove test keys/sinks before provider flows. | | RISK-011 | Local cancellation only changes Firestore; it does not expire the active Stripe Session. The webhook can later move a cancelled record back to paid. | Customers can pay a supposedly cancelled registration/order; capacity/inventory and support state diverge. | Add explicit cancellation saga with Stripe Session expiry and allowed-state transitions. | | RISK-012 | Repository source now denies every admitted positive late-registration amount before identifier/token allocation, registration writes, Stripe construction, or Product/Price/Payment Link calls, and the Admin screen exposes only the exact-zero compatibility path. Firebase deployment is unproven, and legacy reusable Payment Links may still exist. | Older deployed code or an existing link can still create unreconciled or repeated charges/Sessions for one logical registration. The exact-zero local record is also marked paid without Stripe and is not payment, free, comp, or membership authority. | Keep paid late registration unavailable; inventory, deactivate, and reconcile legacy links privately; then replace them with one-off idempotent Checkout Sessions through the normal registration/payment lifecycle. | @@ -107,7 +107,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only projection foundation for attempt-2 parts of RISK-003 and RISK-020 | PAY-002B2C4C2B1 is tracked in live [#280](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/280). The unused pure projector reads only named own data descriptors from an untrusted Session-like object and its SDK-attached `lastResponse`; proxies, accessors, inherited selected fields, wrong descriptor flags, coercion objects, and malformed technical values fail with one fixed redacted error. Unknown fields are ignored without enumeration. The fresh frozen null-prototype result fixes schema `1`, provider `stripe`, and operation `checkout_session_create`, then contains technically bounded server-only Session business primitives and only fixed URL/response categories. Raw URL, request ID, idempotency key, Stripe account ID, API-version text, metadata, contact data, client secret, callback URLs, body, headers, socket, and raw response never enter the result. | `untrusted_checkout_session_projection` remains forgeable, server-only, and unsafe to log, expose, or persist. Its fixed provider/operation labels are not provider-origin or execution proof. A bounded Session ID is not persistence authorization; a bounded HTTPS URL category does not approve its host, callbacks, or fragment; response categories do not prove provider origin/account, dispatch/delivery, key use, plan/send/configuration binding, current time, payment, inventory/capacity, retention, or business state. #280 adds no SDK/provider call, C4C1 positive mapping, journal/runtime import, persistence, or external/live proof. C4C2B2 must control the SDK promise, compare raw memory-only facts with trusted C4A/C4B/configuration evidence, validate the raw URL, bind business/time facts, persist only approved evidence, and return a current URL without logging or storing the URL. | | Source-only transport-comparison foundation for attempt-2 parts of RISK-003 and RISK-020 | PAY-002B2C4C2B2A is tracked in live [#285](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/285). The unused pure classifier revalidates the exact frozen null-prototype #280 projection and one exact schema-1 capsule of own-data, non-Proxy, non-accessor memory-only primitives. Exact API-version `2023-10-16`, idempotency-key, and optional-account equality plus safe projection categories returns only a fresh fixed `untrusted_transport_binding_candidate`; valid mismatch or insufficient evidence returns fixed `reconciliation_required`; malformed input throws one fixed redacted error. Raw Session ID, URL, request ID, API/key/account text, source objects, personal data, and secrets never enter output, logs, persistence, or artifacts. | Equality is not Stripe origin/account control, same-SDK-promise provenance, dispatch/delivery, C4A/C4B/configuration binding, idempotent request proof, approved URL/callback, business/time state, retention/persistence, replay, C4C1 mapping, runtime adoption, deployment, or live proof. Both account values missing proves only matching absence. The word `expected` supplies no trust. A future controller must capture one synchronous call result, derive expectations from the active plan and protected configuration, validate the memory-only URL, attach current business/time evidence, and persist only approved server evidence. | | Foundation only for registration/audit parts of RISK-003, RISK-020, and RISK-032 | PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). The unused server-only transaction creates one immutable `checkoutRequests/{commandKeyHash}` registered record and deterministic revision-1 `auditEvents` partner atomically. Exact retries are read-only. Same-key type/schema/payload conflicts and corrupt, orphan, future, or unexpected stored pairs fail closed without repair. Environment/caller-scope changes derive different B1 keys; a stored mismatch under one key is corruption, not a cross-scope lookup. Fixed results expose no raw identity, UUID, payload, hash, path, or Firestore detail. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs its dedicated exact demo-project/opt-in suite as a named hosted job and protected-release prerequisite. | Registration is neither authorization nor execution permission. #169 keeps the root and revision-1 audit immutable, places lifecycle state in the separate server-only subcollection, fails closed on malformed partners, and proves in synthetic tests that stale workers cannot finalize. The hosted emulator result is still synthetic source proof, not deployment or provider proof. No TTL is safe: [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before deletion. Source/tests/merge do not prove Firebase deployment, Stripe configuration, or live behavior. | -| RISK-008 | ABUSE-001A1 is tracked in live [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159). The source-only slice replaces the browser reCAPTCHA v3 constructor with the pinned Firebase SDK's Enterprise provider and preserves local/test/capability-callback shutdown, missing-key behavior, token refresh, Analytics shutdown, and fixed redacted failure diagnostics. Synthetic tests use no real key or provider call. | No Enterprise key, allowed-domain policy, provider console setting, protected build variable, token exchange, staged metric, callable enforcement, website/Firebase publication, or live behavior is configured or proven. Release `29254280177` failed closed before build because the public site-key variable was absent. Complete #113/#133, ABUSE-001A2, #136/WEB-001, staged metrics, and provider readback before enforcement or a live claim. | +| RISK-008 | ABUSE-001A1 is tracked in live [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159). The source-only slice replaces the browser reCAPTCHA v3 constructor with the pinned Firebase SDK's Enterprise provider and preserves local/test and initial capability-callback shutdown, missing-key behavior, token refresh, Analytics shutdown, and fixed redacted failure diagnostics. #443 separately adds only the source/test Strava clean-page readiness handoff. Synthetic tests use no real key or provider call. | No Enterprise key, allowed-domain policy, provider console setting, protected build variable, configured provider-backed token readiness or acceptance evidence, staged metric, callable enforcement, website/Firebase publication, or live behavior is configured or proven. Release `29254280177` failed closed before build because the public site-key variable was absent. Complete #113/#133, ABUSE-001A2, #136/WEB-001, staged metrics, and provider readback before enforcement or a live claim. | | RISK-017 and RISK-018 | PAY-001A is complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). PAY-001B1 is tracked in live [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219): the website sends only the active field set and omits a volunteer tier; the race callable then adopts an exact bounded root/runner parser before Firestore and matches answers to the selected bounded server event/volunteer field schema before rate limits, role/capacity work, token creation, writes, or Stripe. Unknown fields, malformed prototypes/accessors/proxies, unsafe Unicode, oversized values, invalid participant/volunteer/tier combinations, non-literal waiver acceptance, malformed selected definitions, duplicate keys/options, missing/unknown/wrong-type answers, and invalid select choices return one fixed non-identifying failure. Opaque event IDs remain byte-identical and are encoded before callback URL use. PAY-001C1B is tracked in live [#389](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/389): the merchandise callable strict-parses its exact request root and buyer before Firestore, rate limits, or Stripe, then matches the size and color selections against the stored catalog option lists before the price projection or Stripe, requiring one in-list selection per offered dimension, rejecting a selection for an absent dimension, and returning one fixed non-identifying failure that does not echo the field; the website sends only offered options. PAY-001C1C is tracked in live [#393](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/393): stacked on C1B, the callable accepts an optional bounded `quantity` (absent means one unit; a present value must be a safe integer in `[1, MAX_MERCH_QUANTITY]` or it is the same fixed denial before any side effect) and persists an immutable server-owned price snapshot — the unit price is taken only from the stored product, the quantity is re-validated, and the total is recomputed under safe-integer and Stripe-minimum guards, collapsing any fault to unavailable — while the order stores the total `amountCents` the webhook money reconciliation requires, Stripe charges `unit_amount × quantity`, and the Stripe metadata is unchanged. | The browser projection is compatibility hygiene, never authority. This is source/test evidence, not deployment or production protection. PAY-001B2 still must persist immutable event-field, price, and waiver snapshots and prove compatibility in protected staging. PAY-001C1C adds the merchandise price-snapshot and multi-quantity source contracts; a browser quantity selector, quantity-in-metadata reconciliation (PAY-005), the rest of PAY-001C (cart/multi-line, inventory, canonical variant IDs), and PAY-001D must still adopt their exact schemas. Request IDs, persistence-first records, capacity holds, provider idempotency, provider configuration, and live behavior remain open. Safe validation/log primitives do not replace approved privacy purpose, retention, access, monitoring, or provider controls. | | RISK-001, RISK-002, RISK-034 | PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | Firebase was not deployed. Complete CONFIG/PAY-001/PAY-002 contracts, metadata schema allowlist/migration, canonical reducers/reservations/outbox, retry/dead-letter/TTL/alerts, emulator integration, Stripe test-mode rehearsal, protected deploy, and reconcile. | | RISK-006, part of RISK-024/RISK-032 | #100 merged at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. OAuth secrets and server-owned financial/operational collections are denied; current catalog writes are restricted to inert drafts/display fields. | Firebase deployment skipped. Stage/deploy the exact Rules revision under #105, verify synthetic deny behavior, then move remaining catalog mutation to scoped APIs under ADMIN-001. | @@ -129,8 +129,9 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001D6 [#381](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/381) resolves the sole root `picomatch` 2.x node from 2.3.1 to the patched 2.3.2 release without changing `package.json`; the existing `jest-util` `^2.2.3` and `micromatch` `^2.3.1` ranges already admit it. The exact lock change touches only that node's version, public-registry URL, and integrity while its non-development classification, MIT license, `>=8.6` engine, parent ranges, and the separate development-only `tinyglobby` picomatch 4.0.4 copy stay fixed. A dependency-free Node test pins those identities and ranges; deterministic public-API regressions prove that inherited POSIX names cannot inject host method text into a compiled expression while ordinary `[[:alpha:]]` matching remains compatible. A fresh 2026-07-19 UTC Node 20.19.5/npm 10.8.2 full audit falls from 65 findings (1 critical, 32 high) to 64 (1 critical, 31 high), and the production-only snapshot falls from 6 findings (0 critical, 3 high, 2 moderate, 1 low) to 5 (0 critical, 2 high, 2 moderate, 1 low), removing GHSA-3v7f-55p6-f55p and GHSA-c2c7-rcm5-vvqj from the sole affected node. | No application source imports `picomatch`, and clean before/after optimized builds on the same toolchain are byte-identical: the same 59 files and 19,475,953 bytes. The sorted per-file SHA-256 manifest has aggregate SHA-256 `57f2db4d6217de2f4e158d357aa928cb0af2b6a18238d61d3d866ffd53f234ba`. `npm ls picomatch --all` still reports the pre-existing hoisted `fdir` `^3 || ^4` mismatch; this patch neither introduces nor repairs that separate build-tool layout debt. The remaining root critical `protobufjs` finding is in the old development-only `firebase-tools` chain and requires a separate major CLI, emulator, and protected-release compatibility review; all other root and Functions findings also remain unresolved and are not accepted as safe. This source/test change does not publish the website, deploy Firebase, change a provider, inspect production data, or prove live behavior. | | Part of RISK-014 | SUPPLY-001D7 [#385](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/385) resolves the sole root, non-development `yaml` 1.x node from 1.10.2 to the patched 1.10.3 release without changing `package.json`; the existing `cosmiconfig` `^1.10.0`, `cssnano` `^1.10.2`, and nested `cosmiconfig` `^1.7.2` ranges already admit it. The exact lock change touches only that node's version, public-registry URL, and integrity while the three separate development-only `yaml` 2.x copies remain fixed. A dependency-free Node test pins all four locked identities and development classifications, the three unchanged parent ranges, and the installed root version. Its fixed 601-byte collection input runs in a bounded child process with a reduced stack and proves the patched public `parseDocument` API reports one parser-owned `YAMLSemanticError` instead of exposing raw `RangeError` failures. A fresh 2026-07-19 UTC Node 20.20.2/npm 10.8.2 full audit remains 64 findings (1 critical, 31 high, 19 moderate, 13 low) because vulnerable development-only `yaml` 2.7.1 and 2.8.0 copies remain; the production-only snapshot falls from 5 findings (2 high, 2 moderate, 1 low) to 4 (2 high, 1 moderate, 1 low). | No application source imports `yaml`, and clean before/after optimized builds on the same toolchain are byte-identical: the same 59 files and 19,489,263 bytes. The sorted per-file SHA-256 manifest has aggregate SHA-256 `0207e35601232cd74febaa6e72b9e2322f2159276f32d9ce046f8b050f0f40ed`. The two affected development-only `yaml` 2.x copies and all other root and Functions findings remain unresolved and are not accepted as safe; they require separate compatibility review rather than widening this lock-only production patch. Source, tests, and any merge do not publish the website, deploy Firebase, change a provider, inspect production data, or prove live behavior. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | -| RISK-009, part of RISK-024 | OAUTH-001C1H [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) replaces browser-generated/session-storage-authoritative Strava OAuth state with a 32-byte server-issued challenge. Source stores only a SHA-256 digest in one browser-denied per-UID secret record, binds it to exact UID and decoded Auth `auth_time`, expires it after ten minutes, and transactionally deletes one match before provider exchange or connection writes. A later begin overwrites the older record; mismatch, expiry, replay, and the concurrent loser stop with one fixed result before Strava. Synthetic tests use mocks and no real callback or provider value. | This source does not deploy either Function or the website, configure Strava, prove the custom App Check guard is enabled, enable native runtime App Check enforcement, inspect production data, or prove live OAuth behavior. The initial capability callback still suppresses browser App Check startup, so fail-closed exchange enforcement also needs a separately reviewed clean-page handoff. Scope/account policy, refresh concurrency, provider/local reconciliation, revoke/audit, IAM/encryption, and provider/live evidence remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | -| Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | +| RISK-009, part of RISK-024 | OAUTH-001C1H [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) replaces browser-generated/session-storage-authoritative Strava OAuth state with a 32-byte server-issued challenge. Source stores only a SHA-256 digest in one browser-denied per-UID secret record, binds it to exact UID and decoded Auth `auth_time`, expires it after ten minutes, and transactionally deletes one match before provider exchange or connection writes. A later begin overwrites the older record; mismatch, expiry, replay, and the concurrent loser stop with one fixed result before Strava. Synthetic tests use mocks and no real callback or provider value. | This source does not deploy either Function or the website, configure Strava, prove the custom App Check guard is enabled, enable native runtime App Check enforcement, inspect production data, or prove live OAuth behavior. #443 separately supplies only the source/test clean-page readiness handoff while preserving initial callback suppression. Scope/account policy, refresh concurrency, provider/local reconciliation, revoke/audit, IAM/encryption, configured Enterprise evidence, native enforcement, and provider/live evidence remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | +| RISK-008, RISK-009, part of RISK-024 | OAUTH-001C1I [#443](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/443) preserves initial capability-callback App Check suppression, then permits only a document that began on a normalized Strava callback to initialize the existing Enterprise provider after both current callback locations are clean, Router page state is `null`, and native history state is absent or the exact matching BrowserRouter index/key/empty-user-state record. One single-flight readiness attempt precedes one existing exchange. Before exchange admission, the callback rechecks the clean path, Router entry key, and exact UID, service, Firebase resources, app, attempt, and page lifecycle; dirty or reinjected paths, extra or mismatched history state, missing configuration, provider/readiness failure, or stale mounted context use one fixed failure and make no exchange, while an unmounted completion is inert. Authentication, registration, and shop callbacks are ineligible and keep their existing flow. The method never receives callback values or returns, inspects, logs, or stores the token-ready result. Local/test remain off and ordinary production startup remains eager. | This is source and synthetic-test ordering evidence only. It does not configure an Enterprise key or domains, prove a provider-backed token or Firebase acceptance, enable native `enforceAppCheck`, change a Function or Rule, publish the website, deploy Firebase, configure or contact Strava, inspect production data, or prove live OAuth behavior. App Check initialization is not reversible; later reinjection is scrubbed/discarded but earlier browser, hosting, provider, and network copies are not erased. A lifecycle or reinjection change after exchange admission cannot cancel server or provider work already started; it blocks another exchange and obsolete UI, but reconciliation may still be required. Complete ABUSE-001A2 only after protected provider and exact-revision staging evidence. Account/scope policy, refresh concurrency, reconciliation, revoke/audit, IAM/encryption, deployment, and live proof keep [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) open. | +| Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. #443 completes only the source/test Strava client handoff; native enforcement and the DATA-001A confirmation handoffs remain with ABUSE-001A and their canonical trackers. | | Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as `bec7d5e365eacb418563a172029f241f660d9768`, adding the no-new-dependency exact-record checker, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. The checker now scans 108 frontend files; CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227) and CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239) each retire exactly one reviewed finding. PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) retires two label errors and one prompt warning by removing those unsafe controls. | The reviewable lint baseline deliberately records 121 configured errors and 6 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. Hosted source checks do not configure branch protection, deploy Firebase, or prove Stripe/live behavior. #169, #173, #182, #206, #226, #232, and #238 expand the focused commerce suite; configure/prove branch-required checks and add the broader TEST-001 suite. | | DATA-001A3 / registration confidentiality | DATA-001A3 [#374](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/374) limits the My Account registration callable to one exact stored-UID query. Source no longer reads token email or verification claims and no longer searches registrations by runner email. Matching-email records with no UID or another account's UID are excluded; exact-UID records retain their existing sanitized summary, event lookup, and sort. The page's empty result says only that no upcoming registration is linked and that a signed-out registration may not appear. Synthetic tests use getter and Proxy canaries and make no write or provider call. | This source containment is not deployed or live and does not associate, migrate, repair, or change a registration. Anonymous, historical, mistyped-email, shared-email, and differently owned records stay hidden until a separately approved, audited server association under [#115](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/115). Keep the email index until separate inventory and migration review. Complete protected Function deployment/readback, website publication, exact `runmprc.com` verification, and a made-up account check before describing the boundary as live. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 4cf7875..c19473b 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -142,13 +142,38 @@ flowchart LR Clean -- "Not yet" --> Wait["Wait; no callback check or exchange"] Wait -. "Detected replacement failure" .-> Stop["Fixed accessible failure"] Clean -- "Yes" --> Checks["Existing sign-in, provider-error, code, and state checks"] - Checks --> Exchange["At most one exchange attempt for the same signed-in account and app"] + Checks --> Handoff["#443 App Check readiness boundary below"] + Handoff --> Exchange["At most one exchange attempt for the same signed-in account and app"] Outside["Earlier browser, provider, hosting, or network copies"] -. "Remain outside this boundary" .-> Residual["Back or history is not cleanup proof"] ``` -Text alternative: the callback captures three selected made-up fields in temporary page memory, replaces the current browser and Router entry with the clean path, and proceeds to the callback checks only after both current locations are clean. Unconfirmed cleanup waits without an exchange. A detected replacement failure shows the fixed stop. Cleaning the current entry does not erase earlier browser or outside copies. +Text alternative: the callback captures three selected made-up fields in temporary page memory, replaces the current browser and Router entry with the clean path, and proceeds to the callback checks only after both current locations are clean. The separate #443 readiness boundary follows those checks before an exchange. Unconfirmed cleanup waits without an exchange. A detected replacement failure shows the fixed stop. Cleaning the current entry does not erase earlier browser or outside copies. -The source also discards a later same-route callback. After unmount or a signed-in UID, service, Firebase resources, or app change, an obsolete browser result cannot navigate or show success. That does not cancel an exchange that already reached the server or provider; its outcome may still occur and require separate reconciliation. The later #441 boundary below supplies source-only server-issued state, UID/session binding, expiry, and one-use consumption without changing this address-cleanup order. Source, tests, merge, website publication, `runmprc.com` revision verification, Firebase deployment, Strava configuration, production data, and live OAuth behavior remain separate states. Canonical [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) remains open for native App Check enforcement, account and scope policy, refresh concurrency, reconciliation, revoke/audit, IAM/encryption, provider configuration, deployment, and live proof. +The source also discards a later same-route callback. After unmount or a signed-in UID, service, Firebase resources, or app change, an obsolete browser result cannot navigate or show success. That does not cancel an exchange that already reached the server or provider; its outcome may still occur and require separate reconciliation. The #443 boundary below adds source-only App Check readiness after this cleanup, and the following #441 boundary supplies source-only server-issued state, UID/session binding, expiry, and one-use consumption without changing the cleanup order. Source, tests, merge, website publication, `runmprc.com` revision verification, Firebase deployment, Enterprise provider configuration, Strava configuration, production data, and live OAuth behavior remain separate states. Canonical [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) remains open for native App Check enforcement, account and scope policy, refresh concurrency, reconciliation, revoke/audit, IAM/encryption, provider configuration, deployment, and live proof. + +### Strava clean-page App Check handoff — source only, not live + +OAUTH-001C1I [#443](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/443) preserves #99's initial App Check suppression for every capability-bearing callback. The Firebase resources remember whether this document began on a normalized Strava callback, including the approved plain, percent-encoded-segment, case-changed, and trailing-slash forms. Only after #335 proves that the current native and React Router paths still identify that callback, both locations have empty query and fragment state, Router page state is `null`, and native history state is absent or the exact matching BrowserRouter index/key/empty-user-state record may the callback ask one narrow Strava-specific method to prepare App Check. + +```mermaid +flowchart LR + Initial["Document begins on a recognized Strava callback\ninitial App Check stays suppressed"] --> Clean{"Locations clean, page state null, and native\nRouter record exact with matching key?"} + Clean -- "No" --> Wait["Wait or fixed cleanup stop\nno readiness and no exchange"] + Clean -- "Yes" --> Ready["Strava-only single flight\ninitialize Enterprise once and await readiness"] + Ready -. "Missing key, wrong path, or App Check failure" .-> Stop["Fixed callback failure\nno exchange"] + Ready --> Recheck{"Same clean path, account, services,\nresources, app, attempt, and page?"} + Recheck -- "Changed or reinjected while mounted" --> Stop + Recheck -- "Page closed or unmounted" --> Inert["Inert completion\nno exchange and no stale UI"] + Recheck -- "Yes" --> Exchange["One existing exchange\nwith the captured code and state"] + Other["Auth, registration, or shop callback"] -. "Not eligible for this method" .-> Unchanged["Existing callback flow unchanged"] + Local["Local or test runtime"] -. "App Check remains off" .-> Off["No provider or readiness work"] +``` + +Text alternative: a recognized Strava callback keeps initial App Check startup suppressed until both current locations are clean, page state is null, and native history is absent or contains only the matching Router index, key, and empty user state. The Strava-only single-flight method then initializes the existing Enterprise provider at most once and waits for readiness. Only the same clean current account, services, Firebase resources, app, attempt, Router entry, and mounted page may admit one existing exchange. A dirty, wrong, failed, stale, extra-state, or reinjected mounted attempt before that admission uses the fixed stop without an exchange. Closing or unmounting the page makes a pending completion inert without stale UI. Other callback types are not eligible and keep their existing flow; App Check stays off locally and in tests. + +The method never receives the callback code or state. It awaits one App Check token-ready result without returning, inspecting, logging, or storing that result or any provider detail. Missing public configuration, Enterprise construction or initialization failure, token-readiness failure, a wrong or dirty current path, or an obsolete mounted callback before exchange admission uses the existing fixed callback failure and sends no exchange. An unmounted pending callback is inert. Concurrent callers share the same readiness attempt, ordinary production pages retain eager Enterprise initialization, and local/test emulator isolation remains unchanged. + +App Check initialization cannot be reversed. A later same-document callback reinjection is still scrubbed and discarded under #335; this source rechecks the current locations and lifecycle around readiness but does not erase earlier browser, hosting, provider, or network copies. Once an exchange has been admitted, a later lifecycle or reinjection change cannot cancel server or provider work already started; it only blocks another exchange and prevents an obsolete completion from changing the page, so reconciliation may still be required. The handoff creates no membership, discount, payment, member, or admin authority. This source does not create or configure an Enterprise key or allowed-domain policy, prove a provider-backed token, enable native runtime App Check enforcement, change a Function or Rule, deploy Firebase or the website, contact Strava, inspect production data, or prove live OAuth behavior. Those states remain separate under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88), ABUSE-001A2, and the protected release issues. ### Strava server-issued one-use state — source only, not live @@ -160,18 +185,19 @@ flowchart LR Begin --> Record["Server-only fixed record: digest, UID, session marker, expiry"] Begin --> Provider["Raw challenge travels to Strava in OAuth state"] Provider --> Callback["Callback captures code and state, then cleans current address"] - Callback --> Exchange["Exchange Function receives code and state"] + Callback --> Ready["Client awaits App Check readiness on the clean page"] + Ready --> Exchange["Exchange Function receives code and state"] Exchange --> Consume{"Transaction consumes one matching, unexpired record?"} Consume -- "No" --> Stop["Fixed denial; no provider call or connection write"] Consume -- "Yes" --> Delete["Delete challenge before external work"] Delete --> Token["Existing validated Strava token exchange and paired local write"] ``` -Text alternative: a signed-in member receives one short-lived Strava state value while the server retains only its digest and identity/session binding; after the callback address is clean, one transaction deletes the matching record before the existing provider exchange, while every mismatch, expiry, replay, or concurrent loser stops without contacting Strava or changing the connection. +Text alternative: a signed-in member receives one short-lived Strava state value while the server retains only its digest and identity/session binding; after the callback address is clean and the client-side source awaits App Check readiness, one transaction deletes the matching record before the existing provider exchange, while every mismatch, expiry, replay, or concurrent loser stops without contacting Strava or changing the connection. The state transition is `absent -> active`, or `active-old -> active-new` when connection is started again, followed by exactly one `active -> consumed` transaction. Missing, malformed, wrong-UID, wrong-session, expired, mismatched, already-consumed, and concurrently lost attempts share a fixed public failure and perform no provider or connection write. A provider or persistence failure after consumption does not restore the challenge; the member must start again. Because there is one fixed per-UID record that is overwritten or deleted, this additive source change needs no backfill or collection migration. Existing Firestore Rules already deny every browser read and write under `members/{uid}/secrets/{secretId}`. -This source does not prove the custom App Check guard is fail-closed in a deployed environment, enable native runtime App Check enforcement, configure or contact Strava, deploy Firebase or the website, inspect production data, or prove live OAuth behavior. The initial capability-bearing callback currently suppresses browser App Check startup, so a separate reviewed clean-page handoff is required before fail-closed exchange enforcement can be called compatible. Those remain separate work under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88), ABUSE-001A, and the protected release issues. +This source does not prove the custom App Check guard is fail-closed in a deployed environment, enable native runtime App Check enforcement, configure or contact Strava, deploy Firebase or the website, inspect production data, or prove live OAuth behavior. #443 supplies only the reviewed source/test clean-page readiness handoff while preserving initial callback suppression. Configured Enterprise token evidence, missing/invalid/valid runtime-token proof, native enforcement, provider configuration, protected release, and live verification remain separate work under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88), ABUSE-001A2, and the protected release issues. ### Firebase Auth action link — source only, not live diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 7a98c20..18ea2ca 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1057,7 +1057,7 @@ Officer review steps after the source merge: 10. Confirm the failure sentence is announced as an urgent screen-reader alert. 11. Record website publication, `runmprc.com`, Firebase, Strava, production-data, and live-behavior evidence as separate results. -**Expected result:** the reviewed source uses one fixed, actionable sentence for both a callback query failure and an exchange failure. It does not inspect, display, or log the rejected exchange value. Existing sign-in, missing-code, missing-state, server-rejection, success, and Back-to-account behavior stays in place. The separate OAUTH-001C1G child adds source-only cleanup of the current browser entry before callback-specific checks or exchange, and #441 adds the later source-only server state decision. Neither erases earlier browser, provider, hosting, or network copies or completes issue #88. +**Expected result:** the reviewed source uses one fixed, actionable sentence for both a callback query failure and an exchange failure. It does not inspect, display, or log the rejected exchange value. Existing sign-in, missing-code, missing-state, server-rejection, success, and Back-to-account behavior stays in place. The separate OAUTH-001C1G child adds source-only cleanup of the current browser entry before callback-specific checks or exchange, #443 adds source-only App Check readiness after that cleanup, and #441 adds the source-only server state decision. None erases earlier browser, provider, hosting, or network copies or completes issue #88. **Stop conditions:** any real member or Strava account; a request for a callback URL, authorization code, state value, provider error, private browser history, or screenshot containing private values; a real provider call; a production Firebase or Strava change; a raw detail in the page or console; or a claim that source, tests, merge, or a green workflow proves the wording is live. @@ -1118,7 +1118,65 @@ Officer review steps after the source merge: **Escalation:** contact the platform/security and privacy owners if callback details may have appeared outside the current clean page. Use the private incident path. Do not copy a value into an issue, message, screenshot, or AI tool. -This child keeps canonical issue #88 open and incomplete. The separate #441 procedure below records the later source-only server state boundary. Native App Check enforcement, account and scope policy, refresh concurrency, revoke/audit behavior, IAM/encryption, provider configuration, deployment, and live verification remain separate work. +This child keeps canonical issue #88 open and incomplete. The #443 procedure below records the later source-only clean-page App Check handoff, and the #441 procedure that follows records the source-only server state boundary. Native App Check enforcement, account and scope policy, refresh concurrency, revoke/audit behavior, IAM/encryption, provider configuration, deployment, and live verification remain separate work. + +## Strava App Check handoff after cleanup — SOURCE ONLY, NOT LIVE + +**Status: NOT AVAILABLE YET** + +**Purpose:** keep initial App Check startup off while a made-up Strava callback is current, then prepare it only after the current browser and page locations are clean and before one mocked server exchange. + +**Approver:** membership lead plus platform/security and privacy owners. + +**Prerequisites:** issues [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159), [#335](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/335), [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441), and [#443](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/443) must be merged at exact reviewed commits for the complete source review. Use only made-up callbacks and accounts with mocked Firebase and Strava boundaries. Do not use or request a real site key, App Check result, Firebase project, Strava account, callback, code, state, provider response, member record, or production data. A future staged review also requires a named owner to configure and privately verify the approved Enterprise key and allowed domains, a protected backend-first release of the exact Firebase Functions and website revisions, and approved synthetic provider evidence. None of those states is available or authorized by this source issue. + +```mermaid +flowchart LR + A["Made-up recognized Strava callback\ninitial App Check remains off"] --> B{"Locations and page state clean, and native\nRouter record exact with matching key?"} + B -- "No" --> C["Wait or fixed stop\nno readiness and no exchange"] + B -- "Yes" --> D["One Strava-only App Check readiness attempt"] + D -. "Missing setup or readiness failure" .-> E["Fixed connection failure\nno exchange"] + D --> F{"Same clean account, services, resources,\napp, attempt, and open page?"} + F -- "Changed or reinjected while mounted" --> E + F -- "Page closed or unmounted" --> I["Inert completion\nno exchange and no stale screen"] + F -- "Yes" --> G["One mocked exchange with the\noriginal made-up code and state"] + H["Authentication, registration, or shop callback"] -. "Not eligible for this handoff" .-> J["Existing callback flow unchanged"] +``` + +Text alternative: initial App Check startup stays off while a made-up Strava callback address or saved page detail is current. After both current locations and page state are clean, and native history is absent or contains only the matching Router index, key, and empty user state, one Strava-only readiness attempt may run. Only the same clean account, services, Firebase resources, app, attempt, Router entry, and open page may admit one mocked exchange. A missing setup, readiness failure, extra saved detail, changed mounted context, or reinjected callback before that point stops with the fixed connection failure and no exchange. Closing the page makes a pending completion inert without changing a stale screen. Authentication, registration, and shop callbacks are not eligible and keep their existing flow. + +Officer review steps after the source merge: + +1. Keep this procedure marked **NOT AVAILABLE YET**. +2. Ask the platform owner for issue #443, the reviewed pull request, exact merged commit, and synthetic test results. +3. Confirm every test uses invented callback and account values with mocked Firebase and Strava boundaries. +4. Confirm the initial callback starts no Enterprise provider or App Check readiness work while its address or saved page detail remains current. +5. Confirm the plain, encoded-segment, case-changed, and trailing-slash Strava callback spellings keep that same initial shutdown. +6. Confirm both the current browser location and page route match the Strava callback and have no query or fragment before readiness starts. +7. Confirm page state is empty and native history is either absent or contains only the Router index, matching key, and empty user state before readiness starts. Any extra field, accessor, non-plain value, or mismatched key must stop. +8. Confirm an authentication, registration, or shop callback cannot use this Strava-only handoff. +9. Confirm one current clean callback initializes the existing Enterprise provider at most once and waits for one readiness result before exchange. +10. Confirm the source does not give the readiness method the callback code or state and does not inspect, display, return, log, or store an App Check result or provider detail. +11. Confirm a missing public key, wrong or dirty path, Enterprise construction or initialization failure, or readiness failure shows `We could not connect Strava. Please return to My Account and try again.` and makes no exchange. +12. Confirm a changed signed-in account, service, Firebase resources, app, callback attempt, route, or Router entry key stops an older mounted readiness attempt; confirm a closed page makes its completion inert. +13. Confirm a later callback loaded into the same page is cleaned and discarded and does not start another exchange. Record that it cannot cancel server or provider work if the first exchange already started. +14. Confirm duplicate rendering, rerendering, duplicate completion, and simultaneous readiness requests produce at most one Enterprise initialization, one readiness request, and one exchange. +15. Confirm one successful current readiness result allows only the original made-up code and state to reach the existing mocked exchange once. +16. Confirm ordinary production pages keep their existing eager App Check startup and local/test runtimes keep App Check off. +17. Confirm this handoff creates no membership, dues, discount, payment, member role, or admin authority. +18. Record source changed, tests passed, code merged, website published, exact `runmprc.com` revision verified, Firebase Functions deployed and read back, Enterprise provider and allowed domains configured, provider-backed token behavior evidenced, Strava configured, production data changed, and live behavior verified as separate results. + +**Expected result:** the member screen and fixed connection wording stay the same. Source keeps initial capability-callback App Check startup off, waits until the current native and page locations, page state, and exact Router history wrapper are clean, prepares App Check once for the same current Strava attempt and Router entry, rechecks that context, and then makes the existing mocked exchange once. A cleanup, setup, readiness, saved-state, or mounted lifecycle failure before exchange admission shows the fixed result and makes no exchange. An unmounted completion is inert. Other callback types cannot use this method and keep their existing flow. Once exchange starts, a later change cannot cancel work already at the server or provider; it blocks another exchange and an obsolete screen update, and reconciliation may still be needed. Local/test isolation and ordinary production-page startup remain unchanged. This is source behavior only. + +**Stop conditions:** any real site key, App Check result, Firebase project, member or Strava account, callback, code, state, token, provider response, browser-history capture, developer-tools capture, production record, provider request, console configuration, deployment, or live callback; readiness before both locations and saved page state are clean; an exchange before readiness; another callback type resuming App Check; more than one initialization, readiness request, or exchange; a raw or hostile value being inspected or exposed; an obsolete completion changing the page; or a claim that source, tests, merge, preview, or green CI proves provider configuration, enforcement, deployment, or live protection. + +**Success proof:** for source completion, record issue #443, the reviewed pull request, exact commit, the old-source ordering failure, green focused Firebase-resources and account-callback tests, relevant full frontend and safety checks, and independent security, privacy, lifecycle, test-quality, and backup-officer reviews. Record source, tests, and merge separately. Record website publication, exact `runmprc.com` verification, Firebase deployment/readback, Enterprise provider configuration, provider-backed token evidence, Strava configuration, production-data action, and live behavior as **not performed** unless each has separate approved evidence. A future staged result does not by itself prove production or live behavior. + +**Undo:** before publication, use one reviewed frontend-and-guide revert or safe roll-forward. After a future approved publication, use the protected website rollback only when the deployed Firebase and App Check policy remains compatible with the replacement revision; otherwise stop and use a reviewed coordinated rollback or safe roll-forward. Never undo by disabling a provider or enforcement setting ad hoc, replaying a callback, changing a member record, or copying a callback or App Check value. + +**Escalation:** stop and contact the platform/security and privacy owners. Add the membership lead if a member cannot reconnect. Use the private incident path if callback, App Check, provider, account, or technical detail may have appeared, another exchange may have started, or the released website and Firebase policy may not match. Do not copy the detail into an issue, screenshot, email, message, or AI tool. + +This source child changes the browser's control and data order, so the local flow diagram above records it. It adds no data store, server authority, permission, account ownership, provider configuration, or deployment topology; the full system maps remain unchanged. ## Strava one-use connection check — SOURCE ONLY, NOT LIVE @@ -1128,7 +1186,7 @@ This child keeps canonical issue #88 open and incomplete. The separate #441 proc **Approver:** membership lead plus platform/security and privacy owners. -**Prerequisites:** issue [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) must be merged at one exact reviewed commit. Source review uses only made-up state, account, session, and provider values with mocked Firebase and Strava boundaries. A future staged release requires the separate reviewed clean-page App Check handoff, then the exact begin and exchange Functions deployed and read back before the matching website revision. The current initial callback suppresses browser App Check startup and is not proof of fail-closed compatibility. An approved non-production callback rehearsal is separate provider evidence; it does not by itself prove production or live behavior. A source merge, preview, green workflow, or website-only publication is not enough. +**Prerequisites:** issue [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) must be merged at one exact reviewed commit. Source review uses only made-up state, account, session, and provider values with mocked Firebase and Strava boundaries. A future staged release requires the exact reviewed #443 clean-page App Check handoff, configured Enterprise evidence, and the exact begin and exchange Functions deployed and read back before the matching website revision. Initial callback suppression and #443's source readiness ordering are not proof of fail-closed runtime compatibility. An approved non-production callback rehearsal is separate provider evidence; it does not by itself prove production or live behavior. A source merge, preview, green workflow, or website-only publication is not enough. ```mermaid flowchart LR @@ -1136,12 +1194,13 @@ flowchart LR B --> C["Server stores digest and account-session binding only"] B --> D["Strava returns code and challenge"] D --> E["Page cleans the current address"] - E --> F{"Server consumes exact challenge once?"} + E --> I["Page awaits #443 App Check readiness"] + I --> F{"Server consumes exact challenge once?"} F -- "No, expired, wrong, or repeated" --> G["Fixed stop; try again from My Account"] F -- "Yes" --> H["Delete challenge before provider exchange"] ``` -Text alternative: the server gives the signed-in member one short-lived Strava challenge but stores only its digest and account-session binding; after the page cleans the callback address, the server deletes one exact match before exchange, and every expired, wrong, or repeated attempt stops with the same retry path. +Text alternative: the server gives the signed-in member one short-lived Strava challenge but stores only its digest and account-session binding; after the page cleans the callback address and awaits the separate #443 source readiness boundary, the server deletes one exact match before exchange, and every expired, wrong, or repeated attempt stops with the same retry path. Officer review steps after the source merge: @@ -1151,7 +1210,7 @@ Officer review steps after the source merge: 4. Confirm the begin Function applies its existing App Check guard, then requires a signed-in account and a valid Auth-session marker before writing a challenge. 5. Confirm the server stores no raw challenge. The retained record contains fixed schema/provider labels, a digest, UID, session marker, issue time, and expiry only. The raw challenge must not be in the database, connection record, logs, screenshots, or issue evidence. 6. Confirm starting again replaces the earlier challenge for that same website account. -7. Confirm the callback still removes current address details before it sends the made-up code and state to the server. +7. Confirm the callback still removes current address details and completes the separate #443 source readiness boundary before it sends the made-up code and state to the server. 8. Confirm the server transaction checks the same UID and session, the exact digest, and the ten-minute expiry, then deletes the record before any Strava request or connection write. Source tests use a mocked Strava boundary. 9. Confirm missing, malformed, wrong-account, wrong-session, mismatched, expired, repeated, and simultaneous losing attempts all stop before the provider and connection write. 10. Confirm two simultaneous made-up callbacks produce at most one provider attempt. diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index 4f83fb8..f4e722c 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -2349,6 +2349,14 @@ describe('Strava disconnect browser failure boundary', () => { }); const STRAVA_CALLBACK_FAILURE = 'We could not connect Strava. Please return to My Account and try again.'; +const prepareStravaCallbackAppCheck = jest.fn(); + +function makeCallbackFirebaseResources(firebaseApp: object = app) { + return { + app: firebaseApp, + prepareAppCheckAfterStravaCallbackCleanup: prepareStravaCallbackAppCheck, + }; +} function CallbackAccountDestination() { const navigationType = useNavigationType(); @@ -2376,6 +2384,7 @@ function CallbackLocationWitness() { } type CallbackRouterSnapshot = Readonly<{ + pathname: string; search: string; hash: string; state: unknown; @@ -2390,15 +2399,35 @@ function CallbackSameRouteProbe() { const navigate = useNavigate(); return ( - + <> + + + + ); } @@ -2411,6 +2440,7 @@ function CallbackRouteHarness({ const observationTarget = observation; if (observationTarget) { const snapshot = { + pathname: location.pathname, search: location.search, hash: location.hash, state: location.state, @@ -2468,28 +2498,15 @@ function renderBrowserStravaCallback( function renderStravaCallback( entry = '/account/strava/callback?code=synthetic-code&state=synthetic-state', ) { - window.history.replaceState(null, '', '/account/strava/callback'); - return render( - - - } /> - } /> - - , - ); + return renderBrowserStravaCallback(entry); } describe('Strava callback error boundary', () => { beforeEach(() => { jest.clearAllMocks(); + prepareStravaCallbackAppCheck.mockResolvedValue(undefined); (useServiceLocator as jest.Mock).mockReturnValue({ - services: { firebaseResources: { app } }, + services: { firebaseResources: makeCallbackFirebaseResources() }, isReady: true, }); (useAuth as jest.Mock).mockReturnValue({ @@ -2509,19 +2526,204 @@ describe('Strava callback error boundary', () => { }); describe('current browser history cleanup', () => { + test('prepares App Check after cleanup and before starting the exchange', async () => { + const calls: string[] = []; + let markAppCheckReady: (() => void) | undefined; + const prepareAppCheckAfterStravaCallbackCleanup = jest.fn(() => { + calls.push('prepare'); + return new Promise((resolve) => { + markAppCheckReady = resolve; + }); + }); + (useServiceLocator as jest.Mock).mockReturnValue({ + services: { + firebaseResources: { + app, + prepareAppCheckAfterStravaCallbackCleanup, + }, + }, + isReady: true, + }); + (stravaExchangeCode as jest.Mock).mockImplementationOnce(() => { + calls.push('exchange'); + return new Promise(() => { + // Keep the made-up exchange pending after the readiness gate opens. + }); + }); + + renderBrowserStravaCallback(); + + await waitFor(() => expect(calls).not.toHaveLength(0)); + expect(calls).toEqual(['prepare']); + expect(prepareAppCheckAfterStravaCallbackCleanup).toHaveBeenCalledTimes(1); + expect(prepareAppCheckAfterStravaCallbackCleanup).toHaveBeenCalledWith(); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + + await act(async () => { + markAppCheckReady?.(); + await Promise.resolve(); + }); + + await waitFor(() => expect(stravaExchangeCode).toHaveBeenCalledTimes(1)); + expect(calls).toEqual(['prepare', 'exchange']); + expect(stravaExchangeCode).toHaveBeenCalledWith( + app, + 'synthetic-code', + 'synthetic-state', + ); + }); + + test.each([ + ['short', 0.5, 'i'], + ['empty', 0, ''], + ])('accepts a %s key produced by the pinned BrowserRouter', async ( + _case, + randomValue, + nativeKey, + ) => { + jest.spyOn(Math, 'random').mockReturnValue(randomValue); + (stravaExchangeCode as jest.Mock).mockReturnValueOnce(new Promise(() => { + // Keep the made-up exchange pending on the short-key callback entry. + })); + + renderBrowserStravaCallback(); + + await waitFor(() => expect(stravaExchangeCode).toHaveBeenCalledTimes(1)); + expect(window.history.state).toEqual(expect.objectContaining({ + key: nativeKey, + usr: null, + })); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1); + }); + + test.each([ + [ + 'case-changed', + '/ACCOUNT/STRAVA/CALLBACK?code=variant-code&state=variant-state', + ], + [ + 'encoded segments', + '/%61ccount/%73trava/%63allback?code=variant-code&state=variant-state', + ], + [ + 'trailing slashes', + '/account/strava/callback///?code=variant-code&state=variant-state', + ], + ])('keeps every %s readiness and exchange boundary clean', async ( + _case, + entry, + ) => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const routerObservation: CallbackRouterObservation = { current: null }; + const observations: Array<{ + boundary: string; + nativePathname: string; + native: string; + router: CallbackRouterSnapshot | null; + }> = []; + const observe = (boundary: string) => { + observations.push({ + boundary, + nativePathname: window.location.pathname, + native: `${window.location.search}${window.location.hash}`, + router: routerObservation.current, + }); + }; + prepareStravaCallbackAppCheck.mockImplementationOnce(async () => { + observe('prepare'); + }); + (stravaExchangeCode as jest.Mock).mockImplementationOnce(() => { + observe('exchange'); + return new Promise(() => { + // Keep the made-up exchange pending after both clean observations. + }); + }); + + renderBrowserStravaCallback(entry, true, false, routerObservation); + + await waitFor(() => expect(stravaExchangeCode).toHaveBeenCalledTimes(1)); + expect(observations.map(({ boundary }) => boundary)).toEqual([ + 'prepare', + 'exchange', + ]); + observations.forEach(({ nativePathname, native, router }) => { + expect(native).toBe(''); + expect(router).not.toBeNull(); + expect(router).toMatchObject({ search: '', hash: '', state: null }); + expect(nativePathname).toBe(router?.pathname); + }); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledWith(); + expect(stravaExchangeCode).toHaveBeenCalledWith( + app, + 'variant-code', + 'variant-state', + ); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + test.each([ + ['synchronous throw', 'throw'], + ['asynchronous rejection', 'reject'], + ])('uses one fixed stop for a hostile preparation %s', async (_case, outcome) => { + const messageGetter = jest.fn(() => { + throw new Error('app-check-message-getter-canary'); + }); + const rejection = Object.defineProperty({}, 'message', { + configurable: true, + get: messageGetter, + }); + const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] + .map((method) => jest.spyOn(console, method as any).mockImplementation(() => undefined)); + if (outcome === 'throw') { + prepareStravaCallbackAppCheck.mockImplementationOnce(() => { + throw rejection; + }); + } else { + prepareStravaCallbackAppCheck.mockRejectedValueOnce(rejection); + } + + renderBrowserStravaCallback(); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(messageGetter).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('app-check-message-getter-canary'); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('fails closed when the current Firebase resources lack the preparation boundary', async () => { + (useServiceLocator as jest.Mock).mockReturnValue({ + services: { firebaseResources: { app } }, + isReady: true, + }); + + renderBrowserStravaCallback(); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + }); + test('cleans native and Router state before hooks or exchange', async () => { const routerObservation: CallbackRouterObservation = { current: null }; const observedLocations: Array<{ native: string; router: CallbackRouterSnapshot | null; }> = []; - const observedServices = { firebaseResources: { app } }; const observeLocation = () => { observedLocations.push({ native: `${window.location.search}${window.location.hash}`, router: routerObservation.current, }); }; + const observedServices = { + firebaseResources: { + app, + prepareAppCheckAfterStravaCallbackCleanup: jest.fn(async () => { + observeLocation(); + }), + }, + }; (useServiceLocator as jest.Mock).mockImplementation(() => { observeLocation(); return { @@ -2557,7 +2759,12 @@ describe('Strava callback error boundary', () => { expect(observedLocations).not.toHaveLength(0); expect(observedLocations).toEqual(observedLocations.map(() => ({ native: '', - router: { search: '', hash: '', state: null }, + router: { + pathname: '/account/strava/callback', + search: '', + hash: '', + state: null, + }, }))); expect(window.location.pathname).toBe('/account/strava/callback'); expect(window.location.search).toBe(''); @@ -2593,7 +2800,7 @@ describe('Strava callback error boundary', () => { ['authentication is loading', true, true], ])('cleans the current entry before waiting when %s', async (_case, isReady, isLoading) => { const observedLocations: string[] = []; - const observedServices = { firebaseResources: { app } }; + const observedServices = { firebaseResources: makeCallbackFirebaseResources() }; const observeLocation = () => { observedLocations.push(`${window.location.search}${window.location.hash}`); }; @@ -2680,7 +2887,13 @@ describe('Strava callback error boundary', () => { expect(document.body).not.toHaveTextContent(/history-noop-.*-canary/); }); - test('fails closed when native replacement silently changes to another path', async () => { + test.each([ + ['another path', '/silent-path-divergence'], + ['an equivalent case-changed path', '/ACCOUNT/STRAVA/CALLBACK'], + ])('fails closed when native replacement silently changes to %s', async ( + _case, + divergentPath, + ) => { const routerObservation: CallbackRouterObservation = { current: null, onLocation: (location) => { @@ -2693,7 +2906,7 @@ describe('Strava callback error boundary', () => { window.history.replaceState( window.history.state, '', - '/silent-path-divergence', + divergentPath, ); } }, @@ -2709,7 +2922,7 @@ describe('Strava callback error boundary', () => { const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent(STRAVA_CALLBACK_FAILURE); - expect(window.location.pathname).toBe('/silent-path-divergence'); + expect(window.location.pathname).toBe(divergentPath); expect(useServiceLocator).not.toHaveBeenCalled(); expect(useAuth).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); @@ -2742,6 +2955,7 @@ describe('Strava callback error boundary', () => { const page = renderBrowserStravaCallback(undefined, true, true); await waitFor(() => expect(stravaExchangeCode).toHaveBeenCalledTimes(1)); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1); page.rerender( { ); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1); await act(async () => { finishExchange?.(); await Promise.resolve(); @@ -2767,6 +2982,273 @@ describe('Strava callback error boundary', () => { expect(screen.getByTestId('callback-navigation-type')).toHaveTextContent('REPLACE'); }); + test.each([ + ['signed-in UID'], + ['service identity'], + ['Firebase resources'], + ['Firebase app'], + ])('discards App Check readiness after a %s change', async (changedContext) => { + const readiness = accountDeferred(); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + let firstFirebaseResources = makeCallbackFirebaseResources(); + const firstServices = { firebaseResources: firstFirebaseResources }; + let nextUser = USER; + let nextServices = firstServices; + (useServiceLocator as jest.Mock).mockImplementation(() => ({ + services: nextServices, + isReady: true, + })); + (useAuth as jest.Mock).mockImplementation(() => ({ + user: nextUser, + isAuthenticated: true, + isLoading: false, + })); + const page = renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + + if (changedContext === 'signed-in UID') { + nextUser = { ...USER, uid: 'second-synthetic-user' }; + } else if (changedContext === 'service identity') { + nextServices = { firebaseResources: firstFirebaseResources }; + } else if (changedContext === 'Firebase resources') { + firstFirebaseResources = makeCallbackFirebaseResources(); + firstServices.firebaseResources = firstFirebaseResources; + } else { + firstFirebaseResources.app = { name: 'second-synthetic-app' }; + } + + page.rerender( + + + } /> + } /> + + , + ); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['a later callback', 'Load another callback'], + ['non-null Router state', 'Inject callback Router state'], + ['a replacement Router entry', 'Replace clean callback entry'], + ])('makes readiness inert after reinjection of %s', async (_case, buttonName) => { + const readiness = accountDeferred(); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: buttonName })); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent(/second-.*-canary/); + }); + + test('blocks native Router state before a transition render commits', async () => { + const readiness = accountDeferred(); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + + window.history.replaceState( + { + idx: 0, + key: 'silent-router-state-entry', + usr: { privateRouterState: 'silent-router-state-canary' }, + }, + '', + '/account/strava/callback', + ); + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('silent-router-state-canary'); + }); + + test('blocks extra native history detail injected before preparation', async () => { + let injected = false; + const routerObservation: CallbackRouterObservation = { + current: null, + onLocation: (location) => { + if ( + !injected + && location.search === '' + && location.hash === '' + && location.state === null + ) { + injected = true; + const nativeState = window.history.state as { + idx: number; + key: string; + usr: null; + }; + window.history.replaceState( + { + ...nativeState, + code: 'preparation-saved-code-canary', + }, + '', + window.location.pathname, + ); + } + }, + }; + + renderBrowserStravaCallback(undefined, true, false, routerObservation); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(injected).toBe(true); + expect(prepareStravaCallbackAppCheck).not.toHaveBeenCalled(); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('preparation-saved-code-canary'); + }); + + test.each([ + ['extra saved detail'], + ['a mismatched Router key'], + ])('blocks native history with %s after readiness', async (historyCase) => { + const readiness = accountDeferred(); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + const cleanState = window.history.state as { + idx: number; + key: string; + usr: null; + }; + const changedState = historyCase === 'extra saved detail' + ? { ...cleanState, state: 'readiness-saved-state-canary' } + : { ...cleanState, key: 'different-router-key' }; + + window.history.replaceState(changedState, '', '/account/strava/callback'); + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('readiness-saved-state-canary'); + }); + + test('rechecks the native address after readiness without relying on a Router render', async () => { + const readiness = accountDeferred(); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + + window.history.replaceState( + window.history.state, + '', + '/silent-readiness-divergence?private=native-readiness-canary', + ); + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('native-readiness-canary'); + }); + + test('makes pending App Check readiness inert after unmount without logging', async () => { + const readiness = accountDeferred(); + const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] + .map((method) => jest.spyOn(console, method as any).mockImplementation(() => undefined)); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + const page = renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + page.unmount(); + + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + + expect(stravaExchangeCode).not.toHaveBeenCalled(); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('keeps App Check readiness invalid after the account changes away and back', async () => { + const readiness = accountDeferred(); + const firstServices = { firebaseResources: makeCallbackFirebaseResources() }; + let nextUser = USER; + (useServiceLocator as jest.Mock).mockReturnValue({ + services: firstServices, + isReady: true, + }); + (useAuth as jest.Mock).mockImplementation(() => ({ + user: nextUser, + isAuthenticated: true, + isLoading: false, + })); + prepareStravaCallbackAppCheck.mockReturnValueOnce(readiness.promise); + const page = renderBrowserStravaCallback(); + await waitFor(() => expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1)); + + nextUser = { ...USER, uid: 'second-synthetic-user' }; + page.rerender( + + + } /> + } /> + + , + ); + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + + nextUser = USER; + page.rerender( + + + } /> + } /> + + , + ); + await act(async () => { + readiness.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(stravaExchangeCode).not.toHaveBeenCalled(); + expect(prepareStravaCallbackAppCheck).toHaveBeenCalledTimes(1); + }); + test.each([ ['signed-in UID', 'resolve'], ['service identity', 'reject'], @@ -2776,7 +3258,7 @@ describe('Strava callback error boundary', () => { outcome, ) => { let settleExchange: (() => void) | undefined; - const firstFirebaseResources = { app }; + const firstFirebaseResources = makeCallbackFirebaseResources(); const firstServices = { firebaseResources: firstFirebaseResources }; (useServiceLocator as jest.Mock).mockReturnValue({ services: firstServices, @@ -2807,7 +3289,7 @@ describe('Strava callback error boundary', () => { nextServices = { firebaseResources: firstFirebaseResources }; } else { nextServices = { - firebaseResources: { app: { name: 'second-synthetic-app' } }, + firebaseResources: makeCallbackFirebaseResources({ name: 'second-synthetic-app' }), }; } (useServiceLocator as jest.Mock).mockReturnValue({ @@ -2847,7 +3329,7 @@ describe('Strava callback error boundary', () => { test('keeps a pending attempt invalid after the account changes away and back', async () => { let finishExchange: (() => void) | undefined; - const firstServices = { firebaseResources: { app } }; + const firstServices = { firebaseResources: makeCallbackFirebaseResources() }; (useServiceLocator as jest.Mock).mockReturnValue({ services: firstServices, isReady: true, @@ -2938,7 +3420,7 @@ describe('Strava callback error boundary', () => { ['authentication is loading', true, true], ])('waits without callback work when %s', async (_case, isReady, isLoading) => { (useServiceLocator as jest.Mock).mockReturnValue({ - services: isReady ? { firebaseResources: { app } } : null, + services: isReady ? { firebaseResources: makeCallbackFirebaseResources() } : null, isReady, }); (useAuth as jest.Mock).mockReturnValue({ @@ -2949,6 +3431,7 @@ describe('Strava callback error boundary', () => { renderStravaCallback(); expect(await screen.findByText('Connecting your Strava...')).toBeInTheDocument(); + expect(prepareStravaCallbackAppCheck).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); @@ -2965,6 +3448,7 @@ describe('Strava callback error boundary', () => { expect(await screen.findByText('You need to be signed in to connect Strava.')) .toBeInTheDocument(); expect(document.body).not.toHaveTextContent(/signed-out-provider-canary|hidden-code|hidden-state/); + expect(prepareStravaCallbackAppCheck).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); @@ -2981,6 +3465,7 @@ describe('Strava callback error boundary', () => { expect(stravaExchangeCode).not.toHaveBeenCalled(); expect(screen.getByRole('link', { name: 'Back to account' })) .toHaveAttribute('href', '/account'); + expect(prepareStravaCallbackAppCheck).not.toHaveBeenCalled(); }); test('stops a missing code before exchange', async () => { @@ -2989,6 +3474,7 @@ describe('Strava callback error boundary', () => { expect(await screen.findByRole('alert')).toHaveTextContent( 'Missing authorization code from Strava.', ); + expect(prepareStravaCallbackAppCheck).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); @@ -2999,6 +3485,7 @@ describe('Strava callback error boundary', () => { renderStravaCallback(entry); expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); + expect(prepareStravaCallbackAppCheck).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); diff --git a/src/pages/account/StravaCallback.tsx b/src/pages/account/StravaCallback.tsx index f1fe348..63e4758 100644 --- a/src/pages/account/StravaCallback.tsx +++ b/src/pages/account/StravaCallback.tsx @@ -6,6 +6,7 @@ import { useServiceLocator } from '../../services/ServiceLocatorContext'; import { useAuth } from '../../services/hooks/useAuth'; import SEO from '../../components/SEO'; import { stravaExchangeCode } from '../../services/strava/stravaService'; +import { browserRouterStateIsClean } from '../../services/monitoring/capabilityCallback'; const STRAVA_CALLBACK_FAILURE = 'We could not connect Strava. Please return to My Account and try again.'; @@ -24,6 +25,16 @@ type AttemptContext = Readonly<{ app: object; }>; +type CallbackCleanupState = Readonly<{ + isClean: boolean; + key: string; + pathname: string; +}>; + +type CallbackCleanupWitness = Readonly<{ + current: CallbackCleanupState; +}>; + function readCallbackSnapshot(search: string): CallbackSnapshot { try { const params = new URLSearchParams(search); @@ -37,12 +48,29 @@ function readCallbackSnapshot(search: string): CallbackSnapshot { } } +function cleanCallbackPathname(pathname: string): string { + return pathname.replace(/\/{2,}$/, '/'); +} + function nativeAddressIsClean(expectedPathname: string): boolean { return window.location.pathname === expectedPathname && window.location.search === '' && window.location.hash === ''; } +function callbackCleanupIsCurrent( + cleanupWitness: CallbackCleanupWitness, + expectedKey: string, + expectedPathname: string, +): boolean { + const currentCleanup = cleanupWitness.current; + return currentCleanup.isClean + && currentCleanup.key === expectedKey + && currentCleanup.pathname === expectedPathname + && nativeAddressIsClean(expectedPathname) + && browserRouterStateIsClean(window.history.state, expectedKey); +} + function CallbackScreen({ status, message, @@ -85,7 +113,13 @@ function CallbackScreen({ ); } -function CallbackAttempt({ snapshot }: { snapshot: CallbackSnapshot }) { +function CallbackAttempt({ + snapshot, + cleanupWitness, +}: { + snapshot: CallbackSnapshot; + cleanupWitness: CallbackCleanupWitness; +}) { const { services, isReady } = useServiceLocator(); const { user, isAuthenticated, isLoading, @@ -156,9 +190,11 @@ function CallbackAttempt({ snapshot }: { snapshot: CallbackSnapshot }) { } if (services === null || firebaseResources === null || app === null) return; + const { code, state } = snapshot; + decisionStartedRef.current = true; - const run = Symbol('strava-callback-exchange'); + const run = Symbol('strava-callback-app-check-and-exchange'); runRef.current = run; attemptContextRef.current = { uid, @@ -166,40 +202,75 @@ function CallbackAttempt({ snapshot }: { snapshot: CallbackSnapshot }) { firebaseResources, app, }; + const expectedKey = cleanupWitness.current.key; + const expectedPathname = cleanupWitness.current.pathname; - let exchange: ReturnType; - try { - exchange = stravaExchangeCode(app, snapshot.code, snapshot.state); - } catch { + const runIsCurrent = () => ( + mountedRef.current + && !attemptInvalidatedRef.current + && runRef.current === run + ); + + const failCurrentRun = () => { + if (!runIsCurrent()) return; runRef.current = null; setStatus('error'); setMessage(STRAVA_CALLBACK_FAILURE); + }; + + if (!callbackCleanupIsCurrent(cleanupWitness, expectedKey, expectedPathname)) { + failCurrentRun(); + return; + } + + let readiness: ReturnType< + typeof firebaseResources.prepareAppCheckAfterStravaCallbackCleanup + >; + try { + readiness = firebaseResources.prepareAppCheckAfterStravaCallbackCleanup(); + } catch { + failCurrentRun(); return; } - Promise.resolve(exchange).then( + Promise.resolve(readiness).then( () => { - if ( - !mountedRef.current - || attemptInvalidatedRef.current - || runRef.current !== run - ) return; - runRef.current = null; - setStatus('done'); + if (!runIsCurrent()) return; + if (!callbackCleanupIsCurrent(cleanupWitness, expectedKey, expectedPathname)) { + failCurrentRun(); + return; + } + + let exchange: ReturnType; + try { + exchange = stravaExchangeCode(app, code, state); + } catch { + failCurrentRun(); + return; + } + + Promise.resolve(exchange).then( + () => { + if (!runIsCurrent()) return; + if (!callbackCleanupIsCurrent(cleanupWitness, expectedKey, expectedPathname)) { + failCurrentRun(); + return; + } + runRef.current = null; + setStatus('done'); + }, + () => { + failCurrentRun(); + }, + ); }, () => { - if ( - !mountedRef.current - || attemptInvalidatedRef.current - || runRef.current !== run - ) return; - runRef.current = null; - setStatus('error'); - setMessage(STRAVA_CALLBACK_FAILURE); + failCurrentRun(); }, ); }, [ app, + cleanupWitness, firebaseResources, isAuthenticated, isLoading, @@ -232,17 +303,41 @@ function StravaCallback() { && nativeAddressIsClean(location.pathname); const initiallyCleanRef = useRef(currentLocationIsClean); const cleanupCompleteRef = useRef(initiallyCleanRef.current); + const cleanLocationKeyRef = useRef( + initiallyCleanRef.current ? location.key : null, + ); const cleanupFailedRef = useRef(false); const cleanupRequestedRef = useRef(false); const [cleanupState, setCleanupState] = useState<'pending' | 'clean' | 'failed'>( initiallyCleanRef.current ? 'clean' : 'pending', ); + const cleanupWitnessRef = useRef({ + isClean: false, + key: location.key, + pathname: location.pathname, + }); + cleanupWitnessRef.current = { + isClean: cleanupState === 'clean' && currentLocationIsClean, + key: location.key, + pathname: location.pathname, + }; useEffect(() => { if (cleanupFailedRef.current) return undefined; if (currentLocationIsClean) { + if ( + cleanupCompleteRef.current + && cleanLocationKeyRef.current !== null + && cleanLocationKeyRef.current !== location.key + ) { + cleanupFailedRef.current = true; + snapshotRef.current = null; + setCleanupState('failed'); + return undefined; + } cleanupCompleteRef.current = true; + cleanLocationKeyRef.current = location.key; setCleanupState('clean'); return undefined; } @@ -259,7 +354,7 @@ function StravaCallback() { } const cleanLocation = { - pathname: location.pathname, + pathname: cleanCallbackPathname(location.pathname), search: '', hash: '', }; @@ -299,6 +394,7 @@ function StravaCallback() { }, [ currentLocationIsClean, location.hash, + location.key, location.pathname, location.search, location.state, @@ -312,7 +408,12 @@ function StravaCallback() { if (cleanupState !== 'clean' || !currentLocationIsClean || snapshotRef.current === null) { return ; } - return ; + return ( + + ); } export default StravaCallback; diff --git a/src/services/firebase/FirebaseResources.test.ts b/src/services/firebase/FirebaseResources.test.ts index bfa1255..1d524de 100644 --- a/src/services/firebase/FirebaseResources.test.ts +++ b/src/services/firebase/FirebaseResources.test.ts @@ -1,4 +1,4 @@ -export {}; +import { browserRouterStateIsClean } from '../monitoring/capabilityCallback'; const fs = require('node:fs'); const path = require('node:path'); @@ -20,6 +20,7 @@ const mockConnectFunctionsEmulator = jest.fn(); const mockGetAnalytics = jest.fn(() => ({ name: 'test-analytics' })); const mockIsAnalyticsSupported = jest.fn(() => Promise.resolve(false)); const mockInitializeAppCheck = jest.fn(); +const mockGetToken = jest.fn(); const mockReCaptchaEnterpriseProvider = jest.fn((siteKey: string) => ({ kind: 'enterprise', siteKey, @@ -32,6 +33,7 @@ jest.mock('firebase/analytics', () => ({ isSupported: mockIsAnalyticsSupported, })); jest.mock('firebase/app-check', () => ({ + getToken: mockGetToken, initializeAppCheck: mockInitializeAppCheck, ReCaptchaEnterpriseProvider: mockReCaptchaEnterpriseProvider, ReCaptchaV3Provider: mockReCaptchaV3Provider, @@ -86,6 +88,38 @@ function createResourcesFor( } } +async function withFirebaseModuleFor( + nodeEnv: string, + siteKey: string | undefined, + initialLocation: string, + body: ( + FirebaseResources: typeof import('./FirebaseResources').default, + ) => void | Promise, + initialHistoryState: unknown = null, +) { + const originalNodeEnv = process.env.NODE_ENV; + const originalSiteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; + const originalLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`; + setNodeEnv(nodeEnv); + if (siteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; + else process.env.REACT_APP_RECAPTCHA_SITE_KEY = siteKey; + window.history.replaceState(initialHistoryState, '', initialLocation); + + try { + let FirebaseResources!: typeof import('./FirebaseResources').default; + jest.isolateModules(() => { + // eslint-disable-next-line global-require + FirebaseResources = require('./FirebaseResources').default; + }); + await body(FirebaseResources); + } finally { + window.history.replaceState(null, '', originalLocation); + setNodeEnv(originalNodeEnv); + if (originalSiteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; + else process.env.REACT_APP_RECAPTCHA_SITE_KEY = originalSiteKey; + } +} + describe('FirebaseResources environment isolation', () => { beforeEach(() => { jest.clearAllMocks(); @@ -98,6 +132,7 @@ describe('FirebaseResources environment isolation', () => { mockGetFunctions.mockReturnValue(mockFunctions); mockGetAnalytics.mockReturnValue({ name: 'test-analytics' }); mockInitializeAppCheck.mockReturnValue({ name: 'test-app-check' }); + mockGetToken.mockResolvedValue({ token: 'synthetic-app-check-token' }); mockReCaptchaEnterpriseProvider.mockImplementation((siteKey: string) => ({ kind: 'enterprise', siteKey, @@ -109,6 +144,45 @@ describe('FirebaseResources environment isolation', () => { mockIsAnalyticsSupported.mockImplementation(() => Promise.resolve(false)); }); + test('accepts only empty or exact clean BrowserRouter history state', () => { + const exactState = { idx: 0, key: 'abcd1234', usr: null }; + const userStateGetter = jest.fn(() => null); + const callbackDetailGetter = jest.fn(() => 'private-callback-canary'); + const accessorState = { idx: 0, key: 'abcd1234' }; + Object.defineProperty(accessorState, 'usr', { get: userStateGetter }); + const extraState = { ...exactState } as typeof exactState & { code?: string }; + Object.defineProperty(extraState, 'code', { get: callbackDetailGetter }); + const hostileState = new Proxy(exactState, { + getPrototypeOf() { + throw new Error('private-history-proxy-canary'); + }, + }); + const arrayState = Object.assign([], exactState); + const callableState = Object.assign(() => undefined, exactState); + const nullPrototypeState = Object.assign(Object.create(null), exactState); + + expect(browserRouterStateIsClean(null)).toBe(true); + expect(browserRouterStateIsClean(null, 'default')).toBe(true); + expect(browserRouterStateIsClean(null, 'abcd1234')).toBe(false); + expect(browserRouterStateIsClean(exactState)).toBe(true); + expect(browserRouterStateIsClean(exactState, 'abcd1234')).toBe(true); + expect(browserRouterStateIsClean({ ...exactState, key: 'i' }, 'i')).toBe(true); + expect(browserRouterStateIsClean({ ...exactState, key: '' }, '')).toBe(true); + expect(browserRouterStateIsClean({ ...exactState, key: '' }, 'default')).toBe(true); + expect(browserRouterStateIsClean(exactState, 'wxyz5678')).toBe(false); + expect(browserRouterStateIsClean({ ...exactState, key: 'abcdefghi' })).toBe(false); + expect(browserRouterStateIsClean({ ...exactState, code: 'private-code-canary' })).toBe(false); + expect(browserRouterStateIsClean({ ...exactState, state: 'private-state-canary' })).toBe(false); + expect(browserRouterStateIsClean(accessorState)).toBe(false); + expect(browserRouterStateIsClean(extraState)).toBe(false); + expect(browserRouterStateIsClean(hostileState)).toBe(false); + expect(browserRouterStateIsClean(arrayState)).toBe(false); + expect(browserRouterStateIsClean(callableState)).toBe(false); + expect(browserRouterStateIsClean(nullPrototypeState)).toBe(false); + expect(userStateGetter).not.toHaveBeenCalled(); + expect(callbackDetailGetter).not.toHaveBeenCalled(); + }); + test('development uses only the demo namespace and all three emulators', () => { const resources = createResourcesFor('development', 'configured-public-site-key'); @@ -142,6 +216,7 @@ describe('FirebaseResources environment isolation', () => { 5001, ); expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); expect(mockReCaptchaV3Provider).not.toHaveBeenCalled(); expect(mockIsAnalyticsSupported).not.toHaveBeenCalled(); @@ -165,6 +240,27 @@ describe('FirebaseResources environment isolation', () => { ); }); + test('suppresses startup when an empty Router user state has extra saved detail', async () => { + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback', + async (FirebaseResources) => { + FirebaseResources.getInstance(); + + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + { + idx: 0, + key: 'abcd1234', + usr: null, + code: 'private-saved-code-canary', + }, + ); + }); + test('test runtime also uses the demo project and local emulators', () => { const resources = createResourcesFor('test', 'configured-public-site-key'); @@ -173,6 +269,7 @@ describe('FirebaseResources environment isolation', () => { expect(mockConnectFirestoreEmulator).toHaveBeenCalled(); expect(mockConnectFunctionsEmulator).toHaveBeenCalled(); expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); expect(mockReCaptchaV3Provider).not.toHaveBeenCalled(); expect(mockIsAnalyticsSupported).not.toHaveBeenCalled(); @@ -203,6 +300,7 @@ describe('FirebaseResources environment isolation', () => { isTokenAutoRefreshEnabled: true, }, ); + expect(mockGetToken).not.toHaveBeenCalled(); expect(mockIsAnalyticsSupported).not.toHaveBeenCalled(); expect(mockGetAnalytics).not.toHaveBeenCalled(); expect(resources.analytics).toBeNull(); @@ -225,6 +323,7 @@ describe('FirebaseResources environment isolation', () => { expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); expect(mockReCaptchaV3Provider).not.toHaveBeenCalled(); expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledWith('[MPRC client] app_check_disabled'); } finally { @@ -276,6 +375,7 @@ describe('FirebaseResources environment isolation', () => { expect(resources.firestore).toBe(mockFirestore); expect(resources.functions).toBe(mockFunctions); expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledWith( '[MPRC client] app_check_initialization_failed', @@ -305,6 +405,7 @@ describe('FirebaseResources environment isolation', () => { expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); expect(mockReCaptchaV3Provider).not.toHaveBeenCalled(); expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); expect(mockIsAnalyticsSupported).not.toHaveBeenCalled(); expect(mockGetAnalytics).not.toHaveBeenCalled(); }); @@ -332,6 +433,7 @@ describe('FirebaseResources environment isolation', () => { expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); expect(mockReCaptchaV3Provider).not.toHaveBeenCalled(); expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); expect(mockIsAnalyticsSupported).not.toHaveBeenCalled(); expect(mockGetAnalytics).not.toHaveBeenCalled(); } finally { @@ -345,6 +447,541 @@ describe('FirebaseResources environment isolation', () => { } }); + test.each([ + '/account/strava/callback', + '/auth/action', + '/register/success', + '/shop/purchase/success', + ])('suppresses startup on a clean capability path with non-null Router state: %s', async ( + initialLocation, + ) => { + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + initialLocation, + async (FirebaseResources) => { + const resources = FirebaseResources.getInstance(); + + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + + window.history.replaceState( + { idx: 0, key: 'abcd1234', usr: null }, + '', + initialLocation, + ); + await expect( + resources.prepareAppCheckAfterStravaCallbackCleanup(), + ).rejects.toThrow('Strava callback App Check preparation failed.'); + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + { + idx: 0, + key: 'private-callback-entry', + usr: { privateCallbackState: 'router-state-canary' }, + }, + ); + }); + + test.each([ + [ + 'plain', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + '/account/strava/callback', + ], + [ + 'case-changed', + '/ACCOUNT/STRAVA/CALLBACK?code=synthetic-code&state=synthetic-state', + '/ACCOUNT/STRAVA/CALLBACK', + ], + [ + 'encoded segments', + '/%61ccount/%73trava/%63allback?code=synthetic-code&state=synthetic-state', + '/%61ccount/%73trava/%63allback', + ], + [ + 'trailing slashes', + '/account/strava/callback///?code=synthetic-code&state=synthetic-state', + '/account/strava/callback///', + ], + ])('prepares App Check after a clean %s initial Strava callback', async ( + _case, + initialLocation, + cleanLocation, + ) => { + const appCheck = { name: 'delayed-test-app-check' }; + const tokenGetter = jest.fn(() => 'private-token-canary'); + const toJSON = jest.fn(() => 'private-token-serialization-canary'); + const tokenResult = { toJSON }; + Object.defineProperty(tokenResult, 'token', { get: tokenGetter }); + mockInitializeAppCheck.mockReturnValueOnce(appCheck); + mockGetToken.mockResolvedValueOnce(tokenResult); + + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + initialLocation, + async (FirebaseResources) => { + // The module remembers the initial capability even if cleanup wins + // before the service singleton is constructed. + window.history.replaceState( + { idx: 0, key: 'abcd1234', usr: null }, + '', + cleanLocation, + ); + const resources = FirebaseResources.getInstance(); + + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + + await expect( + resources.prepareAppCheckAfterStravaCallbackCleanup(), + ).resolves.toBeUndefined(); + + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledWith( + 'configured-public-site-key', + ); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledWith(resources.app, { + provider: { + kind: 'enterprise', + siteKey: 'configured-public-site-key', + }, + isTokenAutoRefreshEnabled: true, + }); + expect(mockGetToken).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledWith(appCheck); + }, + ); + + expect(tokenGetter).not.toHaveBeenCalled(); + expect(toJSON).not.toHaveBeenCalled(); + }); + + test.each(['development', 'test'])( + '%s keeps delayed App Check preparation as a local no-op', + async (nodeEnv) => { + const resources = createResourcesFor( + nodeEnv, + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + ); + + await expect( + resources.prepareAppCheckAfterStravaCallbackCleanup(), + ).resolves.toBeUndefined(); + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + ); + + test.each([ + [ + 'query remains', + '/account/strava/callback?code=reinjected-code&state=reinjected-state', + ], + ['fragment remains', '/account/strava/callback#reinjected-fragment'], + ['path is wrong', '/account'], + ['path encoding is malformed', '/account/%ZZ/callback'], + ])('rejects delayed App Check preparation when the current native %s', async ( + _case, + currentLocation, + ) => { + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', currentLocation); + const resources = FirebaseResources.getInstance(); + + await expect( + resources.prepareAppCheckAfterStravaCallbackCleanup(), + ).rejects.toThrow('Strava callback App Check preparation failed.'); + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + ); + }); + + test('rejects delayed preparation when clean Router metadata has extra saved detail', async () => { + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState( + { + idx: 0, + key: 'abcd1234', + usr: null, + state: 'private-saved-state-canary', + }, + '', + '/account/strava/callback', + ); + const resources = FirebaseResources.getInstance(); + + await expect( + resources.prepareAppCheckAfterStravaCallbackCleanup(), + ).rejects.toThrow('Strava callback App Check preparation failed.'); + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + ); + }); + + test.each([ + '/auth/action?mode=verifyEmail&oobCode=synthetic-action-code', + '/register/success?registration=r1&token=synthetic-capability', + '/shop/purchase/success?order=o1&token=synthetic-capability', + '/account/%ZZ/callback?code=synthetic-code&state=synthetic-state', + ])('does not restart App Check from a non-Strava initial capability: %s', async ( + initialLocation, + ) => { + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + initialLocation, + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + + await expect( + resources.prepareAppCheckAfterStravaCallbackCleanup(), + ).rejects.toThrow('Strava callback App Check preparation failed.'); + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + ); + }); + + test('shares one provider and token-readiness flight across concurrent calls', async () => { + const appCheck = { name: 'single-flight-test-app-check' }; + let finishToken: (() => void) | undefined; + mockInitializeAppCheck.mockReturnValueOnce(appCheck); + mockGetToken.mockImplementationOnce(() => new Promise((resolve) => { + finishToken = () => resolve({ token: 'synthetic-app-check-token' }); + })); + + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + const first = resources.prepareAppCheckAfterStravaCallbackCleanup(); + const second = resources.prepareAppCheckAfterStravaCallbackCleanup(); + + expect(second).toBe(first); + await Promise.resolve(); + await Promise.resolve(); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledTimes(1); + + finishToken?.(); + await expect(Promise.all([first, second])).resolves.toEqual([ + undefined, + undefined, + ]); + + const afterReady = resources.prepareAppCheckAfterStravaCallbackCleanup(); + expect(afterReady).toBe(first); + await expect(afterReady).resolves.toBeUndefined(); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }, + ); + }); + + test('a dirty concurrent call poisons the queued clean preparation', async () => { + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + const first = resources.prepareAppCheckAfterStravaCallbackCleanup(); + + window.history.replaceState( + null, + '', + '/account/strava/callback?code=reinjected-code-canary', + ); + const dirty = resources.prepareAppCheckAfterStravaCallbackCleanup(); + window.history.replaceState(null, '', '/account/strava/callback'); + + await expect(dirty).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + await expect(first).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + expect(mockReCaptchaEnterpriseProvider).not.toHaveBeenCalled(); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + }, + ); + }); + + test('a clean provider reentry shares the queued preparation', async () => { + let reenteredPreparation: Promise | undefined; + + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + mockReCaptchaEnterpriseProvider.mockImplementationOnce((siteKey: string) => { + reenteredPreparation = resources.prepareAppCheckAfterStravaCallbackCleanup(); + return { kind: 'enterprise', siteKey }; + }); + + const preparation = resources.prepareAppCheckAfterStravaCallbackCleanup(); + await expect(preparation).resolves.toBeUndefined(); + expect(reenteredPreparation).toBe(preparation); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }, + ); + }); + + test.each([ + ['Enterprise construction', 'provider'], + ['App Check initialization', 'initialization'], + ['token request', 'token'], + ])('stops at the next boundary when %s dirties the callback', async ( + _case, + boundary, + ) => { + const dirtyCallback = () => { + window.history.replaceState( + null, + '', + '/account/strava/callback?code=boundary-reinjection-canary', + ); + }; + if (boundary === 'provider') { + mockReCaptchaEnterpriseProvider.mockImplementationOnce((siteKey: string) => { + dirtyCallback(); + return { kind: 'enterprise', siteKey }; + }); + } else if (boundary === 'initialization') { + mockInitializeAppCheck.mockImplementationOnce(() => { + dirtyCallback(); + return { name: 'dirty-initialization-app-check' }; + }); + } else { + mockGetToken.mockImplementationOnce(() => { + dirtyCallback(); + return new Promise(() => { + // Keep the SDK request pending after its synchronous route mutation. + }); + }); + } + + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + const preparation = resources.prepareAppCheckAfterStravaCallbackCleanup(); + + await expect(preparation).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes( + boundary === 'provider' ? 0 : 1, + ); + expect(mockGetToken).toHaveBeenCalledTimes(boundary === 'token' ? 1 : 0); + + window.history.replaceState(null, '', '/account/strava/callback'); + const retry = resources.prepareAppCheckAfterStravaCallbackCleanup(); + expect(retry).toBe(preparation); + await expect(retry).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + }, + ); + }); + + test('caches failure when the native callback becomes dirty during token readiness', async () => { + let finishToken: (() => void) | undefined; + mockGetToken.mockImplementationOnce(() => new Promise((resolve) => { + finishToken = () => resolve({ token: 'synthetic-app-check-token' }); + })); + + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + const preparation = resources.prepareAppCheckAfterStravaCallbackCleanup(); + await Promise.resolve(); + await Promise.resolve(); + expect(mockGetToken).toHaveBeenCalledTimes(1); + + window.history.replaceState( + null, + '', + '/account/strava/callback?code=reinjected-code-canary', + ); + finishToken?.(); + await expect(preparation).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + + window.history.replaceState(null, '', '/account/strava/callback'); + const retry = resources.prepareAppCheckAfterStravaCallbackCleanup(); + expect(retry).toBe(preparation); + await expect(retry).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }, + ); + }); + + test('a dirty microtask before the ready marker poisons the completed flight', async () => { + let finishToken: (() => void) | undefined; + mockGetToken.mockImplementationOnce(() => new Promise((resolve) => { + finishToken = () => resolve({ token: 'synthetic-app-check-token' }); + })); + + await withFirebaseModuleFor( + 'production', + 'configured-public-site-key', + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + const preparation = resources.prepareAppCheckAfterStravaCallbackCleanup(); + await Promise.resolve(); + await Promise.resolve(); + expect(mockGetToken).toHaveBeenCalledTimes(1); + + finishToken?.(); + const dirtyObservation = Promise.resolve().then(() => { + window.history.replaceState( + null, + '', + '/account/strava/callback?code=microtask-reinjection-canary', + ); + const dirty = resources.prepareAppCheckAfterStravaCallbackCleanup(); + window.history.replaceState(null, '', '/account/strava/callback'); + return dirty; + }); + + await expect(dirtyObservation).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + await expect(preparation).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes(1); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }, + ); + }); + + test.each([ + ['missing key', 'missing-key'], + ['Enterprise constructor failure', 'provider'], + ['App Check initialization failure', 'initialization'], + ['synchronous token failure', 'token-sync'], + ['asynchronous token failure', 'token-async'], + ])('uses one fixed redacted failure for %s', async (_case, failureStage) => { + const canary = `private-${failureStage}-canary`; + const messageGetter = jest.fn(() => { + throw new Error(`${canary}-getter-read`); + }); + const hostileFailure = {}; + Object.defineProperty(hostileFailure, 'message', { get: messageGetter }); + const siteKey = failureStage === 'missing-key' + ? undefined + : 'configured-public-site-key'; + const expectedDiagnostic = failureStage === 'missing-key' + ? '[MPRC client] app_check_disabled' + : '[MPRC client] app_check_initialization_failed'; + if (failureStage === 'provider') { + mockReCaptchaEnterpriseProvider.mockImplementationOnce(() => { + throw hostileFailure; + }); + } else if (failureStage === 'initialization') { + mockInitializeAppCheck.mockImplementationOnce(() => { + throw hostileFailure; + }); + } else if (failureStage === 'token-sync') { + mockGetToken.mockImplementationOnce(() => { + throw hostileFailure; + }); + } else if (failureStage === 'token-async') { + mockGetToken.mockImplementationOnce(() => Promise.reject(hostileFailure)); + } + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await withFirebaseModuleFor( + 'production', + siteKey, + '/account/strava/callback?code=synthetic-code&state=synthetic-state', + async (FirebaseResources) => { + window.history.replaceState(null, '', '/account/strava/callback'); + const resources = FirebaseResources.getInstance(); + const first = resources.prepareAppCheckAfterStravaCallbackCleanup(); + const second = resources.prepareAppCheckAfterStravaCallbackCleanup(); + + expect(second).toBe(first); + await expect(first).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + await expect(second).rejects.toThrow( + 'Strava callback App Check preparation failed.', + ); + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith(expectedDiagnostic); + expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain(canary); + expect(mockReCaptchaEnterpriseProvider).toHaveBeenCalledTimes( + failureStage === 'missing-key' ? 0 : 1, + ); + expect(mockInitializeAppCheck).toHaveBeenCalledTimes( + ['missing-key', 'provider'].includes(failureStage) ? 0 : 1, + ); + expect(mockGetToken).toHaveBeenCalledTimes( + failureStage.startsWith('token-') ? 1 : 0, + ); + }, + ); + expect(messageGetter).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + } + }); + test('ordinary production query pages keep Analytics disabled', async () => { mockIsAnalyticsSupported.mockResolvedValueOnce(true); @@ -367,6 +1004,10 @@ describe('FirebaseResources environment isolation', () => { expect(source).toContain('ReCaptchaEnterpriseProvider'); expect(source).not.toContain('ReCaptchaV3Provider'); + expect(source).not.toContain('initialBrowserLocation'); + expect(source).not.toMatch(/pathname:\s*window\.location\.pathname/); + expect(source).not.toMatch(/search:\s*window\.location\.search/); + expect(source).not.toMatch(/hash:\s*window\.location\.hash/); }); test('rejects malformed direct Function names before building a URL', () => { diff --git a/src/services/firebase/FirebaseResources.ts b/src/services/firebase/FirebaseResources.ts index f705e0a..41d4b96 100644 --- a/src/services/firebase/FirebaseResources.ts +++ b/src/services/firebase/FirebaseResources.ts @@ -1,6 +1,6 @@ import { FirebaseApp, initializeApp } from 'firebase/app'; import { - AppCheck, initializeAppCheck, ReCaptchaEnterpriseProvider, + AppCheck, getToken, initializeAppCheck, ReCaptchaEnterpriseProvider, } from 'firebase/app-check'; import { Auth, connectAuthEmulator, getAuth } from 'firebase/auth'; import { @@ -14,7 +14,11 @@ import { getFunctions, } from 'firebase/functions'; -import hasCapabilityCallbackState from '../monitoring/capabilityCallback'; +import hasCapabilityCallbackState, { + browserRouterStateIsClean, + isCapabilityCallbackPath, + isStravaCapabilityCallbackPath, +} from '../monitoring/capabilityCallback'; import { clientFailureEvents, reportClientFailure, @@ -23,6 +27,9 @@ import { const isLocalRuntime = process.env.NODE_ENV !== 'production'; const LOCAL_FIREBASE_PROJECT_ID = 'demo-mprc-local'; const FUNCTION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; +const STRAVA_APP_CHECK_PREPARATION_FAILED = ( + 'Strava callback App Check preparation failed.' +); const LOCAL_FIREBASE_CONFIG = { apiKey: 'demo-api-key', @@ -46,8 +53,31 @@ const PRODUCTION_FIREBASE_CONFIG = { const FIREBASE_CONFIG = isLocalRuntime ? LOCAL_FIREBASE_CONFIG : PRODUCTION_FIREBASE_CONFIG; -const initialPageHadCapabilityCallbackState = typeof window !== 'undefined' +const initialPageHadUrlCapabilityCallbackState = typeof window !== 'undefined' && hasCapabilityCallbackState(window.location); +const initialPagePathWasCapabilityCallback = typeof window !== 'undefined' + && isCapabilityCallbackPath(window.location.pathname); +const initialPageHadCapabilityCallbackState = initialPageHadUrlCapabilityCallbackState + || ( + typeof window !== 'undefined' + && initialPagePathWasCapabilityCallback + && !browserRouterStateIsClean(window.history.state) + ); +const initialPageWasStravaCapabilityCallback = typeof window !== 'undefined' + && initialPageHadUrlCapabilityCallbackState + && isStravaCapabilityCallbackPath(window.location.pathname); + +function currentNativeStravaCallbackIsClean(): boolean { + return typeof window !== 'undefined' + && isStravaCapabilityCallbackPath(window.location.pathname) + && window.location.search === '' + && window.location.hash === '' + && browserRouterStateIsClean(window.history.state); +} + +function stravaAppCheckPreparationError(): Error { + return new Error(STRAVA_APP_CHECK_PREPARATION_FAILED); +} class FirebaseResources { readonly app: FirebaseApp; @@ -62,6 +92,12 @@ class FirebaseResources { private _appCheck: AppCheck | null = null; + private _stravaAppCheckPreparation: Promise | null = null; + + private _stravaAppCheckPreparationInvalidated = false; + + private _stravaAppCheckPreparationReady = false; + private static _instance: FirebaseResources | null = null; private static _emulatorsConnected = false; @@ -80,26 +116,136 @@ class FirebaseResources { // App Check has no local Emulator Suite target. Do not initialize a // provider or exchange a debug token from development or tests. // Its reCAPTCHA Enterprise provider is also an outside script, so do not start it - // while an initial OAuth/checkout capability remains in the page URL. + // while an initial OAuth/checkout capability remains in the page URL or saved + // Router entry. if ( isLocalRuntime || initialPageHadCapabilityCallbackState || (typeof window !== 'undefined' && hasCapabilityCallbackState(window.location)) ) return; + this._appCheck = this.initializeAppCheckProvider(); + } + + private initializeAppCheckProvider( + beforeInitialization?: () => boolean, + ): AppCheck | null { const siteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; if (!siteKey) { reportClientFailure(clientFailureEvents.appCheckDisabled); - return; + return null; } try { - this._appCheck = initializeAppCheck(this.app, { - provider: new ReCaptchaEnterpriseProvider(siteKey), + const provider = new ReCaptchaEnterpriseProvider(siteKey); + if (beforeInitialization && !beforeInitialization()) return null; + return initializeAppCheck(this.app, { + provider, isTokenAutoRefreshEnabled: true, }); } catch { reportClientFailure(clientFailureEvents.appCheckInitializationFailed); + return null; + } + } + + private stravaAppCheckPreparationIsCurrent(): boolean { + if ( + this._stravaAppCheckPreparationInvalidated + || !currentNativeStravaCallbackIsClean() + ) { + this._stravaAppCheckPreparationInvalidated = true; + return false; + } + return true; + } + + private async prepareStravaAppCheckOnce(): Promise { + // The first public check and this queued check bracket the microtask that + // establishes the single flight. Do not initialize after a reinjection. + if (!this.stravaAppCheckPreparationIsCurrent()) { + throw stravaAppCheckPreparationError(); + } + + const appCheck = this.initializeAppCheckProvider( + () => this.stravaAppCheckPreparationIsCurrent(), + ); + if (appCheck === null) { + throw stravaAppCheckPreparationError(); + } + this._appCheck = appCheck; + + if (!this.stravaAppCheckPreparationIsCurrent()) { + throw stravaAppCheckPreparationError(); + } + + let tokenReadiness: ReturnType; + try { + // Readiness is the only result used here. The token value is never + // accepted, returned, inspected, logged, or stored by application code. + tokenReadiness = Promise.resolve(getToken(appCheck)); + } catch { + reportClientFailure(clientFailureEvents.appCheckInitializationFailed); + throw stravaAppCheckPreparationError(); + } + + if (!this.stravaAppCheckPreparationIsCurrent()) { + // The SDK request cannot be cancelled. Attach a value-blind rejection + // sink before this preparation stops so a later SDK failure is inert. + tokenReadiness.then(() => undefined, () => undefined); + throw stravaAppCheckPreparationError(); + } + + try { + await tokenReadiness; + } catch { + reportClientFailure(clientFailureEvents.appCheckInitializationFailed); + throw stravaAppCheckPreparationError(); + } + + if (!this.stravaAppCheckPreparationIsCurrent()) { + throw stravaAppCheckPreparationError(); + } + } + + prepareAppCheckAfterStravaCallbackCleanup(): Promise { + // App Check has no local emulator. Its deliberate local/test no-op must + // not turn an otherwise valid synthetic callback into a failure. + if (isLocalRuntime) return Promise.resolve(); + + if (!initialPageWasStravaCapabilityCallback) { + this._stravaAppCheckPreparationInvalidated = true; + return Promise.reject(stravaAppCheckPreparationError()); + } + + if (!currentNativeStravaCallbackIsClean()) { + this._stravaAppCheckPreparationInvalidated = true; + return Promise.reject(stravaAppCheckPreparationError()); + } + + if (this._stravaAppCheckPreparationInvalidated) { + if ( + this._stravaAppCheckPreparation !== null + && !this._stravaAppCheckPreparationReady + ) return this._stravaAppCheckPreparation; + return Promise.reject(stravaAppCheckPreparationError()); + } + + if (this._stravaAppCheckPreparation === null) { + // Queue the irreversible provider work so the promise is cached before + // any provider code can re-enter this method. + this._stravaAppCheckPreparation = Promise.resolve() + .then(() => this.prepareStravaAppCheckOnce()) + .then(() => { + // The async preparation and this final handler are separate + // microtasks. A concurrent dirty observation between them must + // poison the flight before it can be marked ready. + if (!this.stravaAppCheckPreparationIsCurrent()) { + throw stravaAppCheckPreparationError(); + } + this._stravaAppCheckPreparationReady = true; + }); } + return this._stravaAppCheckPreparation; } static getInstance(): FirebaseResources { diff --git a/src/services/monitoring/capabilityCallback.ts b/src/services/monitoring/capabilityCallback.ts index 18e5fb7..cbc408e 100644 --- a/src/services/monitoring/capabilityCallback.ts +++ b/src/services/monitoring/capabilityCallback.ts @@ -11,26 +11,90 @@ const CAPABILITY_CALLBACK_PATHS = new Set([ '/shop/purchase/success', ]); -export default function hasCapabilityCallbackState( - location: BrowserLocationState, -): boolean { +const STRAVA_CALLBACK_PATH = '/account/strava/callback'; + +function normalizeCapabilityCallbackPath(pathname: string): string | null { let routerPathname: string; try { // Match React Router's segment decoding without turning an encoded slash // into a new path separator. - routerPathname = location.pathname + routerPathname = pathname .split('/') .map((segment) => decodeURIComponent(segment).replace(/\//g, '%2F')) .join('/'); } catch { - // A malformed encoded path cannot be compared reliably. If it also - // carries query/fragment state, suppress telemetry rather than guess. - return location.search.length > 0 || location.hash.length > 0; + return null; } - const normalizedPathname = routerPathname === '/' + return routerPathname === '/' ? '/' : routerPathname.toLowerCase().replace(/\/+$/, ''); +} + +export function isStravaCapabilityCallbackPath(pathname: string): boolean { + return normalizeCapabilityCallbackPath(pathname) === STRAVA_CALLBACK_PATH; +} + +export function isCapabilityCallbackPath(pathname: string): boolean { + const normalizedPathname = normalizeCapabilityCallbackPath(pathname); + return normalizedPathname !== null && CAPABILITY_CALLBACK_PATHS.has(normalizedPathname); +} + +export function browserRouterStateIsClean( + historyState: unknown, + expectedKey?: string, +): boolean { + if (historyState === null) { + return expectedKey === undefined || expectedKey === 'default'; + } + if (typeof historyState !== 'object') return false; + + try { + if (Object.getPrototypeOf(historyState) !== Object.prototype) return false; + const ownKeys = Reflect.ownKeys(historyState); + if ( + ownKeys.length !== 3 + || !['idx', 'key', 'usr'].every((key) => ownKeys.includes(key)) + ) return false; + + const indexState = Object.getOwnPropertyDescriptor(historyState, 'idx'); + const keyState = Object.getOwnPropertyDescriptor(historyState, 'key'); + const userState = Object.getOwnPropertyDescriptor(historyState, 'usr'); + if ( + indexState === undefined + || keyState === undefined + || userState === undefined + || !Object.prototype.hasOwnProperty.call(indexState, 'value') + || !Object.prototype.hasOwnProperty.call(keyState, 'value') + || !Object.prototype.hasOwnProperty.call(userState, 'value') + ) return false; + + // The pinned Router takes at most eight lowercase base-36 characters + // from Math.random(). Short or empty outputs are possible. + const keyIsValid = typeof keyState.value === 'string' + && /^[a-z0-9]{0,8}$/.test(keyState.value); + const keyMatchesRouter = expectedKey === undefined + || keyState.value === expectedKey + || (keyState.value === '' && expectedKey === 'default'); + return Number.isSafeInteger(indexState.value) + && indexState.value >= 0 + && keyIsValid + && keyMatchesRouter + && userState.value === null; + } catch { + return false; + } +} + +export default function hasCapabilityCallbackState( + location: BrowserLocationState, +): boolean { + const normalizedPathname = normalizeCapabilityCallbackPath(location.pathname); + if (normalizedPathname === null) { + // A malformed encoded path cannot be compared reliably. If it also + // carries query/fragment state, suppress telemetry rather than guess. + return location.search.length > 0 || location.hash.length > 0; + } return CAPABILITY_CALLBACK_PATHS.has(normalizedPathname) && (location.search.length > 0 || location.hash.length > 0);