diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index f6a143fa..2080bd43 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -10,12 +10,12 @@ "targets": [ "src" ], - "scannedFileCount": 120, + "scannedFileCount": 127, "scannedFilesByExtension": { ".js": 12, ".jsx": 35, - ".ts": 43, - ".tsx": 30 + ".ts": 46, + ".tsx": 34 }, "errorCount": 113, "warningCount": 6, @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:b62532df877e1a1599273d0bdbf9876e75dae8be6e60ecadce601f854c283b6a", + "fingerprint": "sha256:67db6e465743580f63e5f1bdd359b60bceaa2217c4e754ea8902e84a85cf74ba", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -65,6 +65,10 @@ "src/pages/account/Account.tsx", "src/pages/account/MemberDirectoryProfile.test.tsx", "src/pages/account/MemberDirectoryProfile.tsx", + "src/pages/account/RunnerCard.tsx", + "src/pages/account/RunnerConnections.test.tsx", + "src/pages/account/RunnerConnections.tsx", + "src/pages/account/RunnerProfileForm.tsx", "src/pages/account/StravaCallback.tsx", "src/pages/account/StravaSection.tsx", "src/pages/activities/Activities.jsx", @@ -118,6 +122,9 @@ "src/services/account/memberDirectorySearchService.ts", "src/services/account/memberDirectoryService.test.ts", "src/services/account/memberDirectoryService.ts", + "src/services/account/runnerConnectionContract.ts", + "src/services/account/runnerConnectionService.test.ts", + "src/services/account/runnerConnectionService.ts", "src/services/accountEmail/accountEmailSender.test.ts", "src/services/accountEmail/accountEmailSender.ts", "src/services/analytics/analytics.test.ts", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d6160f5..6e3aecd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,3 +190,21 @@ jobs: - run: npm ci --legacy-peer-deps --ignore-scripts - name: Run Firestore rules tests against the emulator run: npm run test:rules + - name: Install Functions dependencies for runner profile tests + run: npm --prefix functions ci --ignore-scripts + - name: Run runner profile persistence tests against the emulator + env: + REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1' + REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1' + REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR: '1' + run: >- + npx --no-install firebase emulators:exec + --project demo-functions-test + --only firestore,auth + "npm --prefix functions run test:run -- + --runInBand runnerConnectionProfiles.emulator.test.js + runnerConnectionService.emulator.test.js + runnerConnectionRecommendations.emulator.test.js" + - name: Run runner client HTTP transport tests + timeout-minutes: 5 + run: node scripts/run-runner-transport-tests.cjs diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 457ec673..1b92e730 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -40,6 +40,27 @@ The 2026-09-27 production-dependency audit remains nonzero: root 5 findings (1 l ## Backlog conventions +September 27 addition: [COMMUNITY-001 / #689](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/689) +tracks owner-authorized opt-in runner connections. See [the implementation +record](./docs/RUNNER_CONNECTIONS.md) for current source and remaining integrated +acceptance cases. The draft source adds a tested private storage primitive and +member-confirmed 18+ with no DOB. A disabled callable factory now checks current +Auth and canonical membership before saves and recommendations, with bounded +daily windows, directed hide/block and fresh delivery checks. Approved membership +population, retention/account-deletion handling, persistent privacy-choice +management and integrated staging remain incomplete. The member interface now +has a tested form, exact preview, consent, withdrawal, recommendations and +this-visit privacy undo; its source gate remains literally false. This is +not a live feature or permission to +deploy additional Functions through the two-profile-Function release. +The latest source also adds mandatory actual-client HTTP tests against isolated +Auth/Firestore/Functions emulators. These do not verify token signatures or a +hosted browser pilot; local emulator success does not close #689. +A separate explicit local browser mode now connects the actual workspace/client +to those emulators and verifies save/reload, suggestions, block/undo, withdrawal +and nonmember denial with disposable fixtures. Normal app sign-in and hosted +acceptance remain unproven; production source gates are unchanged. + ### Labels - Priority: `priority:P0`, `priority:P1`, `priority:P2` diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index be5b582f..319b2ab8 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -13,6 +13,20 @@ RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) pr ## 1. Program outcome +Owner-authorized runner connections are tracked in [#689](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/689). +The [implementation record](./docs/RUNNER_CONNECTIONS.md) distinguishes the initial +synthetic domain/persistence and disabled profile/recommendation-service tests +(including bounded windows, hide/block and delivery rechecks), and the disabled +member form/preview/recommendation UI, from outstanding canonical membership +population, retention/account-deletion approval, persistent privacy-choice +management, integrated staging and live-release work. Explicit 18+ self-attestation +is selected; no birth date is collected. The existing small profile deployment +scope is unchanged. +Actual client-to-local-HTTP transport now has an isolated synthetic rehearsal; +the actual member workspace/client also has a connected local browser rehearsal. +Normal whole-app sign-in, hosted integration and token/App Check verification +remain separate required checks because the Functions emulator bypasses signatures. + MPRC can open a race or merchandise item for sale only when the platform can: - Prove one client request creates at most one active Checkout Session and business record. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index bde293fa..b0a31039 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -6,6 +6,41 @@ This runbook explains how to operate the website, Firebase services, and Stripe integration safely. Unless a procedure is explicitly marked **available now**, it describes the **target** operating model and must not be represented as implemented or deployed. The implementation source of truth is [GITHUB_ISSUES.md](./GITHUB_ISSUES.md); any procedure whose owning issue is not `done` is unavailable for production use. +Runner connections (#689) are **NOT AVAILABLE YET**. The draft matching/storage +code is exercised only with synthetic local data and exports no new Cloud +Function. Do not collect profiles, run backfills or deploy these paths through +the existing two-profile-Function release. Read the +[officer review guide](./docs/officers/RUNNER_CONNECTIONS.md) for the selected +18+ self-attestation, evidence boundary and stop conditions. The disabled profile +service's current-account/membership checks pass local tests; approved membership +population, hosted Auth/App Check proof and a protected pilot remain +outstanding. The disabled recommendation service has local synthetic tests for +bounded daily windows, hide/block and fresh delivery checks. It adds no usable +officer tool; retention/account-deletion and privacy-choice recovery still need +approved procedures before a pilot. The member form, exact-card preview, +recommendations, withdrawal and this-visit privacy undo now pass synthetic UI +tests; the route and account link remain disabled by a literal source gate. +The browser demo used an in-memory fake client, not Firebase. It proves layout +and interface transitions, not hosted access or an integrated release. +The separate real-client HTTP rehearsal now uses the local Auth, Firestore and +Functions emulators. Engineers run `node scripts/run-runner-transport-tests.cjs` +with Node 20/Java 21 and the committed root/Functions installs; officers request +its evidence through the guide. The script supplies an isolated demo project, +empty CLI configuration and synthetic credentials and cleans up its own fixtures. +Never use the production Functions entry point for this test. The emulator +bypasses token signatures, so this does not replace hosted Auth/App Check proof. +The explicit `--browser` option now provides a connected local member-workspace +rehearsal with made-up accounts, not the app's normal sign-in flow. The printed +loopback URL is the only supported host. Its **End rehearsal and clean up** action +must report zero users/root collections and stop the services. See the +implementation record for the fixed scope and tested save/reload, privacy and +nonmember-denial behavior. Production source gates remain disabled. + +The #689 frontend increment expands the lint inventory to 127 files: 12 JS, +35 JSX, 46 TS and 34 TSX. All seven new TypeScript files are finding-free; +the exact existing 113 errors and six warnings are unchanged. Only the inventory +and fingerprint are updated. This is not a clean-lint claim. + Club officers and backup maintainers should start with [OFFICER_START_HERE.md](./OFFICER_START_HERE.md). It converts this technical runbook into short request, approval, verification, access, and emergency steps without terminal commands. ### Node 22 profile release prerequisite — hosted behavior NOT AVAILABLE YET diff --git a/SECURITY.md b/SECURITY.md index 149b3ba8..83ed1777 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,38 @@ This is both the repository security policy and the current engineering risk reg The public informational website can continue operating with normal care. **Live race or merchandise payments should remain disabled until every P0 launch blocker below is closed and verified in a staging dress rehearsal.** The existing payment implementation is a useful prototype, but several correctness defects could mark unpaid transactions paid, lose payment callbacks, oversell race capacity, fail to reconcile late-payment links, or allow overly broad administrative access. +Runner connections [#689](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/689) +remain source-only and unavailable. Their separate private storage paths deny +all browser access, including owner/admin access. Transactional saves require +versioned discovery consent and explicit 18+ self-attestation; withdrawal clears +both the card and affirmation. This is not age/identity verification and never +accepts a DOB. The disabled profile-service factory checks current verified +Auth, revocation and a bounded canonical membership read, with native App Check +and separate request limits; its callback tests do not prove hosted enforcement. +Source now bounds daily recommendations to five references, with fresh candidate, +consent and directed-exclusion checks before delivery; all locator, window and +exclusion records deny browser access. These point-in-time reads cannot recall +data already sent. The source-gated browser interface uses closed projections, +unchecked initial consent/age confirmation, exact previews, account/app-bound +state and identical-command recovery after uncertain saves. It clears suggestions +on blur, expiry or failed requests; that is not a substitute for server checks. +Approved membership population, retention/account-deletion approval, persistent +privacy-choice management and protected integration/release proof remain required; see the +[implementation record](./docs/RUNNER_CONNECTIONS.md). + +The runner transport harness uses a fixed demo project, loopback-only client and +service network guard, disposable CLI settings and ephemeral synthetic Admin +credentials. It does not import the production Functions index or enable a +production capability. Local HTTP checks now use the actual browser client +modules, but the Functions emulator bypasses token-signature verification: +passing local missing-token/account/membership tests does not prove hosted Auth +or App Check enforcement, attestation, IAM or browser CORS behavior. +The explicit `--browser` rehearsal additionally exercises real browser-to-loopback +calls with the actual member workspace/client, not the normal app login route. +Its fixed local assets use exact Host/Origin checks, restrictive CSP and no-store +headers; only randomized synthetic fixtures are seeded and removed. This proves +the exercised local CORS behavior, not hosted CORS or cryptographic enforcement. + ### Runtime prerequisite — source only, 2026-09-27 RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) targets Node.js 22 without changing first-generation triggers, permissions, profile schema, deployment scope, or resource limits. [Google schedules Node 20 decommissioning for October 30, 2026](https://docs.cloud.google.com/functions/docs/runtime-support). Both protected-release CI reads now require the separate Node 22 backend/profile-persistence job, and provider verification rejects either profile Function unless its runtime is exactly `nodejs22`. The original Node 20 baseline checks remain required. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 6594cb05..98527430 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -20,6 +20,29 @@ RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) pr ### Goals +The September 27 owner direction adds in-house, opt-in runner connections under +[#689](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/689). The current +[implementation record](./docs/RUNNER_CONNECTIONS.md) contains a synthetic-tested +validator/ranker, private transactional storage and a disabled callable +factory with current Auth/canonical-membership reads, bounded daily suggestions +and directed hide/block controls. The member form, exact-card preview and +recommendation/privacy interface now have synthetic tests, but remain disabled +behind a literal source gate. No service is deployed. Approved membership +population remains a release +dependency; roles and officer-directory consent never replace it. +The selected 18+ policy is explicit member self-attestation, +with no DOB or verified-age claim; current membership remains independently +server-authoritative. Member-discovery consent is +separate from officer lookup. Payment processing, demographic matching and live +release remain separate decisions; no existing member is automatically enrolled. +The actual client also passes a separate Node/Web SDK-to-local-HTTP rehearsal +against Auth/Firestore/Functions emulators. It is not a rendered-browser pilot +or token-signature/App Check proof; hosted staging remains required. +A separate test-only browser entry now joins the actual member workspace and +client to those local emulators, with in-memory Auth and disposable fixtures. +Save/reload, suggestions, block/undo, withdrawal/reload and nonmember denial +worked; the production route and normal whole-app sign-in remain unexercised. + - Publish public club content, events, and merchandise. - Support anonymous and signed-in race registration. - Apply member pricing only when the server verifies a current member role. @@ -799,7 +822,7 @@ The CommonJS module creates an account-independent revision-1 snapshot, records This contract does not verify a person, payment, plan, evidence item, refund, dispute, or policy decision. It does not choose calendar-year versus anniversary terms, grace, prices, plan eligibility, retention, or legacy disposition. Its identifier grammar is not a semantic privacy classifier; a future trusted server must mint opaque values and establish every referenced fact. The last-command marker prevents only an immediate changed retry; command IDs are not a durable global replay registry. #451 changes only the accepted command ordering within schema version 1: existing link-first snapshots remain valid, and because no snapshot is persisted or runtime-adopted, this correction needs no migration or backfill. Durable cross-record UID uniqueness, full command replay history, append-only audit, Firestore schema/Rules, custom claims, token refresh/revocation, runtime authorization, any future migration, and deployment are later children behind #110, #113, #114, AUTH-003/ADMIN, and the protected release work. -The module is imported by no runtime or Functions index. It reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, changes no current profile/role/claim, and cannot make #81, annual renewal, discounts, roster export, or officer membership tools available. Source tests and a merge are not Firebase deployment or live behavior proof. +The module itself reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, changes no current profile/role/claim, and is absent from the Functions index. The disabled runner-profile service factory in #689 now composes it after a bounded transactional read of `memberships/{membershipId}`; local synthetic emulator tests prove only that consumer. Approved membership population, unique association and officer operations remain unavailable under #81/#114/#115. This does not make annual renewal, discounts, roster export or officer membership tools available. Source tests and a merge are not Firebase deployment or live behavior proof. ### 8.0b Officer manual off-platform dues-evidence command — SOURCE ONLY, UNUSED diff --git a/docs/RUNNER_CONNECTIONS.md b/docs/RUNNER_CONNECTIONS.md new file mode 100644 index 00000000..033bcc0a --- /dev/null +++ b/docs/RUNNER_CONNECTIONS.md @@ -0,0 +1,499 @@ +# Runner connections — implementation record + +Status: **IN PROGRESS, NOT AVAILABLE YET**. Owner-authorized in-house community +development is tracked in [#689](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/689). +Payment handling is a separate decision. This feature does not enroll existing +members, widen officer-directory consent, or require new payment processing. + +## Current source + +`functions/runnerConnections.js` validates a small explicitly chosen runner card +and ranks a bounded window of at most 48 supplied candidates. It has no network, +database, account, deployment, or Cloud Function export. Its eligibility inputs +are assertions for a future trusted adapter, **not authentication or proof of +current membership/adult eligibility**. It is not imported by the application. + +- Easy pace is entered as whole seconds in explicit min/km or min/mile units. + One mile is exactly 1.609344 km; normalization rounds to whole seconds/km. + Canonical accepted pace is 120–1800 seconds/km; distance is 500–50000 metres. +- Up to seven non-overlapping weekly windows use Sunday=0 and minutes after + midnight in the club's America/Los_Angeles timezone. They indicate usual + availability, not attendance at any particular run or event. +- Running together requires a shared pace, distance, area, terrain, run style, + sufficient overlapping time for the minimum shared distance, and reciprocal + experience preferences. Broadening never relaxes those constraints. +- Version 1 ordinary scoring awards four points per shared goal and two for + shared experience. Optional interests add reasons, not a score penalty when + absent. Scores are internal ordering choices, not compatibility percentages. +- At most four ordinary cards plus one separately opted-in broadening card are + returned. Broadening selects an otherwise compatible person with different + experience, goals or voluntarily supplied interests. Both parties must opt in. + Stable entry-reference ordering breaks ties; an empty pool stays empty. +- Only member-chosen card fields and fixed reason codes are returned. No UID, + contact fields, private preferences, account photos, officer consent, scores, + demographic fields or authority assertions appear in the output. +- Contact and demographic fields are rejected rather than silently accepted. + Optional field-permissioned demographic matching remains a later requirement; + no attributes are inferred from names, pictures or outside data. + +The area, goal, interest, bounds and weight choices are reversible engineering +defaults for synthetic evaluation. They are not approved club policy. + +### Private profile persistence — source and emulator only + +`functions/runnerConnectionProfiles.js` adds a server-only storage primitive, +not a callable endpoint. It is not imported by `functions/index.js` or the app. +Every operation requires an injected trusted authorization callback; there is +no default grant. The callback runs on every transaction attempt, including +idempotent retries. The storage-only tests use fake admission; the service tests +below instead use local Auth and canonical membership records. + +- Dave selected **member-confirmed 18+** on September 27. A save requires + `adultConfirmed: true` and `consentVersion: 1`. This is self-attestation, not + verified age or identity. No date of birth is accepted or stored. The future + form must present an unchecked-by-default affirmation. Current membership + must still be checked separately on the server. +- Missing profiles default to private without creating a record. A separate + `runnerConnectionProfiles/{uid}` document stores only the selected card, + consent and revision metadata. No existing member or officer-directory + record is copied, changed or automatically enrolled. +- A UUID command and expected revision make each save/withdrawal retry-safe. + Profile changes and a minimal `auditEvents` receipt commit together. A + reused command with changed content or stale revision is rejected. An + uncertain reply can be recovered with the identical command. +- Withdrawal removes the card and clears discovery, matching, broadening and + adult affirmation. A revision tombstone prevents a stale save from restoring + it. A new save requires the current revision and fresh explicit affirmation. + Receipt/tombstone retention still requires owner approval before live use; + this implementation does not choose a retention schedule. +- Malformed stored data, clock rollback and provider failure fail closed with + fixed messages. No request, profile or provider error is logged. The private + receipt contains operation metadata and a fingerprint, not card fields. +- Explicit Rules deny every browser read/write, including owner/admin access, + nested records, queries and collection-group queries. Admin SDK access still + requires the separate trusted authorization boundary; Rules do not police it. + +These are additive, unused source paths. No backfill, data migration, existing +client contract change, new dependency or Cloud Function export is introduced. + +### Profile callable integration — disabled source, local emulator only + +`functions/runnerConnectionService.js` connects the stores to six callable +handlers: read/save/withdraw one's own card, get recommendations, and read/change +one's own exclusion for a previously selected reference. Its factory defaults +disabled and is not imported by the deployment index. Request data and environment +variables cannot enable it. Each handler uses native App Check, zero reserved +instances, a two-instance maximum, 256 MB memory and a 30-second timeout. These +source settings are not provider configuration or a bill ceiling. + +- The callable runtime supplies verified identity/App Check context. A fresh + Auth Admin lookup on every transaction attempt then requires the same UID, + a verified, enabled account and a session not older than the revocation time. + A never-revoked account's absent timestamp has the installed SDK's meaning + of no revocation; malformed present timestamps deny access. Provider failures + return fixed unavailability, without raw errors or profile data. +- Saves query at most two `memberships` records by explicit `association.uid`. + Exactly one must have a document ID matching its stable `membershipId` and + satisfy the existing `deriveMembershipEntitlement` contract at the server + time after the query returns. Missing, ambiguous, malformed, pending, expired, + future, suspended or ended membership denies saving, including exact retries. + Browser/profile roles, an admin claim and email matches are never fallbacks. +- `memberships/{membershipId}` is an **additive consumer schema**, containing + the existing version-1 authority snapshot. This feature writes no membership, + term, payment, evidence, association or claim. Approved population, durable + UID uniqueness and membership operations remain #81/#114/#115 dependencies. + Do not manually seed real membership records to make this feature work. + Explicit Rules deny browser access to this collection and its descendants. +- A current verified, non-revoked account may read or withdraw only its own + card after membership expires. This grants no access to other cards or + recommendations and cannot republish a profile. Deleted, disabled, revoked + or unverified accounts need account recovery/private support before removal. +- Closed requests are snapshotted before asynchronous work. Per-account limits + allow 30 reads, 12 saves and 12 withdrawals per hour. Every attempt, including + an exact retry, counts. Withdrawal has a separate bucket; exhausting saves + cannot exhaust it. Limits reuse private `ratelimits` storage and its pending + TTL setup; keys are linkable pseudonyms, not anonymous data. These limits + bound this service's work, not all invocation or provider charges. +- Every response is private/no-store. Only the saved owner's closed profile + projection is returned; no membership, payment, Auth or roster record travels + to the browser. + +The 34 service emulator cases use real local Auth/Firestore and invoke the +installed SDK's callable callback with synthetic context. They prove the +implemented admission, persistence and metering behavior, **not** HTTP token +verification or hosted App Check enforcement. Seven additional unit checks +cover the disabled gate, real SDK runtime configuration and request snapshots. +Independent read-only review found no actionable service/Rules/CI defect and +separately passed the seven unit and eleven CI contract checks. Auth freshness +is a point-in-time server read, not an atomic lock spanning Auth and Firestore; +an account change after that read cannot retroactively recall a completed save. +The recommendation delivery boundary below has the same point-in-time limitation. + +### Bounded recommendations and privacy controls — disabled source only + +`runnerConnectionRecommendations.js` adds server-selected daily windows and +requester-scoped hide/block controls. It is not a browser roster or a deployed +endpoint. All six handlers stay disabled and absent from the deployment index. + +- A profile save atomically adds/removes `runnerConnectionEntries/{hashedUid}` + alongside the profile and audit. Entries only locate candidates; they cannot + authorize a card. A deterministic per-viewer, per-UTC-day pivot selects at + most 48 entries with two bounded document-ID queries. No composite index, + browser cursor, search query or caller-selected pool size is introduced. +- A window holds at most four ordinary and one mutually opted-in broadening + suggestion. `runnerConnectionWindows/{uid}` stores only those private UID + references and version/time metadata, never cached cards. Refresh, profile + edits, hides and blocks cannot fill vacated slots with new people that day. + Sparse/empty results remain honest. The UI must say no matches in today's + selection, not no compatible club members: the sample is not exhaustive. + The next UTC day permits a new window. + A deterministic daily audit prevents silent rebuilding if a cache is lost. +- After selection, a fresh transaction checks the actor's current Auth and + canonical membership, profiles/consent, both directed exclusions, and each + candidate's enabled/verified account and current canonical membership. + Candidate accounts have no calling session to authenticate; caller session + revocation remains mandatory. Fresh feasibility/consent checks run again. + The actor is checked once more after candidate work. Removed or ineligible + cards are omitted, not replaced. Unknown provider failure returns fixed + unavailability, never partial results or a raw provider message. +- A requester-scoped `seen` receipt is created for each selected reference; + this means selected, not proof a response was delivered or viewed. It permits + privacy changes later, without exposing another UID. A closed lookup returns + only the requester's current exclusion flags/revision for that reference. + `runnerConnectionExclusions/{hashedPair}` records directed hide/block state. + Hide affects only one's own results; either person's block prevents the pair + in both directions. Undo changes only the requester's own choice. +- Exclusion commands require a UUID and current revision, and commit with a + minimal audit. Identical retries do not duplicate changes; changed/stale + commands conflict. A known reference can still be hidden/blocked after term + expiry or target withdrawal, with a current verified/non-revoked account. + No endpoint lists hidden people or arbitrary references; release work must + define an approved, bounded recovery/manage-controls experience. +- Limits are separate: six recommendation requests, 30 exclusion lookups and + 30 exclusion changes per account/hour. Each attempt counts. Recommendation + exhaustion cannot consume withdrawal or exclusion budgets. These bounds and + the daily cadence are reversible engineering defaults, not a bill ceiling, + a club retention policy, or a guarantee against multi-account abuse. +- Entries, windows (including nested receipts) and exclusions deny every + browser read/write, query and collection-group query, including admins. + Pair/reference hashes are linkable pseudonyms, not anonymous or secret data. + Receipts/exclusions/audits require approved retention and account-deletion + handling before live use; this change sets no retention period. + +Migration impact: additive unused collections only. No real records are copied +or backfilled. An older profile with no locator remains undiscoverable until a +fresh explicit save; replay of an old command does not silently enroll it. +Withdrawal clears the locator atomically but retains private anti-replay and +privacy-control records pending approved retention. No dependencies changed. + +Freshness proof is limited: emulator tests mutate consent, membership, account +state and blocks **between selection and final delivery** and require withholding. +Firestore provides a consistent transaction snapshot, not an atomic lock across +Auth, Firestore and network delivery. A change after the final read cannot recall +already sent data. These tests do not prove live middleware or provider settings. + +### Member interface — disabled source, synthetic tests only + +`/account/running-partners` and its My Account link are controlled by the literal +`RUNNER_CONNECTIONS_AVAILABLE = false`. No query, environment or stored browser +value enables them. A direct visit currently shows an unavailable notice and +public-run/contact links without starting runner-profile work. Backend exports +remain absent; the existing two-profile-Function release scope is unchanged. + +- The form has explicit pace units, bounded distance and usual Pacific-time + windows, coarse running areas and optional interests. New profiles start + with unchecked 18+, discovery, similarity and broadening choices. The initial + Saturday window is marked for review, not an attendance claim. Unit changes + clear both pace inputs rather than reinterpreting them. +- A valid exact-card preview is required before saving. Preview and suggestions + use the same renderer; private experience preferences are omitted. Editing + invalidates the preview. Turning discovery off clears both recommendation + permissions; turning it back on does not silently restore them. Withdrawal + clears all card, sharing and age state. No DOB or verified-age claim is used. +- The client snapshots closed requests and validates closed responses from the + six scoped callables. Mutation replies must match the intended revision and + content before success appears. Unknown outcomes retain the identical UUID, + revision and payload for retry while other edits are disabled; known rejection + requires reloading current state. Raw provider errors are never displayed. +- UI state is keyed to the UID and Firebase app object. The client checks the + current UID before sending and after awaiting. This prevents stale display + across accounts; it is not the trusted authorization boundary. No card is + persisted to browser storage or sent to logs/analytics by this feature. +- Suggestions load only on request. Empty copy describes today's limited + selection, not the whole club. Unavailable, blurred/hidden or expired results + are cleared, including an in-flight response after blur. Hide/block first read + the requester's current exclusion revision and preserve the other flag. + Changes clear suggestions without requesting replacement people. +- Up to five privacy choices from the current visit have own-choice undo. + This is **not** persistent history management: leaving/reloading the page + loses that display. A bounded way to recover/manage past choices is still a + release gate. Private introductions, messaging and contact disclosure are absent. + +Migration impact: additive inactive route/client only; no backfill, deployment, +membership writer, new dependency, lockfile or generated sitemap change. UI +checks are defense in depth; all membership, consent and disclosure authority +stays on the server. Public club information remains available without a profile. + +## Integration still required before the first slice is complete + +1. Establish and independently verify approved canonical membership population, + unique associations and operator procedures before enabling the consumer. +2. Rehearse callable HTTP Auth/App Check enforcement and account-loss recovery; + the tested callbacks are not proof that a hosted endpoint exists. +3. Approve privacy wording, retention/account-deletion handling, and a bounded + member-facing way to find and manage one's existing hide/block choices. +4. Rehearse the rendered browser and real callable transport together in isolated + staging; local SDK HTTP tests and a fake-client browser demo are not hosted + end-to-end acceptance. +5. Keep the implemented member interface disabled until backend-first deployment, + persistent privacy-choice management and privacy/release review pass. +6. Complete full validation, independent review, officer handoff, a synthetic + staging rehearsal and separate exact website/Firebase/provider verification. + +```mermaid +flowchart LR + Fixture["Synthetic profile and eligibility assertions"] --> Core["Implemented bounded validator and ranker"] + Core --> Cards["Synthetic card results only"] + Commands["Synthetic callable context"] --> Service["Six-handler callable factory: production disabled"] + Service --> Auth["Local Auth: current account and revocation"] + Service --> Membership["Local Firestore: bounded canonical membership read"] + Auth --> Store["Implemented transaction storage primitive"] + Membership --> Store + Store --> Private["Local emulator: private profiles, locators and minimal audit receipts"] + Service --> Window["At most 48 locators; daily window of at most five references"] + Window --> Recheck["Fresh account, membership, consent and directed-block checks"] + Recheck --> Core + Service --> Controls["Own scoped exclusion lookup/change and audit"] + Controls --> Recheck + UI["Implemented member form, preview and privacy UI: source gate OFF"] -.-> Client["Closed client contract: six scoped callables"] + Client -.-> Service + UI --> Demo["Synthetic browser demo: fake in-memory client only"] + HTTP["Actual client modules and Web SDK under Node"] --> Emulators["Loopback Functions HTTP emulator: test-only exports"] + ConnectedBrowser["Actual member UI and client in disposable local browser rehearsal"] --> Emulators + Emulators --> Service + Public["Public club run information: no runner profile needed"] +``` + +Text alternative: synthetic callbacks exercise current account/membership checks +and private profile storage, bounded recommendation windows and fresh privacy +checks in local emulators; a separate Node test drives the actual client through +local HTTP, while a separate disposable browser rehearsal joins the actual member +UI and client to those emulators. Public run information needs no runner profile +and no runner service is live; the production route stays disabled. + +## Evidence boundary + +The initial 50 synthetic unit tests cover validation, unit conversion, hard +compatibility, consent, eligibility assertions, exclusions, broadening, sparse +results, bounded input and deterministic output. Current local Node 20/Java 21 +checks pass 35 persistence, 34 profile-service and 38 recommendation-emulator +cases, 468 Rules cases, +and 134 workflow/release/security checks. Functions lint passes. The ordinary +Functions checkpoint passed 7,629 cases with 133 emulator-only skips; the current +Auth/Firestore run passes 7,737 with 63 separately opted-in commerce-journal +cases skipped. Skipped tests are not counted as passes. CI requires both Auth +and Firestore for all three runner emulator suites, separately from unit tests. + +The preceding profile-service checkpoint's exact CI run 36309661314 passed all +five jobs, including the actually executed emulator and build/artifact steps. +The recommendation checkpoint `e57ad41` passed all five jobs in CI run +36310998065; 468 Rules cases, all 107 runner emulator cases and build/artifact +verification actually executed. Those runs do not prove later revisions; use +the PR's exact-head checks for the new frontend increment. + +The focused storage review found no actionable defect. Its suggested concurrent +save/withdrawal and forced authorization-loss-on-retry cases now pass. A +withdrawal that loses a revision race reports a conflict, not success; the +caller must refresh and submit a new withdrawal. No accepted withdrawal is +undone by a stale command. This is still injected test admission, not a proven +live revocation policy. Review does not complete the outstanding integration. + +Independent read-only review of the recommendation increment found no actionable +defect, and independently passed seven service-unit and eleven CI-contract tests. +Its residuals are recorded above: limited daily sampling, point-in-time Auth +freshness, then-missing member UI/retention procedures and unproven hosted enforcement. +The main-task emulator run also proves exclusion audit rollback, exact recovery +after a lost reply, authorization loss on transaction retry, and midnight retry. +These results do **not** prove deployed authorization, verified age, +provider billing limits or real connections. The issue and draft PR +remain open until integrated acceptance cases pass. No live member data is +needed for development. + +The September 27 frontend checkpoint passes 38 focused synthetic cases and the +full 1,384-case frontend suite. Client/server parity tests compare every integer +pace input from 120 through 3,000 seconds in both units with the actual pure server +validator, including rejected inputs, and compare the exact public-card projection. +Tests also cover invalid windows, midnight, unchecked age/consent, exact preview, +withdrawal, same-command retry, account/app changes and stale-result clearing. +Independent read-only UI review found no actionable defect; it did not rerun tests +or inspect a live integration. The owner-run browser check on September 27 used +only an explicitly labeled, disposable in-memory fake client. At desktop and +390×844 phone width, preview/save, suggestion display, block/own undo and withdrawal +behaved as expected; phone width had no horizontal overflow and captured console +warnings/errors were empty. No screenshot artifact is retained. This is layout +and UI-transition evidence, not real persistence, App Check or hosted authorization. +The temporary browser and local server were closed afterward. TypeScript, exact +lint-baseline and optimized artifact checks are recorded with the PR's head evidence. + +### Real client-to-emulator HTTP transport checkpoint + +The separate `tests/runner-transport.test.cjs` loads the actual three client +TypeScript modules in memory and uses the installed Firebase Web SDK under Node. +It calls the actual six callable handlers through the Functions HTTP emulator, +with local Auth and Firestore, not `.run()` or a fake callable client. Eight +sequential cases cover saving, recovery after deliberately losing a committed +HTTP reply, exact command replay, missing App Check/signed-out denial, missing +canonical membership despite an admin claim, invalid requests, stale revisions, +closed/no-store replies, recommendations, mutual blocking, own undo, candidate +withdrawal, current account loss and owner withdrawal after membership expiry. + +Run it with Node 22, Java 21 and the existing root/Functions lockfile installs: + +```sh +node scripts/run-runner-transport-tests.cjs +``` + +The launcher fixes `demo-runner-http-test`, loopback hosts and a separate +`runner-transport.firebase.json`; no existing production config/index is changed. +Its nested package matches the Node 22 runtime and SDK versions already declared +and locked by `functions/`; do not install a separate nested dependency tree. Its test-only +entry point refuses a different project/host or a non-emulator runtime. The +launcher passes only an allowlisted environment and a new temporary CLI config. +A test-only CLI adapter disables actual Google credential discovery/export; +an empty config alone did not stop ADC discovery. Admin uses an ephemeral +synthetic certificate kept in memory, never a real credential file. The existing +Functions network guard allows only configured loopback endpoints for the test +client and service. The CLI's unused local Eventarc URL is checked exactly and +removed before that guard; production test-safety rules are not weakened. + +Only this run's randomized synthetic accounts and exact associated documents +are created/deleted. Teardown removes its profiles, memberships, locators, +windows/receipts, directed exclusions, audit records and limits, and stops the +emulators. The disposable CLI configuration is removed. No real records, +membership approvals, live provider settings or retention decisions are created. +Twenty harness checks cover environment rejection, credential-free startup, +exact CLI arguments, diagnostic containment, interruption/cleanup and shared +SDK declarations. CI requires the exact transport launcher as a five-minute +step in the existing Rules job; skip/omission/command weakening is rejected. + +**Important limit:** the installed Firebase CLI enables token-verification +bypass in its Functions emulator. These cases prove local request transport, +server account/membership reads and persistence behavior, **not cryptographic +ID-token or App Check verification**, real attestation, hosted IAM, replay +protection, browser CORS enforcement, or a connected rendered-browser pilot. +Synthetic App Check tokens work only in that local decoder. Native App Check +enforcement in the service source remains enabled; production feature gates and +deployment exports remain unchanged. Hosted staging proof is still required. + +Local Node 20/Java 21 verification passes all eight HTTP cases with zero users +and root collections after teardown, all 20 harness checks, full Functions with +Auth/Firestore (7,757 passed; 63 separately opted-in commerce cases skipped), +135 workflow/release/security checks, Functions lint and whitespace checks. +Independent read-only transport review found no actionable defect; it did not +run the emulators. No runtime client/server, Rules, production config, lockfile +or generated sitemap changed. Fresh production-dependency audits still report +the existing root five findings (two high) and Functions twelve (one high, +`sharp`); this test-only SDK manifest reuses the parent installation, not an +upgrade or a new dependency resolution. Those advisories remain release work. + +With Node 20, Java 21 and committed lockfile installs, run the focused storage +check with: + +```sh +REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR=1 REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR=1 REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR=1 npx --no-install firebase emulators:exec --project demo-functions-test --only firestore,auth "npm --prefix functions run test:run -- --runInBand runnerConnectionProfiles.emulator.test.js runnerConnectionService.emulator.test.js runnerConnectionRecommendations.emulator.test.js" +npm run test:rules +``` + +Platform references: [native callable App Check](https://firebase.google.com/docs/app-check/cloud-functions) +and [Auth session revocation](https://firebase.google.com/docs/auth/admin/manage-sessions). +The installed Admin SDK's `verifyDecodedJWTNotRevokedOrDisabled` also confirms +the absent revocation-timestamp behavior. These explain implementation choices; +they do not verify an MPRC deployment. + +The separate September 27 backend preflight found staging billing disabled and +zero billing accounts accessible to the authorized club account. The existing +profile deployment guard passes, but Functions deployment and protected release +gates remain unresolved. This feature does not bypass them. + +### Connected local browser rehearsal — not a hosted pilot + +`node scripts/run-runner-transport-tests.cjs --browser` starts one fixed alternate +test command with the same demo project, isolated CLI environment and loopback +emulators. The argument-free command remains the mandatory eight-case CI suite; +an inherited browser-mode variable cannot switch it. No arbitrary command, +project or port argument is accepted. Use Node 22/Java 21 and existing lockfile +installs. Do not run two transport modes concurrently. + +The test-only server builds `tests/runner-browser/entry.tsx` in memory using the +existing locked build tools. It imports the actual `RunnerWorkspace` and actual +callable client without changing the production source gate. Auth persistence +is in memory. Three randomized accounts at `example.test` are created only in +the empty emulators. Owner and partner receive declared synthetic membership +fixtures; the partner starts with a declared fixture card. The outsider has no +membership. These are not approved records or membership administration tools. + +Open the printed literal `http://127.0.0.1:9619` URL. The page is prominently +marked synthetic. It serves only three fixed assets and a same-origin POST +cleanup action; exact Host/Origin checks reject alternate hosts and cross-site +actions. CSP restricts scripts to that origin and connections to the loopback +Auth/Functions endpoints. Responses are no-store, nosniff and no-referrer; the +server accepts no arbitrary file, target UID or Admin command. No artifact or +credential is written to disk. The normal **End rehearsal and clean up** action +signs out, closes the client, deletes only this run's synthetic IDs, verifies +zero Auth users/root collections, and ends the emulators. A 30-minute timeout +and signals also request shutdown; forced interruption is not independently +proven to drain every emulator process and is not the normal success path. + +September 27 actual browser evidence: the new profile started with unchecked +age/sharing choices; preview/save succeeded; a full reload plus fresh local sign-in +recovered the saved card; recommendations displayed the synthetic partner. +At 390×844, blocking withheld the card with an honest empty result and own undo +restored the same partner. Withdrawal cleared the card and age/sharing choices, +including after another full reload. Switching to the outsider and submitting a +valid card was denied by the server and displayed unavailable, not success. +No horizontal overflow was found at phone width. Captured warning/error logs +were empty; a phone screenshot was inspected but no screenshot artifact retained. +Normal cleanup reported zero users and root collections and all emulators stopped. + +This proves rendered form/client/local-server integration and the exercised +browser-to-loopback cross-origin calls. It does **not** prove the production route, +whole-app sign-in/service-locator integration, hosted CORS/identity/App Check, +cryptographic token signatures, real eligibility, billing limits or a released +feature. The harness selects synthetic accounts outside the app's normal login +route. Hosted staging and all existing membership/privacy/release gates remain. + +Local verification for this increment: 36 harness checks; 7,665 ordinary Functions +tests passed with 171 emulator-only cases explicitly skipped; the eight actual +HTTP cases reran with zero skips; 98 workflow/release/staging/dependency contract +checks passed. The test entry's TypeScript check, Functions lint, syntax and +whitespace checks passed. Independent read-only review of code, lifecycle and +documentation found no actionable issue and did not run tests. The final lifecycle +version was separately started, read from the actual browser and normally stopped +with zero users/root collections verified. Browser interaction remains a manual +rehearsal, not an automated CI browser test. No application runtime, Rules, +production config, CI permissions, package/lockfile or generated sitemap changed. + + +### September 28 integration with the Node 22 baseline + +The runner branch incorporates the merged #692 runtime prerequisite while +retaining the runner privacy/security record. The test-only transport manifest +now matches the parent Node 22 engine; a regression check rejects runtime drift. +Runner workflow mutation tests target their own job so the added Node 22 job +cannot receive a mutation intended to test the runner gate. All six baseline +CI jobs and both protected-release checks remain required. + +Local validation of the combined source: Node 20.19.5 and Node 22.23.3 each pass +7,669 ordinary Functions tests, with 171 emulator-only cases explicitly skipped. +The isolated Node 22/Java 21 transport run passes all eight HTTP cases with zero +skips and shuts down its emulators. All 140 workflow/release/staging/dependency +contract checks, Functions lint, exact frontend lint inventory and whitespace +checks pass. The transport runtime-parity regression failed before the manifest +fix; the runner-job mutation test failed before its target was scoped correctly. +No lint debt or security gate was relaxed. + +Use the updated PR head's CI result for hosted test evidence. These local checks +do not establish a hosted service, normal whole-app sign-in or cryptographic +Auth/App Check enforcement. The production feature gate remains off and the +six runner handlers remain outside the deployment index. Membership, persistent +privacy-choice management, retention and release prerequisites still apply. diff --git a/docs/officers/README.md b/docs/officers/README.md index 3fc272e3..c9e3fd9c 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -40,6 +40,7 @@ In words: approve the merge, request one exact release, and approve its protecte 7. [Simple system maps](./SYSTEM_MAPS.md) 8. [Plain-language glossary](./GLOSSARY.md) 9. [Low-cost community scope and release review](./LOW_COST_COMMUNITY.md) +10. [Runner connections — not available yet](./RUNNER_CONNECTIONS.md) ## Five rules diff --git a/docs/officers/RUNNER_CONNECTIONS.md b/docs/officers/RUNNER_CONNECTIONS.md new file mode 100644 index 00000000..1aed53a7 --- /dev/null +++ b/docs/officers/RUNNER_CONNECTIONS.md @@ -0,0 +1,129 @@ +# Finding running partners — NOT AVAILABLE YET + +**Purpose:** explain the proposed opt-in member experience and how to review its +development without exposing member records. + +**Approver:** Dave Liu for source development. A membership lead and the club's +privacy/security owner must approve the consent wording and live pilot. Dave +selected member-confirmed 18+ on September 27; this is not verified age. + +**Prerequisites:** [issue #689](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/689), +a reviewed implementation, synthetic testing, and the existing protected backend +release. A specialist is still required for implementation and release. + +## What works now + +An internal matching calculation, private storage and profile service pass tests +with made-up runners and accounts in local test systems. The service checks +current accounts and membership terms; a member/admin role alone does not grant +access. The disabled recommendation service also has local tests for daily +suggestions, hide/block controls and privacy checks before returning a card. +The member form, preview, suggestions and privacy controls now also pass tests +with made-up data. The page remains disabled and no card is published. +The runner service has not been released to members. An automatic pull-request +preview is not a member release or a connected backend test. +The officer lookup setting does not authorize member discovery. + +The tested form asks members to confirm they are 18 or older. It does not +collect a birth date or claim to verify age or identity. For a new profile the +box starts unchecked, as do all sharing choices. Withdrawal clears the +confirmation, so joining again requires a new confirmation. Club membership is +checked separately by the tested server code. +Approved membership records and officer procedures are still a release +prerequisite. Do not enter records directly to bypass that work. + +The tested service allows a person whose membership expired to read or withdraw +only their own card while their verified account remains usable. Saving requires +current membership. Disabled, deleted, revoked or unverified accounts need +account recovery or private support. Separate limits preserve withdrawal when +the save limit has been reached. These are source behaviors, not live tools. + +The tested recommendation service offers at most four ordinary suggestions and +one separately opted-in broadening suggestion per day. Refreshing or hiding +cards does not reveal more people that day. Hiding affects only your results; +blocking prevents the pair from appearing to either person. Undo changes only +your own choice. Suggestions are checked again before they are returned, but +information already delivered cannot be taken back. The tested interface allows +undo for choices made during the current visit only. It still needs a clear way +to recover and review choices from earlier visits. Retention and account +deletion procedures must be approved before a pilot; no retention period has +been selected by this code. + +The form shows the exact card before saving. Changing card details removes the +old preview. Turning discovery off clears both suggestion choices; turning it +back on does not recheck them. If a save reply is uncertain, the form stops other +edits and offers **Retry the same change** instead of claiming success. Switching +accounts clears the old page state. Suggestions clear when the page loses focus, +when today's window ends, or when a new request fails. These safeguards do not +prove hosted access is ready. Public club-run information needs no runner profile. + +The September 27 phone and desktop demonstration used a disposable, made-up +in-memory service. It did not connect to Firebase or create accounts. The phone +layout had no horizontal overflow; preview, save, block, own-choice undo and +withdrawal screens worked. No screenshot artifact was retained. + +A separate local test now sends the actual client requests through the local +backend, using only made-up accounts. It proves saves, suggestions, blocking, +undo, withdrawal, membership/account checks and recovery after a lost save reply. +This is stronger than the fake-service demonstration, but still not a connected +browser pilot. The local test system does not verify Google's token signatures; +the hosted identity and app-protection checks still need separate proof. The +test does not use an officer's credentials or change billing. + +A later September 27 demonstration connected the actual form and client to the +local backend. A saved made-up card survived a page reload and fresh sign-in. +Phone checks proved suggestions, blocking, own undo and withdrawal, with no +horizontal overflow. Withdrawal stayed in effect after a reload; an account +without a made-up membership could not save. The test accounts and records were +removed afterward. This uses a special test account selector, not the website's +normal sign-in route. It still does not prove hosted access or a live pilot. + +The September 28 branch update incorporates the Node 22 backend maintenance +without enabling runner connections. Both runtime compatibility checks and the +runner privacy tests remain required. This does not change the pilot prerequisites +or the two-profile-operation scope of the first backend release. + +## Review steps + +1. Ask the platform owner for the issue's current source and test record. +2. Confirm any demonstration labels all made-up runners as synthetic. +3. Check that normal run information stays public without a runner profile. +4. Before a pilot, request proof of explicit opt-in, withdrawal, blocking, + current member access, the unchecked 18+ confirmation and private-data protection. +5. Request a demonstration that hiding, blocking, withdrawal and membership loss + remove suggestions without filling the space with new people that day. +6. Check that the card preview excludes contact details and private preferences. +7. Ask to see an uncertain save and its same-request retry, using made-up data. +8. Before a pilot, request a working way to recover privacy choices after leaving + and returning to the page. The current visit-only undo is insufficient. +9. Request separate website and Firebase deployment evidence before announcing + the feature. A calculation test or green build is not proof of availability. +10. Ask which checks used the local test system and which proved the hosted + service. Do not accept local token tests as proof of hosted identity checks. +11. Ask for the connected local demonstration's cleanup record. It must show + zero remaining test users and collections, with the local services stopped. + +**Expected result:** officers can distinguish source development from a usable +member service. No officer collects profiles or changes a roster during review. + +**Stop conditions:** a demonstration uses real member records, officer-only +consent is reused, results include private contacts, unavailable saves look +successful, an age-confirmation box is preselected, confirmation is described +as verified age, a minor is included, or anyone claims the code is a live service. + +**Success proof:** for now, the recorded synthetic calculation, local account, +membership, storage, bounded recommendations, privacy-control, request-limit and +browser-denial tests, plus separate form/client tests and a fake-service browser +demonstration, plus actual client-to-local-backend request tests. Connected +rendered-browser-to-local-backend checks also pass. Rendered-browser-to-hosted-server +checks, the website's normal sign-in integration, persistent privacy-choice +management, hosted authentication checks and a live pilot remain outstanding. +A specialist is still needed to arrange a demonstration and every release step; +backup officers can review the evidence above without a terminal. + +**Undo:** keep the capability unavailable and request a reviewed code revert if +needed. Do not delete member records, grant roles or change billing as a repair. + +**Escalation:** platform owner for failures; membership and privacy/security +owners for eligibility, consent or a private report. Use the private incident +channel for sensitive reports, not a public issue or screenshot. diff --git a/firestore.rules b/firestore.rules index bfe960a4..95640ac2 100644 --- a/firestore.rules +++ b/firestore.rules @@ -499,6 +499,39 @@ service cloud.firestore { } } + // Canonical membership authority is consumed only by trusted server code. + // Roles and browser profile mirrors are not permission to read or edit it. + match /memberships/{membershipId} { + allow read, write: if false; + + match /{nestedDocument=**} { + allow read, write: if false; + } + } + + // Member-discovery cards are not the officer directory or a browser-readable + // roster. Even the owner/admin must use the separately authorized server + // boundary for consent, revisions, withdrawal and private projections. + match /runnerConnectionProfiles/{uid} { + allow read, write: if false; + + match /{nestedDocument=**} { + allow read, write: if false; + } + } + + // Candidate locators, daily windows/receipts and directed exclusions are + // server-only. Hashing identifiers does not make these anonymous records. + match /runnerConnectionEntries/{document=**} { + allow read, write: if false; + } + match /runnerConnectionWindows/{document=**} { + allow read, write: if false; + } + match /runnerConnectionExclusions/{document=**} { + allow read, write: if false; + } + match /auditEvents/{auditEventId} { allow read, write: if false; } diff --git a/functions/runnerConnectionProfiles.emulator.test.js b/functions/runnerConnectionProfiles.emulator.test.js new file mode 100644 index 00000000..1e82db76 --- /dev/null +++ b/functions/runnerConnectionProfiles.emulator.test.js @@ -0,0 +1,314 @@ +'use strict'; + +const admin = require('firebase-admin'); +const { createRunnerProfileStore, PROFILE_COLLECTION } = require('./runnerConnectionProfiles'); +const { ENTRY_COLLECTION, runnerEntryId } = require('./runnerConnectionReferences'); +const { profile } = require('./testSupport/runnerConnectionsFixtures'); + +// Firestore's transaction-conflict backoff can exceed Jest's five-second unit +// default. Keep the one-winner/idempotency assertions unchanged. +jest.setTimeout(30000); + +if (process.env.REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR === '1' + && (!/^127\.0\.0\.1:\d+$/.test(process.env.FIRESTORE_EMULATOR_HOST || '') + || process.env.GCLOUD_PROJECT !== 'demo-functions-test')) { + throw new Error('Runner profile tests require the isolated demo emulator.'); +} + +const describeEmulator = process.env.FIRESTORE_EMULATOR_HOST ? describe : describe.skip; +const UID = 'synthetic-connection-owner'; +const OTHER = 'synthetic-other-owner'; +const INITIAL_TIME = 1800000000000; +const consent = { memberDiscovery: true, similarity: true, broadenCircle: false }; +const requestId = (index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`; +const save = (index, expectedRevision = 0, overrides = {}) => ({ + requestId: requestId(index), expectedRevision, profile: profile(), consent, + consentVersion: 1, adultConfirmed: true, ...overrides, +}); + +describeEmulator('runner profile persistence against local Firestore', () => { + let app; + let db; + let store; + let clock; + let authorize; + let denyAdmission; + + beforeAll(() => { + app = admin.initializeApp({ projectId: 'demo-functions-test' }, 'synthetic-runner-profiles'); + db = app.firestore(); + }); + + beforeEach(async () => { + // Only this suite's known synthetic roots are touched; no real project IDs. + for (const uid of [UID, OTHER]) { + await db.collection(PROFILE_COLLECTION).doc(uid).delete(); + await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(uid)).delete(); + } + const audits = await db.collection('auditEvents').where('actorUid', '==', UID).get(); + await Promise.all(audits.docs.map((doc) => doc.ref.delete())); + clock = INITIAL_TIME; + denyAdmission = false; + authorize = jest.fn(async ({ uid, operation }) => !denyAdmission && uid === UID + && ['read', 'save', 'withdraw'].includes(operation)); + store = createRunnerProfileStore({ db, authorize, now: () => clock }); + }); + + afterAll(async () => { + for (const uid of [UID, OTHER]) { + await db.collection(PROFILE_COLLECTION).doc(uid).delete(); + await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(uid)).delete(); + } + const audits = await db.collection('auditEvents').where('actorUid', '==', UID).get(); + await Promise.all(audits.docs.map((doc) => doc.ref.delete())); + await app.delete(); + }); + + async function auditCount() { + return (await db.collection('auditEvents').where('actorUid', '==', UID).get()).size; + } + + test('has no implicit authorization policy', () => { + expect(() => createRunnerProfileStore({ db })).toThrow('Runner connection service is unavailable.'); + }); + + test('missing profile defaults to private without creating a document', async () => { + await expect(store.readOwn(UID)).resolves.toEqual({ + schemaVersion: 1, consentVersion: 1, adultConfirmed: false, revision: 0, profile: null, + consent: { memberDiscovery: false, similarity: false, broadenCircle: false }, + }); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).exists).toBe(false); + expect(await auditCount()).toBe(0); + }); + + test('persists a separate canonical card, preserves units and never modifies the member roster', async () => { + const rosterRef = db.collection('members').doc(UID); + await rosterRef.set({ marker: 'synthetic-unchanged-roster', role: 'unverified' }); + const original = await rosterRef.get(); + try { + const input = save(1, 0, { profile: profile({ displayName: ' Synthetic Alias ', pace: { + unit: 'min/mile', fastSeconds: 483, slowSeconds: 579, + } }) }); + const result = await store.saveOwn(UID, input); + expect(result.revision).toBe(1); + expect(result.profile.displayName).toBe('Synthetic Alias'); + expect(result.profile.pace).toEqual(input.profile.pace); + expect(result.consent).toEqual(consent); + expect(await store.readOwn(UID)).toEqual(result); + expect((await rosterRef.get()).data()).toEqual(original.data()); + expect(await auditCount()).toBe(1); + const audits = await db.collection('auditEvents').where('actorUid', '==', UID).get(); + expect(JSON.stringify(audits.docs.map((doc) => doc.data()))) + .not.toMatch(/Synthetic Alias|fastSeconds|first_10k|coffee/); + } finally { + await rosterRef.delete(); + } + }); + + test('same-command parallel requests apply once, including audit', async () => { + const command = save(2); + const results = await Promise.all(Array.from({ length: 8 }, () => store.saveOwn(UID, command))); + expect(results.every((result) => result.revision === 1)).toBe(true); + expect(await auditCount()).toBe(1); + clock += 1000; + expect(await store.saveOwn(UID, command)).toEqual(results[0]); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).data().updatedAtMs).toBe(INITIAL_TIME); + }); + + test('concurrent different commands cannot overwrite one another at the same revision', async () => { + const results = await Promise.allSettled([ + store.saveOwn(UID, save(3)), + store.saveOwn(UID, save(4, 0, { profile: profile({ displayName: 'Different Synthetic Alias' }) })), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.find((result) => result.status === 'rejected').reason.code).toBe('aborted'); + expect((await store.readOwn(UID)).revision).toBe(1); + expect(await auditCount()).toBe(1); + }); + + test('a changed command payload cannot reuse the same request ID', async () => { + await store.saveOwn(UID, save(5)); + await expect(store.saveOwn(UID, save(5, 0, { profile: profile({ goals: ['social'] }) }))) + .rejects.toMatchObject({ code: 'aborted' }); + expect((await store.readOwn(UID)).profile.goals).toContain('first_10k'); + expect(await auditCount()).toBe(1); + }); + + test('withdrawal clears the card and consent; stale save/replay cannot resurrect it', async () => { + await store.saveOwn(UID, save(6)); + expect((await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(UID)).get()).data()) + .toEqual({ schemaVersion: 1, uid: UID }); + const command = { requestId: requestId(7), expectedRevision: 1 }; + const withdrawn = await store.withdrawOwn(UID, command); + expect(withdrawn).toMatchObject({ revision: 2, adultConfirmed: false, profile: null, consent: { + memberDiscovery: false, similarity: false, broadenCircle: false, + } }); + const stored = (await db.collection(PROFILE_COLLECTION).doc(UID).get()).data(); + expect(stored.discoverable).toBe(false); + expect((await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(UID)).get()).exists).toBe(false); + expect(JSON.stringify(stored)).not.toMatch(/Synthetic Runner|first_10k|coffee/); + expect(await store.withdrawOwn(UID, command)).toEqual(withdrawn); + await expect(store.saveOwn(UID, save(6))).rejects.toMatchObject({ code: 'aborted' }); + await expect(store.saveOwn(UID, save(8, 1))).rejects.toMatchObject({ code: 'aborted' }); + expect(await store.readOwn(UID)).toEqual(withdrawn); + expect(await auditCount()).toBe(2); + }); + + test('new explicit opt-in can publish after withdrawal using the current revision', async () => { + await store.withdrawOwn(UID, { requestId: requestId(9), expectedRevision: 0 }); + expect((await store.saveOwn(UID, save(10, 1))).revision).toBe(2); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).data().discoverable).toBe(true); + }); + + test('a concurrent save and withdrawal have one winner; accepted withdrawal cannot be undone by stale retry', async () => { + await store.saveOwn(UID, save(20)); + const edit = save(21, 1, { profile: profile({ goals: ['social'] }) }); + const removal = { requestId: requestId(22), expectedRevision: 1 }; + const [saved, withdrawn] = await Promise.allSettled([ + store.saveOwn(UID, edit), store.withdrawOwn(UID, removal), + ]); + expect([saved, withdrawn].filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect([saved, withdrawn].find((result) => result.status === 'rejected').reason.code).toBe('aborted'); + // If the edit won, withdrawal must report a conflict, not pretend success. + // A new withdrawal against the refreshed revision finishes that intent. + if (withdrawn.status === 'rejected') { + await store.withdrawOwn(UID, { requestId: requestId(23), expectedRevision: 2 }); + } + await expect(store.saveOwn(UID, edit)).rejects.toMatchObject({ code: 'aborted' }); + expect(await store.readOwn(UID)).toMatchObject({ + adultConfirmed: false, profile: null, consent: { + memberDiscovery: false, similarity: false, broadenCircle: false, + }, + }); + expect(await auditCount()).toBe(withdrawn.status === 'fulfilled' ? 2 : 3); + }); + + test('authorization loss on a forced transaction retry prevents both writes', async () => { + let attempt = 0; + const retryingDb = { + collection: db.collection.bind(db), + runTransaction: (work) => db.runTransaction(async (transaction) => { + attempt += 1; + const result = await work(transaction); + if (attempt === 1) { + denyAdmission = true; + // Simulate the SDK's retryable ABORTED result before its queued + // writes commit; the second attempt must run admission again. + const conflict = new Error('synthetic-transaction-conflict'); + conflict.code = 10; + throw conflict; + } + return result; + }), + }; + const retryingStore = createRunnerProfileStore({ db: retryingDb, authorize, now: () => clock }); + await expect(retryingStore.saveOwn(UID, save(24))).rejects.toMatchObject({ code: 'permission-denied' }); + expect(attempt).toBe(2); + expect(authorize).toHaveBeenCalledTimes(2); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).exists).toBe(false); + expect(await auditCount()).toBe(0); + }); + + test('a private draft never becomes discoverable', async () => { + await store.saveOwn(UID, save(11, 0, { consent: { + memberDiscovery: false, similarity: false, broadenCircle: false, + } })); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).data().discoverable).toBe(false); + expect((await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(UID)).get()).exists).toBe(false); + }); + + test.each([ + { uid: OTHER }, + { memberEligible: true }, + { adultEligible: true }, + { searchableByOfficers: true }, + { expectedRevision: -1 }, + { expectedRevision: '0' }, + { expectedRevision: Number.MAX_SAFE_INTEGER }, + { expectedRevision: Number.MAX_SAFE_INTEGER - 1 }, + { consentVersion: 0 }, + { consentVersion: '1' }, + { consentVersion: undefined }, + { adultConfirmed: false }, + { adultConfirmed: 'true' }, + { adultConfirmed: undefined }, + { requestId: 'not-a-uuid' }, + { consent: { memberDiscovery: false, similarity: true, broadenCircle: false } }, + { profile: profile({ email: 'synthetic@example.test' }) }, + ])('rejects forged or invalid request case %# before admission or writes', async (overrides) => { + await expect(store.saveOwn(UID, save(12, 0, overrides))).rejects.toMatchObject({ code: 'invalid-argument' }); + expect(authorize).not.toHaveBeenCalled(); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).exists).toBe(false); + expect(await auditCount()).toBe(0); + }); + + test('requires fresh trusted admission even for an idempotent retry', async () => { + await store.saveOwn(UID, save(13)); + denyAdmission = true; + await expect(store.saveOwn(UID, save(13))).rejects.toMatchObject({ code: 'permission-denied' }); + await expect(store.readOwn(UID)).rejects.toMatchObject({ code: 'permission-denied' }); + expect(await auditCount()).toBe(1); + }); + + test('never treats truthy nonboolean admission as authority', async () => { + authorize.mockResolvedValue('true'); + await expect(store.saveOwn(UID, save(14))).rejects.toMatchObject({ code: 'permission-denied' }); + expect(await auditCount()).toBe(0); + }); + + test('denies other subjects; a request cannot redirect the server-selected owner', async () => { + await store.saveOwn(UID, save(15)); + await expect(store.readOwn(OTHER)).rejects.toMatchObject({ code: 'permission-denied' }); + await expect(store.saveOwn(OTHER, save(16))).rejects.toMatchObject({ code: 'permission-denied' }); + expect((await db.collection(PROFILE_COLLECTION).doc(OTHER).get()).exists).toBe(false); + }); + + test('validates stored state and clock monotonicity without repairing it', async () => { + await store.saveOwn(UID, save(17)); + clock -= 1; + await expect(store.saveOwn(UID, save(18, 1))).rejects.toMatchObject({ code: 'unavailable' }); + clock = INITIAL_TIME; + await db.collection(PROFILE_COLLECTION).doc(UID).update({ discoverable: false }); + await expect(store.readOwn(UID)).rejects.toMatchObject({ code: 'unavailable' }); + await expect(store.saveOwn(UID, save(18, 1))).rejects.toMatchObject({ code: 'unavailable' }); + expect(await auditCount()).toBe(1); + }); + + test('provider/admission errors are fixed and never echo private data', async () => { + authorize.mockRejectedValue(new Error('synthetic-private-provider-canary')); + await expect(store.readOwn(UID)).rejects.toMatchObject({ + code: 'unavailable', message: 'Runner connection service is unavailable.', + }); + }); + + test('audit failure aborts the profile write, rather than persisting an unaudited opt-in', async () => { + const failingDb = { + collection: db.collection.bind(db), + runTransaction: (work) => db.runTransaction((transaction) => work({ + get: transaction.get.bind(transaction), + set: transaction.set.bind(transaction), + create: () => { throw new Error('synthetic-audit-failure'); }, + })), + }; + const failingStore = createRunnerProfileStore({ db: failingDb, authorize, now: () => clock }); + await expect(failingStore.saveOwn(UID, save(19))).rejects.toMatchObject({ code: 'unavailable' }); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).exists).toBe(false); + expect((await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(UID)).get()).exists).toBe(false); + expect(await auditCount()).toBe(0); + }); + + test('uncertain post-commit response can be recovered with the identical command', async () => { + const lostResponseDb = { + collection: db.collection.bind(db), + runTransaction: async (work) => { + await db.runTransaction(work); + throw new Error('synthetic-response-lost-after-commit'); + }, + }; + const uncertainStore = createRunnerProfileStore({ db: lostResponseDb, authorize, now: () => clock }); + const command = save(20); + await expect(uncertainStore.saveOwn(UID, command)).rejects.toMatchObject({ code: 'unavailable' }); + expect((await store.saveOwn(UID, command)).revision).toBe(1); + expect(await auditCount()).toBe(1); + }); +}); diff --git a/functions/runnerConnectionProfiles.js b/functions/runnerConnectionProfiles.js new file mode 100644 index 00000000..f656765d --- /dev/null +++ b/functions/runnerConnectionProfiles.js @@ -0,0 +1,211 @@ +'use strict'; + +const { createHash } = require('node:crypto'); +const { readRunnerProfileInput, readConnectionConsent } = require('./runnerConnections'); +// Reuse the strict shape/UID primitives, never officer consent or directory data. +const { readExactDataObject, isSafeUid, REQUEST_ID_PATTERN } = require('./memberDirectoryProjection'); +const { ENTRY_COLLECTION, runnerEntryId } = require('./runnerConnectionReferences'); + +const PROFILE_COLLECTION = 'runnerConnectionProfiles'; +// Version of this source contract, not evidence that a consent notice is approved. +const CONSENT_VERSION = 1; +const PRIVATE_CONSENT = Object.freeze({ memberDiscovery: false, similarity: false, broadenCircle: false }); +const MESSAGES = Object.freeze({ + 'invalid-argument': 'Runner connection request is invalid.', + 'permission-denied': 'Runner connection access is unavailable.', + aborted: 'Runner connection profile changed. Reload before trying again.', + unavailable: 'Runner connection service is unavailable.', +}); +const ACTIONS = Object.freeze({ + save: 'runner_connection.profile_saved', + withdraw: 'runner_connection.profile_withdrawn', +}); + +class RunnerProfileError extends Error { + constructor(code) { + super(MESSAGES[code]); + this.code = code; + } +} + +function fail(code = 'unavailable') { throw new RunnerProfileError(code); } + +function revision(value) { + if (!Number.isSafeInteger(value) || Object.is(value, -0) || value < 0 + || value >= Number.MAX_SAFE_INTEGER) fail('invalid-argument'); + return value; +} + +function digest(value) { return createHash('sha256').update(value).digest('hex'); } + +function readCommand(input, operation) { + try { + const fields = ['requestId', 'expectedRevision']; + if (operation === 'save') fields.push('profile', 'consent', 'consentVersion', 'adultConfirmed'); + const data = readExactDataObject(input, fields); + if (typeof data.requestId !== 'string' || !REQUEST_ID_PATTERN.test(data.requestId)) fail('invalid-argument'); + if (revision(data.expectedRevision) >= Number.MAX_SAFE_INTEGER - 1 + || (operation === 'save' && (data.consentVersion !== CONSENT_VERSION + || data.adultConfirmed !== true))) fail('invalid-argument'); + const command = { + requestId: data.requestId, + expectedRevision: revision(data.expectedRevision), + consentVersion: CONSENT_VERSION, + adultConfirmed: operation === 'save', + profile: operation === 'save' ? readRunnerProfileInput(data.profile) : null, + consent: operation === 'save' ? readConnectionConsent(data.consent) : PRIVATE_CONSENT, + }; + return Object.freeze({ ...command, requestDigest: digest(JSON.stringify([operation, command])) }); + } catch { return fail('invalid-argument'); } +} + +// Snapshot a closed wire request before any asynchronous service work. The +// store validates again so its direct callers cannot bypass the same contract. +function readRunnerProfileRequest(input, operation) { + if (!['save', 'withdraw'].includes(operation)) fail('invalid-argument'); + const command = readCommand(input, operation); + const request = { requestId: command.requestId, expectedRevision: command.expectedRevision }; + if (operation === 'save') Object.assign(request, { + profile: command.profile, + consent: command.consent, + consentVersion: command.consentVersion, + adultConfirmed: command.adultConfirmed, + }); + return Object.freeze(request); +} + +function publicState(state) { + return Object.freeze({ + schemaVersion: 1, + consentVersion: CONSENT_VERSION, + adultConfirmed: state?.adultConfirmed === true, + revision: state?.revision || 0, + profile: state?.profile || null, + consent: state?.consent || PRIVATE_CONSENT, + }); +} + +function readStoredProfile(value, now) { + if (value === null) return null; + const data = readExactDataObject(value, [ + 'schemaVersion', 'consentVersion', 'adultConfirmed', 'revision', 'profile', 'consent', 'discoverable', + 'updatedAtMs', 'lastRequestId', + ]); + if (data.schemaVersion !== 1 || data.consentVersion !== CONSENT_VERSION || revision(data.revision) < 1 + || typeof data.lastRequestId !== 'string' || !REQUEST_ID_PATTERN.test(data.lastRequestId) + || !Number.isSafeInteger(data.updatedAtMs) || data.updatedAtMs < 0 || data.updatedAtMs > now) fail(); + const consent = readConnectionConsent(data.consent); + const profile = data.profile === null ? null : readRunnerProfileInput(data.profile); + if (data.adultConfirmed !== (profile !== null) + || data.discoverable !== consent.memberDiscovery || (profile === null + && (consent.memberDiscovery || consent.similarity || consent.broadenCircle))) fail(); + return { ...data, profile, consent }; +} + +function readAudit(value) { + const data = readExactDataObject(value, [ + 'schemaVersion', 'actorUid', 'action', 'requestId', 'requestDigest', + 'expectedRevision', 'revision', 'createdAtMs', + ]); + if (data.schemaVersion !== 1 || !isSafeUid(data.actorUid) + || !Object.values(ACTIONS).includes(data.action) + || typeof data.requestId !== 'string' || !REQUEST_ID_PATTERN.test(data.requestId) + || typeof data.requestDigest !== 'string' || !/^[0-9a-f]{64}$/.test(data.requestDigest) + || revision(data.revision) !== revision(data.expectedRevision) + 1 + || !Number.isSafeInteger(data.createdAtMs) || data.createdAtMs < 0) fail(); + return data; +} + +/** + * Unexported-by-Firebase persistence primitive. The required trusted authorize + * callback must check the current actor for each operation/transaction attempt. + * No default grants access. It must NOT be supplied by a browser or replaced + * by the ranker's booleans. runnerConnectionService supplies the disabled, + * emulator-tested Auth/membership/App Check adapter; live release still needs + * approved canonical membership population and hosted enforcement evidence. + */ +function createRunnerProfileStore({ db, authorize, now = Date.now } = {}) { + if (!db || typeof db.runTransaction !== 'function' || typeof db.collection !== 'function' + || typeof authorize !== 'function' || typeof now !== 'function') fail(); + + async function transact(uid, operation, work) { + if (!isSafeUid(uid)) fail('invalid-argument'); + try { + return await db.runTransaction(async (transaction) => { + if (await authorize({ uid, operation, transaction }) !== true) fail('permission-denied'); + const timestamp = now(); + if (!Number.isSafeInteger(timestamp) || timestamp < 0) fail(); + const ref = db.collection(PROFILE_COLLECTION).doc(uid); + const snapshot = await transaction.get(ref); + let state; + try { state = readStoredProfile(snapshot.exists ? snapshot.data() : null, timestamp); } catch { fail(); } + return work({ transaction, ref, state, timestamp }); + }); + } catch (error) { + if (error instanceof RunnerProfileError) throw error; + return fail(); + } + } + + async function mutate(uid, input, operation) { + // Close the request before any read or authorization callback. UID is a + // server-selected argument, never a permitted field of the request body. + const command = readCommand(input, operation); + return transact(uid, operation, async ({ transaction, ref, state, timestamp }) => { + const auditRef = db.collection('auditEvents').doc(`runner_profile_${digest(JSON.stringify([uid, command.requestId]))}`); + const auditSnapshot = await transaction.get(auditRef); + if (auditSnapshot.exists) { + let audit; + try { audit = readAudit(auditSnapshot.data()); } catch { fail(); } + if (!state || audit.actorUid !== uid || audit.createdAtMs > timestamp) fail(); + if (audit.action !== ACTIONS[operation] || audit.requestId !== command.requestId + || audit.requestDigest !== command.requestDigest || audit.expectedRevision !== command.expectedRevision + || state.revision !== audit.revision || state.lastRequestId !== command.requestId) fail('aborted'); + return publicState(state); + } + if (state?.lastRequestId === command.requestId) fail(); + if ((state?.revision || 0) !== command.expectedRevision) fail('aborted'); + const next = { + schemaVersion: 1, + consentVersion: CONSENT_VERSION, + adultConfirmed: command.adultConfirmed, + revision: command.expectedRevision + 1, + profile: command.profile, + consent: command.consent, + discoverable: command.consent.memberDiscovery, + updatedAtMs: timestamp, + lastRequestId: command.requestId, + }; + // Audit contains operation metadata only, never the card/pace/goals or raw + // request. The fingerprint is server-only, not anonymous public data. + transaction.set(ref, next); + // This index is a candidate locator only. Discovery must re-read the + // profile, current account/membership and exclusions before returning it. + const entryRef = db.collection(ENTRY_COLLECTION).doc(runnerEntryId(uid)); + if (next.discoverable) transaction.set(entryRef, { schemaVersion: 1, uid }); + else transaction.delete(entryRef); + transaction.create(auditRef, { + schemaVersion: 1, + actorUid: uid, + action: ACTIONS[operation], + requestId: command.requestId, + requestDigest: command.requestDigest, + expectedRevision: command.expectedRevision, + revision: next.revision, + createdAtMs: timestamp, + }); + return publicState(next); + }); + } + + return Object.freeze({ + readOwn: (uid) => transact(uid, 'read', ({ state }) => publicState(state)), + saveOwn: (uid, request) => mutate(uid, request, 'save'), + withdrawOwn: (uid, request) => mutate(uid, request, 'withdraw'), + }); +} + +module.exports = { + PROFILE_COLLECTION, CONSENT_VERSION, createRunnerProfileStore, readRunnerProfileRequest, + readStoredProfile, +}; diff --git a/functions/runnerConnectionRecommendations.emulator.test.js b/functions/runnerConnectionRecommendations.emulator.test.js new file mode 100644 index 00000000..7597705c --- /dev/null +++ b/functions/runnerConnectionRecommendations.emulator.test.js @@ -0,0 +1,445 @@ +'use strict'; + +const admin = require('firebase-admin'); +const { createRunnerConnectionCallables, MEMBERSHIP_COLLECTION, accountRateKey } = require('./runnerConnectionService'); +const { createRunnerProfileStore, PROFILE_COLLECTION } = require('./runnerConnectionProfiles'); +const { createRunnerRecommendationStore, readExclusionRequest } = require('./runnerConnectionRecommendations'); +const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('./membershipAuthority'); +const { profile } = require('./testSupport/runnerConnectionsFixtures'); +const { + ENTRY_COLLECTION, WINDOW_COLLECTION, EXCLUSION_COLLECTION, DAY_MS, + runnerEntryId, suggestionRef, exclusionId, digest, +} = require('./runnerConnectionReferences'); + +jest.setTimeout(30000); +const isolated = /^127\.0\.0\.1:\d+$/.test(process.env.FIRESTORE_EMULATOR_HOST || '') + && /^127\.0\.0\.1:\d+$/.test(process.env.FIREBASE_AUTH_EMULATOR_HOST || '') + && process.env.GCLOUD_PROJECT === 'demo-functions-test'; +if (process.env.REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR === '1' && !isolated) { + throw new Error('Runner recommendations require isolated demo Auth and Firestore emulators.'); +} +const describeEmulator = isolated ? describe : describe.skip; +const UIDS = Array.from({ length: 52 }, (_, index) => `synthetic-suggestions-${index}`); +const UID = UIDS[0]; +const OTHER = UIDS[1]; +const requestId = (index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`; +const cards = (result) => [...result.similar, ...result.broaden]; +const consent = { memberDiscovery: true, similarity: true, broadenCircle: true }; + +// Context is synthetic and trusted, not HTTP middleware/token verification. +// Profile fixtures use storage-only admission; recommendations/exclusions use +// the real service's current Auth and canonical membership checks below. +describeEmulator('bounded recommendations and directed exclusions', () => { + let app; + let db; + let auth; + let clock; + let service; + let profiles; + const touchedPairs = new Set(); + const touchedUsers = new Set(); + const context = (uid = UID) => ({ + app: { appId: 'synthetic-app' }, + auth: { uid, token: { email_verified: true, auth_time: Math.floor(clock / 1000) } }, + rawRequest: { res: { setHeader: jest.fn() } }, + }); + const memberRef = (uid) => db.collection(MEMBERSHIP_COLLECTION).doc(`member-${uid}`); + const windowRef = (uid = UID) => db.collection(WINDOW_COLLECTION).doc(uid); + const pairRef = (actor, target) => { + const id = exclusionId(actor, target); + touchedPairs.add(id); + return db.collection(EXCLUSION_COLLECTION).doc(id); + }; + + async function cleanup() { + for (const uid of touchedUsers) { + try { await auth.deleteUser(uid); } catch (failure) { + if (failure.code !== 'auth/user-not-found') throw failure; + } + const seen = await windowRef(uid).collection('seen').get(); + const audits = await db.collection('auditEvents').where('actorUid', '==', uid).get(); + await Promise.all([...seen.docs, ...audits.docs].map((doc) => doc.ref.delete())); + await Promise.all([ + db.collection(PROFILE_COLLECTION).doc(uid).delete(), memberRef(uid).delete(), + db.collection(ENTRY_COLLECTION).doc(runnerEntryId(uid)).delete(), windowRef(uid).delete(), + ...['read', 'save', 'withdraw', 'recommend', 'exclude', 'exclusionRead'].map((operation) => + db.collection('ratelimits').doc(`runner_connection_${operation}__${accountRateKey(uid)}`).delete()), + ]); + } + await Promise.all([...touchedPairs].map((id) => db.collection(EXCLUSION_COLLECTION).doc(id).delete())); + touchedPairs.clear(); + touchedUsers.clear(); + } + + beforeAll(() => { + app = admin.initializeApp({ projectId: 'demo-functions-test' }); + db = app.firestore(); + auth = app.auth(); + }); + beforeEach(() => { + clock = Date.now(); + profiles = createRunnerProfileStore({ db, now: () => clock, authorize: async () => true }); + service = createRunnerConnectionCallables({ enabled: true, db, auth, now: () => clock }); + }); + afterEach(async () => { jest.restoreAllMocks(); await cleanup(); }); + afterAll(async () => { await app.delete(); }); + + async function seed(count = 2) { + for (let index = 0; index < count; index += 1) { + const uid = UIDS[index]; + touchedUsers.add(uid); + await auth.createUser({ uid, email: `suggestion-${index}@example.test`, emailVerified: true }); + const created = createMembershipAuthority({ + membershipAuthoritySchemaVersion: 1, membershipId: `member-${uid}`, commandId: 'synthetic-create', + }); + const linked = applyMembershipAuthorityCommand(created, { + membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', + commandId: 'synthetic-link', expectedRevision: 1, uid, + }); + await memberRef(uid).set(applyMembershipAuthorityCommand(linked, { + membershipAuthoritySchemaVersion: 1, commandType: 'record_term_decision', + commandId: 'synthetic-term', expectedRevision: 2, termRevision: 1, termState: 'approved', + termId: 'synthetic-term-id', startsAtMs: clock - 60000, endsAtMs: clock + 3 * DAY_MS, + planRef: 'synthetic-plan', evidenceRef: 'synthetic-evidence', policyVersion: 'synthetic-policy', + })); + await profiles.saveOwn(uid, { + requestId: requestId(1), expectedRevision: 0, adultConfirmed: true, consentVersion: 1, + consent, profile: profile({ displayName: `Synthetic Runner ${index}`, experience: index === 7 ? 'experienced' : 'beginner' }), + }); + } + } + const recommend = (uid = UID, chosenService = service) => chosenService.getMyRunnerConnectionRecommendations.run({}, context(uid)); + async function exclude(actor, target, index = 2, expectedRevision = 0, overrides = {}) { + pairRef(actor, target); + return service.setMyRunnerConnectionExclusion.run({ + requestId: requestId(index), entryRef: suggestionRef(actor, target), expectedRevision, + hidden: true, blocked: false, ...overrides, + }, context(actor)); + } + async function expire(uid) { + const record = (await memberRef(uid).get()).data(); + await memberRef(uid).set({ ...record, term: { ...record.term, endsAtMs: clock } }); + } + + test('closed output gives four ordinary and one mutually opted-in broadening card without identifiers or contacts', async () => { + await seed(8); + const result = await recommend(); + expect(result).toMatchObject({ rankerVersion: 1, status: 'ready' }); + expect(result.similar).toHaveLength(4); + expect(result.broaden).toHaveLength(1); + expect(result.broaden[0].experience).toBe('experienced'); + expect(result.windowEndsAtMs).toBe((Math.floor(clock / DAY_MS) + 1) * DAY_MS); + expect(JSON.stringify(result)).not.toMatch(/synthetic-suggestions|@example|memberEligible|experiencePreferences|adultConfirmed|similarity|schemaVersion/); + const stored = (await windowRef().get()).data(); + expect(Object.keys(stored).sort()).toEqual(['createdAtMs', 'day', 'schemaVersion', 'uids']); + expect(stored.uids).toHaveLength(5); + const audits = await db.collection('auditEvents').where('actorUid', '==', UID).where('action', '==', 'runner_connection.window_selected').get(); + expect(audits.size).toBe(1); + expect(JSON.stringify(audits.docs[0].data())).not.toMatch(/displayName|entryRef|pace|uids/); + }); + + test('no profile and sparse pool are honest, with no default enrollment', async () => { + await seed(1); + expect(cards(await recommend())).toEqual([]); + await profiles.withdrawOwn(UID, { requestId: requestId(2), expectedRevision: 1 }); + expect(await recommend()).toMatchObject({ status: 'profile_required', similar: [], broaden: [] }); + }); + + test.each(['withdrawn', 'disabled', 'deleted', 'unverified', 'expired', 'missing-membership', 'malformed-profile', 'invalid-index', 'reverse-block'])('%s candidate is withheld even when a locator exists', async (state) => { + await seed(); + if (state === 'withdrawn') { + await profiles.withdrawOwn(OTHER, { requestId: requestId(2), expectedRevision: 1 }); + await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(OTHER)).set({ schemaVersion: 1, uid: OTHER }); + } + if (state === 'disabled') await auth.updateUser(OTHER, { disabled: true }); + if (state === 'deleted') await auth.deleteUser(OTHER); + if (state === 'unverified') await auth.updateUser(OTHER, { emailVerified: false }); + if (state === 'expired') await expire(OTHER); + if (state === 'missing-membership') await memberRef(OTHER).delete(); + if (state === 'malformed-profile') await db.collection(PROFILE_COLLECTION).doc(OTHER).update({ adultConfirmed: false }); + if (state === 'invalid-index') await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(OTHER)).update({ uid: 'synthetic-forged' }); + if (state === 'reverse-block') { + await recommend(OTHER); + await exclude(OTHER, UID, 2, 0, { blocked: true }); + } + expect(cards(await recommend())).toEqual([]); + }); + + test.each(['expired', 'missing-membership', 'disabled', 'revoked'])('%s actor cannot retrieve cached suggestions', async (state) => { + await seed(); + await recommend(); + const ctx = context(); + if (state === 'expired') await expire(UID); + if (state === 'missing-membership') await memberRef(UID).delete(); + if (state === 'disabled') await auth.updateUser(UID, { disabled: true }); + if (state === 'revoked') { + await auth.revokeRefreshTokens(UID); + ctx.auth.token.auth_time = Math.floor(Date.parse((await auth.getUser(UID)).tokensValidAfterTime) / 1000) - 1; + } + await expect(service.getMyRunnerConnectionRecommendations.run({}, ctx)).rejects.toMatchObject({ code: 'permission-denied' }); + }); + + test('refreshes and profile edits reuse the same references; hiding never fills freed slots', async () => { + await seed(8); + const initial = cards(await recommend()).map((card) => card.entryRef); + const selected = (await windowRef().get()).data().uids; + await exclude(UID, selected[0]); + await profiles.saveOwn(UID, { + requestId: requestId(3), expectedRevision: 1, adultConfirmed: true, consentVersion: 1, + consent, profile: profile({ interests: ['coffee'], goals: ['social'] }), + }); + for (let index = 0; index < 3; index += 1) { + const refs = cards(await recommend()).map((card) => card.entryRef); + expect(refs).toHaveLength(4); + expect(refs.every((ref) => initial.includes(ref))).toBe(true); + expect(refs).not.toContain(suggestionRef(UID, selected[0])); + } + expect((await windowRef().get()).data().uids).toEqual(selected); + }); + + test('concurrent first requests commit one window and the same card set', async () => { + await seed(8); + const results = await Promise.all([recommend(), recommend()]); + expect(results[0]).toEqual(results[1]); + expect((await db.collection('auditEvents').where('actorUid', '==', UID).where('action', '==', 'runner_connection.window_selected').get()).size).toBe(1); + }); + + test('missing window cannot be silently rebuilt after its daily receipt committed', async () => { + await seed(); + await recommend(); + await windowRef().delete(); + await expect(recommend()).rejects.toMatchObject({ code: 'unavailable' }); + }); + + test('hide is one-way, block is mutual, and undo never removes the other person’s block', async () => { + await seed(); + await recommend(); + await recommend(OTHER); + await exclude(UID, OTHER); + expect(cards(await recommend())).toHaveLength(0); + expect(cards(await recommend(OTHER))).toHaveLength(1); + await exclude(UID, OTHER, 3, 1, { hidden: false, blocked: true }); + expect(cards(await recommend(OTHER))).toHaveLength(0); + await exclude(OTHER, UID, 2, 0, { hidden: false, blocked: true }); + await exclude(UID, OTHER, 4, 2, { hidden: false, blocked: false }); + expect(cards(await recommend())).toHaveLength(0); + const own = await service.getMyRunnerConnectionExclusion.run({ entryRef: suggestionRef(UID, OTHER) }, context()); + expect(own).toEqual({ revision: 3, hidden: false, blocked: false }); + }); + + test('exclusion retries are idempotent and conflicting commands/reused IDs cannot overwrite newer intent', async () => { + await seed(); + await recommend(); + const first = await exclude(UID, OTHER); + expect(await exclude(UID, OTHER)).toEqual(first); + await expect(exclude(UID, OTHER, 2, 0, { blocked: true })).rejects.toMatchObject({ code: 'aborted' }); + const results = await Promise.allSettled([ + exclude(UID, OTHER, 3, 1, { hidden: false }), exclude(UID, OTHER, 4, 1, { blocked: true }), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.find((result) => result.status === 'rejected').reason.code).toBe('aborted'); + await expect(exclude(UID, OTHER)).rejects.toMatchObject({ code: 'aborted' }); + expect((await db.collection('auditEvents').where('actorUid', '==', UID).where('action', '==', 'runner_connection.exclusion_set').get()).size).toBe(2); + }); + + test('privacy controls remain usable after membership expiry, target withdrawal and day rotation', async () => { + await seed(); + await recommend(); + await expire(UID); + await profiles.withdrawOwn(OTHER, { requestId: requestId(2), expectedRevision: 1 }); + clock += DAY_MS; + expect(await exclude(UID, OTHER)).toEqual({ revision: 1, hidden: true, blocked: false }); + expect(await service.getMyRunnerConnectionExclusion.run({ entryRef: suggestionRef(UID, OTHER) }, context())) + .toEqual({ revision: 1, hidden: true, blocked: false }); + }); + + test('exclusions require this requester’s issued receipt, never an arbitrary or another viewer’s target', async () => { + await seed(); + await expect(exclude(UID, OTHER)).rejects.toMatchObject({ code: 'permission-denied' }); + await recommend(OTHER); + await expect(service.getMyRunnerConnectionExclusion.run({ entryRef: suggestionRef(OTHER, UID) }, context())) + .rejects.toMatchObject({ code: 'permission-denied' }); + }); + + test.each(['withdraw', 'expire', 'disable', 'block', 'actor-withdraw', 'actor-expire'])('%s between selection and delivery is rechecked', async (change) => { + await seed(); + if (change === 'block') await recommend(OTHER); + let transactions = 0; + const wrappedDb = { + collection: db.collection.bind(db), + runTransaction: async (work) => { + const result = await db.runTransaction(work); + transactions += 1; + if (transactions === 1) { + if (change === 'withdraw') await profiles.withdrawOwn(OTHER, { requestId: requestId(2), expectedRevision: 1 }); + if (change === 'expire') await expire(OTHER); + if (change === 'disable') await auth.updateUser(OTHER, { disabled: true }); + if (change === 'block') await exclude(OTHER, UID, 2, 0, { blocked: true }); + if (change === 'actor-withdraw') await profiles.withdrawOwn(UID, { requestId: requestId(2), expectedRevision: 1 }); + if (change === 'actor-expire') await expire(UID); + } + return result; + }, + }; + const fresh = createRunnerConnectionCallables({ enabled: true, db: wrappedDb, auth, now: () => clock }); + if (change === 'actor-expire') await expect(recommend(UID, fresh)).rejects.toMatchObject({ code: 'permission-denied' }); + else expect(cards(await recommend(UID, fresh))).toEqual([]); + }); + + test('recommendation budget is six per hour and cannot exhaust hide/block or withdrawal', async () => { + await seed(); + for (let attempt = 0; attempt < 6; attempt += 1) await recommend(); + const lookup = jest.spyOn(auth, 'getUser'); + await expect(recommend()).rejects.toMatchObject({ code: 'resource-exhausted' }); + expect(lookup).not.toHaveBeenCalled(); + await exclude(UID, OTHER); + await expect(service.withdrawMyRunnerConnectionProfile.run({ requestId: requestId(3), expectedRevision: 1 }, context())) + .resolves.toMatchObject({ profile: null }); + }); + + test('a new UTC day creates a new bounded window; old receipts still permit exclusion', async () => { + await seed(8); + await recommend(); + const old = (await windowRef().get()).data(); + clock += DAY_MS; + expect(cards(await recommend()).length).toBeLessThanOrEqual(5); + expect((await windowRef().get()).data().day).toBe(old.day + 1); + await exclude(UID, old.uids[0]); + expect((await db.collection('auditEvents').where('actorUid', '==', UID).where('action', '==', 'runner_connection.window_selected').get()).size).toBe(2); + }); + + test('index selection inspects at most 48 entries and final delivery joins at most five candidates', async () => { + await seed(52); + const indexSizes = []; + const joinedSizes = []; + const measuredDb = { + collection: db.collection.bind(db), + runTransaction: (work) => db.runTransaction((transaction) => work({ + get: async (ref) => { + const snapshot = await transaction.get(ref); + if (snapshot.docs && snapshot.docs[0]?.ref.parent.id === ENTRY_COLLECTION) indexSizes.push(snapshot.size); + return snapshot; + }, + getAll: (...refs) => { joinedSizes.push(refs.length); return transaction.getAll(...refs); }, + set: transaction.set.bind(transaction), create: transaction.create.bind(transaction), + })), + }; + const measured = createRunnerConnectionCallables({ enabled: true, db: measuredDb, auth, now: () => clock }); + expect(cards(await recommend(UID, measured)).length).toBeLessThanOrEqual(5); + expect(indexSizes.reduce((sum, size) => sum + size, 0)).toBe(48); + expect(joinedSizes).toHaveLength(2); + expect(joinedSizes[0]).toBeLessThanOrEqual(48 * 3); + expect(joinedSizes[1]).toBeLessThanOrEqual(5 * 3); + }); + + test('provider failure returns fixed unavailability, never partial cards or private error detail', async () => { + await seed(); + await recommend(); + const original = auth.getUser.bind(auth); + jest.spyOn(auth, 'getUser').mockImplementation((uid) => uid === OTHER + ? Promise.reject(new Error('synthetic-private-provider-canary')) : original(uid)); + await expect(recommend()).rejects.toMatchObject({ code: 'unavailable', message: 'Runner connection service is unavailable.' }); + }); + + test('closed requests cannot set UID, cursors, bounds, age, or source enablement', async () => { + const lookup = jest.spyOn(auth, 'getUser'); + for (const input of [{ uid: OTHER }, { cursor: 'synthetic' }, { limit: 500 }, { adultConfirmed: true }, { enabled: true }]) { + await expect(service.getMyRunnerConnectionRecommendations.run(input, context())).rejects.toMatchObject({ code: 'invalid-argument' }); + } + expect(lookup).not.toHaveBeenCalled(); + const input = { requestId: requestId(1), entryRef: suggestionRef(UID, OTHER), expectedRevision: 0, hidden: true, blocked: false }; + const frozen = readExclusionRequest(input); + input.blocked = true; + expect(frozen.blocked).toBe(false); + expect(Object.isFrozen(frozen)).toBe(true); + for (const extra of [{ expectedRevision: -0 }, { expectedRevision: Number.MAX_SAFE_INTEGER }, { uid: OTHER }, { blocked: 'true' }]) { + expect(() => readExclusionRequest({ ...input, ...extra })).toThrow(); + } + expect(() => createRunnerRecommendationStore({ db })).toThrow(); + }); + + test('a corrupted exclusion receipt fails closed and does not overwrite the saved choice', async () => { + await seed(); + await recommend(); + await exclude(UID, OTHER); + const receipt = db.collection('auditEvents').doc(`runner_exclusion_${digest(UID, requestId(2))}`); + await receipt.update({ expectedRevision: 10 }); + await expect(exclude(UID, OTHER)).rejects.toMatchObject({ code: 'unavailable' }); + expect((await pairRef(UID, OTHER).get()).data().revision).toBe(1); + }); + + test('private save atomically removes a previously published locator', async () => { + await seed(); + await profiles.saveOwn(OTHER, { + requestId: requestId(2), expectedRevision: 1, adultConfirmed: true, consentVersion: 1, + consent: { memberDiscovery: false, similarity: false, broadenCircle: false }, profile: profile(), + }); + expect((await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(OTHER)).get()).exists).toBe(false); + expect(cards(await recommend())).toEqual([]); + }); + + test('audit failure rolls back exclusion; uncertain committed response is recovered with the same command', async () => { + await seed(); + await recommend(); + const input = { requestId: requestId(2), entryRef: suggestionRef(UID, OTHER), expectedRevision: 0, hidden: true, blocked: false }; + const failingDb = { + collection: db.collection.bind(db), + runTransaction: (work) => db.runTransaction((transaction) => work({ + get: transaction.get.bind(transaction), set: transaction.set.bind(transaction), + create: () => { throw new Error('synthetic-audit-failure'); }, + })), + }; + const failing = createRunnerConnectionCallables({ enabled: true, db: failingDb, auth, now: () => clock }); + await expect(failing.setMyRunnerConnectionExclusion.run(input, context())).rejects.toMatchObject({ code: 'unavailable' }); + expect((await pairRef(UID, OTHER).get()).exists).toBe(false); + const uncertainDb = { + collection: db.collection.bind(db), + runTransaction: async (work) => { await db.runTransaction(work); throw new Error('synthetic-response-lost'); }, + }; + const uncertain = createRunnerConnectionCallables({ enabled: true, db: uncertainDb, auth, now: () => clock }); + await expect(uncertain.setMyRunnerConnectionExclusion.run(input, context())).rejects.toMatchObject({ code: 'unavailable' }); + expect(await service.setMyRunnerConnectionExclusion.run(input, context())).toEqual({ revision: 1, hidden: true, blocked: false }); + }); + + test('authorization is rerun on retry and a disabled actor cannot commit a queued exclusion', async () => { + await seed(); + await recommend(); + let attempts = 0; + const retryDb = { + collection: db.collection.bind(db), + runTransaction: (work) => db.runTransaction(async (transaction) => { + attempts += 1; + const result = await work(transaction); + if (attempts === 1) { + await auth.updateUser(UID, { disabled: true }); + const conflict = new Error('synthetic-transaction-conflict'); + conflict.code = 10; + throw conflict; + } + return result; + }), + }; + const retry = createRunnerConnectionCallables({ enabled: true, db: retryDb, auth, now: () => clock }); + const input = { requestId: requestId(2), entryRef: suggestionRef(UID, OTHER), expectedRevision: 0, hidden: true, blocked: false }; + await expect(retry.setMyRunnerConnectionExclusion.run(input, context())).rejects.toMatchObject({ code: 'permission-denied' }); + expect(attempts).toBe(2); + expect((await pairRef(UID, OTHER).get()).exists).toBe(false); + }); + + test('crossing UTC midnight between selection and delivery aborts instead of widening the window', async () => { + await seed(); + let transactions = 0; + const rolloverDb = { + collection: db.collection.bind(db), + runTransaction: async (work) => { + const result = await db.runTransaction(work); + transactions += 1; + if (transactions === 1) clock = (Math.floor(clock / DAY_MS) + 1) * DAY_MS; + return result; + }, + }; + const rollover = createRunnerConnectionCallables({ enabled: true, db: rolloverDb, auth, now: () => clock }); + await expect(recommend(UID, rollover)).rejects.toMatchObject({ code: 'aborted' }); + expect(cards(await recommend())).toHaveLength(1); + }); +}); diff --git a/functions/runnerConnectionRecommendations.js b/functions/runnerConnectionRecommendations.js new file mode 100644 index 00000000..a715355f --- /dev/null +++ b/functions/runnerConnectionRecommendations.js @@ -0,0 +1,254 @@ +'use strict'; + +const { FieldPath } = require('firebase-admin/firestore'); +const { readExactDataObject, isSafeUid, REQUEST_ID_PATTERN } = require('./memberDirectoryProjection'); +const { PROFILE_COLLECTION, readStoredProfile } = require('./runnerConnectionProfiles'); +const { MAX_CANDIDATES, rankRunnerConnections } = require('./runnerConnections'); +const { + ENTRY_COLLECTION, WINDOW_COLLECTION, EXCLUSION_COLLECTION, DAY_MS, + digest, runnerEntryId, suggestionRef, exclusionId, windowPivot, +} = require('./runnerConnectionReferences'); + +class RecommendationError extends Error { + constructor(code = 'unavailable') { super('Runner connection request could not be completed.'); this.code = code; } +} +function fail(code) { throw new RecommendationError(code); } +function natural(value, max = Number.MAX_SAFE_INTEGER - 1) { + if (!Number.isSafeInteger(value) || Object.is(value, -0) || value < 0 || value > max) fail(); + return value; +} +function profile(snapshot, now) { + return readStoredProfile(snapshot.exists ? snapshot.data() : null, now); +} +function visible(state) { return state?.adultConfirmed === true && state.discoverable && state.profile !== null; } +function runner(viewerUid, uid, state) { + return { + entryRef: suggestionRef(viewerUid, uid), memberEligible: true, adultEligible: true, + pairExcluded: false, consent: state.consent, profile: state.profile, + }; +} +function readWindow(value, now) { + if (value === null) return null; + const data = readExactDataObject(value, ['schemaVersion', 'day', 'uids', 'createdAtMs']); + if (data.schemaVersion !== 1 || natural(data.createdAtMs) > now + || natural(data.day) !== Math.floor(data.createdAtMs / DAY_MS) + || !Array.isArray(data.uids) || data.uids.length > 5 + || Reflect.ownKeys(data.uids).length !== data.uids.length + 1 + || data.uids.some((uid) => !isSafeUid(uid)) || new Set(data.uids).size !== data.uids.length) fail(); + return data; +} +function readExclusion(value, now) { + if (value === null) return { revision: 0, hidden: false, blocked: false }; + const data = readExactDataObject(value, ['schemaVersion', 'revision', 'hidden', 'blocked', 'lastRequestId', 'updatedAtMs']); + if (data.schemaVersion !== 1 || natural(data.revision) < 1 || natural(data.updatedAtMs) > now + || typeof data.hidden !== 'boolean' || typeof data.blocked !== 'boolean' + || typeof data.lastRequestId !== 'string' || !REQUEST_ID_PATTERN.test(data.lastRequestId)) fail(); + return data; +} +function readExclusionRequest(input) { + try { + const data = readExactDataObject(input, ['requestId', 'entryRef', 'expectedRevision', 'hidden', 'blocked']); + if (typeof data.requestId !== 'string' || !REQUEST_ID_PATTERN.test(data.requestId) + || typeof data.entryRef !== 'string' || !/^runner_[0-9a-f]{64}$/.test(data.entryRef) + || typeof data.hidden !== 'boolean' || typeof data.blocked !== 'boolean') fail(); + natural(data.expectedRevision, Number.MAX_SAFE_INTEGER - 2); + return Object.freeze({ + requestId: data.requestId, entryRef: data.entryRef, expectedRevision: data.expectedRevision, + hidden: data.hidden, blocked: data.blocked, + }); + } catch { return fail('invalid-argument'); } +} +function readExclusionLookup(input) { + try { + const data = readExactDataObject(input, ['entryRef']); + if (typeof data.entryRef !== 'string' || !/^runner_[0-9a-f]{64}$/.test(data.entryRef)) fail(); + return Object.freeze({ entryRef: data.entryRef }); + } catch { return fail('invalid-argument'); } +} + +/** Candidate cache never authorizes disclosure. Every response has a second, + * fresh transaction after window selection, rechecking only its bounded IDs. + * A committed snapshot cannot recall data already sent over the network. + */ +function createRunnerRecommendationStore({ db, authorize, candidateEligible, now = Date.now } = {}) { + if (!db || typeof db.runTransaction !== 'function' || typeof authorize !== 'function' + || typeof candidateEligible !== 'function' || typeof now !== 'function') fail(); + const windowRef = (uid) => db.collection(WINDOW_COLLECTION).doc(uid); + const pairRef = (viewer, target) => db.collection(EXCLUSION_COLLECTION).doc(exclusionId(viewer, target)); + const exclusionResult = (value) => ({ revision: value.revision, hidden: value.hidden, blocked: value.blocked }); + + async function transact(uid, operation, work) { + if (!isSafeUid(uid)) fail('invalid-argument'); + try { + return await db.runTransaction(async (transaction) => { + if (await authorize({ uid, operation, transaction }) !== true) fail('permission-denied'); + natural(now()); + return work(transaction); + }); + } catch (failure) { + if (failure instanceof RecommendationError) throw failure; + return fail(); + } + } + + async function readViewer(uid, transaction) { + return profile(await transaction.get(db.collection(PROFILE_COLLECTION).doc(uid)), now()); + } + + async function candidateUids(uid, day, transaction) { + const pivot = windowPivot(uid, day); + const query = db.collection(ENTRY_COLLECTION).orderBy(FieldPath.documentId()); + const after = await transaction.get(query.startAt(pivot).limit(MAX_CANDIDATES)); + const before = after.size < MAX_CANDIDATES + ? await transaction.get(query.endBefore(pivot).limit(MAX_CANDIDATES - after.size)) : null; + const uids = []; + for (const entry of [...after.docs, ...(before?.docs || [])]) { + try { + const data = readExactDataObject(entry.data(), ['schemaVersion', 'uid']); + if (data.schemaVersion === 1 && isSafeUid(data.uid) && data.uid !== uid + && entry.id === runnerEntryId(data.uid) && !uids.includes(data.uid)) uids.push(data.uid); + } catch { /* Malformed index entries cannot authorize a card. */ } + } + return uids; + } + + async function currentMatches(uid, viewer, uids, transaction) { + if (!uids.length) return rankRunnerConnections(runner(uid, uid, viewer), []); + // At most 48 source cards and two directed pair records each; final delivery + // handles at most five. No roster scan, browser cursor or arbitrary target. + const refs = uids.flatMap((target) => [ + db.collection(PROFILE_COLLECTION).doc(target), pairRef(uid, target), pairRef(target, uid), + ]); + const snapshots = await transaction.getAll(...refs); + const candidates = await Promise.all(uids.map(async (target, index) => { + let state; + let own; + let reverse; + try { + state = profile(snapshots[index * 3], now()); + own = readExclusion(snapshots[index * 3 + 1].exists ? snapshots[index * 3 + 1].data() : null, now()); + reverse = readExclusion(snapshots[index * 3 + 2].exists ? snapshots[index * 3 + 2].data() : null, now()); + } catch { return null; } + if (!visible(state) || own.hidden || own.blocked || reverse.blocked) return null; + const candidate = runner(uid, target, state); + const feasible = rankRunnerConnections(runner(uid, uid, viewer), [candidate]); + if (!feasible.similar.length && !feasible.broaden.length) return null; + if (await candidateEligible({ uid: target, transaction }) !== true) return null; + return candidate; + })); + return rankRunnerConnections(runner(uid, uid, viewer), candidates.filter(Boolean)); + } + + async function selectWindow(uid) { + return transact(uid, 'recommend', async (transaction) => { + const viewer = await readViewer(uid, transaction); + if (!visible(viewer)) return null; + const timestamp = natural(now()); + const day = Math.floor(timestamp / DAY_MS); + const ref = windowRef(uid); + const snapshot = await transaction.get(ref); + const current = readWindow(snapshot.exists ? snapshot.data() : null, timestamp); + if (current?.day === day) return current; + const auditRef = db.collection('auditEvents').doc(`runner_window_${digest(uid, day)}`); + // A missing/corrupt cache must not allow an already issued window to be + // rebuilt into a fresh set of identities. Recovery is reviewed, not silent. + if ((await transaction.get(auditRef)).exists) fail(); + const uids = await candidateUids(uid, day, transaction); + const result = await currentMatches(uid, viewer, uids, transaction); + const selected = [...result.similar, ...result.broaden].map((card) => + uids.find((target) => suggestionRef(uid, target) === card.entryRef)); + const next = { schemaVersion: 1, day, uids: selected, createdAtMs: timestamp }; + transaction.set(ref, next); + for (const target of selected) { + // A requester-scoped receipt permits future hide/block/undo of a shown + // reference, even after the day changes or that person withdraws. + transaction.set(ref.collection('seen').doc(suggestionRef(uid, target)), { schemaVersion: 1, uid: target }); + } + transaction.create(auditRef, { + schemaVersion: 1, actorUid: uid, action: 'runner_connection.window_selected', + day, resultCount: selected.length, createdAtMs: timestamp, + }); + return next; + }); + } + + async function recommend(uid) { + const selection = await selectWindow(uid); + return transact(uid, 'recommend', async (transaction) => { + const viewer = await readViewer(uid, transaction); + const timestamp = natural(now()); + const windowEndsAtMs = (Math.floor(timestamp / DAY_MS) + 1) * DAY_MS; + if (!visible(viewer)) return { rankerVersion: 1, similar: [], broaden: [], status: 'profile_required', windowEndsAtMs }; + // If midnight passed during selection, retry the whole request. Do not + // silently widen one request into two different recommendation windows. + if (selection && selection.day !== Math.floor(timestamp / DAY_MS)) fail('aborted'); + const result = await currentMatches(uid, viewer, selection?.uids || [], transaction); + // Firestore supplies a consistent final snapshot. Recheck the external + // account boundary after candidate work as well, before releasing cards. + if (await authorize({ uid, operation: 'recommend', transaction }) !== true) fail('permission-denied'); + return { ...result, status: 'ready', windowEndsAtMs }; + }); + } + + async function exclusionTarget(uid, entryRef, transaction) { + const seen = await transaction.get(windowRef(uid).collection('seen').doc(entryRef)); + if (!seen.exists) fail('permission-denied'); + const receipt = readExactDataObject(seen.data(), ['schemaVersion', 'uid']); + if (receipt.schemaVersion !== 1 || !isSafeUid(receipt.uid) || receipt.uid === uid + || suggestionRef(uid, receipt.uid) !== entryRef) fail(); + return receipt.uid; + } + + async function lookupExclusion(uid, input) { + const request = readExclusionLookup(input); + return transact(uid, 'exclusionRead', async (transaction) => { + const target = await exclusionTarget(uid, request.entryRef, transaction); + const snapshot = await transaction.get(pairRef(uid, target)); + return exclusionResult(readExclusion(snapshot.exists ? snapshot.data() : null, natural(now()))); + }); + } + + async function setExclusion(uid, input) { + const request = readExclusionRequest(input); + return transact(uid, 'exclude', async (transaction) => { + const target = await exclusionTarget(uid, request.entryRef, transaction); + const ref = pairRef(uid, target); + const snapshot = await transaction.get(ref); + const timestamp = natural(now()); + const state = readExclusion(snapshot.exists ? snapshot.data() : null, timestamp); + const auditRef = db.collection('auditEvents').doc(`runner_exclusion_${digest(uid, request.requestId)}`); + const auditSnapshot = await transaction.get(auditRef); + const requestDigest = digest(request, target); + if (auditSnapshot.exists) { + const audit = readExactDataObject(auditSnapshot.data(), [ + 'schemaVersion', 'actorUid', 'action', 'requestId', 'requestDigest', 'expectedRevision', 'revision', 'createdAtMs', + ]); + if (audit.schemaVersion !== 1 || audit.actorUid !== uid || audit.action !== 'runner_connection.exclusion_set' + || natural(audit.createdAtMs) > timestamp + || natural(audit.revision) !== natural(audit.expectedRevision) + 1) fail(); + if (audit.requestId !== request.requestId || audit.requestDigest !== requestDigest + || audit.expectedRevision !== request.expectedRevision || audit.revision !== state.revision + || state.lastRequestId !== request.requestId) fail('aborted'); + if (state.hidden !== request.hidden || state.blocked !== request.blocked) fail(); + return exclusionResult(state); + } + if (state.lastRequestId === request.requestId) fail(); + if (state.revision !== request.expectedRevision) fail('aborted'); + const next = { + schemaVersion: 1, revision: request.expectedRevision + 1, + hidden: request.hidden, blocked: request.blocked, lastRequestId: request.requestId, updatedAtMs: timestamp, + }; + transaction.set(ref, next); + transaction.create(auditRef, { + schemaVersion: 1, actorUid: uid, action: 'runner_connection.exclusion_set', + requestId: request.requestId, requestDigest, expectedRevision: request.expectedRevision, + revision: next.revision, createdAtMs: timestamp, + }); + return exclusionResult(next); + }); + } + + return Object.freeze({ recommend, setExclusion, readExclusion: lookupExclusion }); +} + +module.exports = { createRunnerRecommendationStore, readExclusionRequest, readExclusionLookup }; diff --git a/functions/runnerConnectionReferences.js b/functions/runnerConnectionReferences.js new file mode 100644 index 00000000..1ab12716 --- /dev/null +++ b/functions/runnerConnectionReferences.js @@ -0,0 +1,19 @@ +'use strict'; + +const { createHash } = require('node:crypto'); + +const ENTRY_COLLECTION = 'runnerConnectionEntries'; +const WINDOW_COLLECTION = 'runnerConnectionWindows'; +const EXCLUSION_COLLECTION = 'runnerConnectionExclusions'; +const DAY_MS = 24 * 60 * 60 * 1000; +const digest = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex'); +const runnerEntryId = (uid) => `entry_${digest('runner_entry_v1', uid)}`; +const suggestionRef = (viewer, target) => `runner_${digest('runner_suggestion_v1', viewer, target)}`; +const exclusionId = (viewer, target) => `pair_${digest('runner_exclusion_v1', viewer, target)}`; +const windowPivot = (viewer, day) => `entry_${digest('runner_window_v1', viewer, day)}`; + +// These are linkable private pseudonyms, not secrets or authorization tokens. +module.exports = { + ENTRY_COLLECTION, WINDOW_COLLECTION, EXCLUSION_COLLECTION, DAY_MS, + digest, runnerEntryId, suggestionRef, exclusionId, windowPivot, +}; diff --git a/functions/runnerConnectionService.emulator.test.js b/functions/runnerConnectionService.emulator.test.js new file mode 100644 index 00000000..2f0e5135 --- /dev/null +++ b/functions/runnerConnectionService.emulator.test.js @@ -0,0 +1,263 @@ +'use strict'; + +const admin = require('firebase-admin'); +const { createRunnerConnectionCallables, MEMBERSHIP_COLLECTION, accountRateKey } = require('./runnerConnectionService'); +const { PROFILE_COLLECTION } = require('./runnerConnectionProfiles'); +const { ENTRY_COLLECTION, runnerEntryId } = require('./runnerConnectionReferences'); +const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('./membershipAuthority'); +const { profile } = require('./testSupport/runnerConnectionsFixtures'); + +jest.setTimeout(30000); +const isolatedEmulators = /^127\.0\.0\.1:\d+$/.test(process.env.FIRESTORE_EMULATOR_HOST || '') + && /^127\.0\.0\.1:\d+$/.test(process.env.FIREBASE_AUTH_EMULATOR_HOST || '') + && process.env.GCLOUD_PROJECT === 'demo-functions-test'; +if (process.env.REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR === '1' && !isolatedEmulators) { + throw new Error('Runner service tests require isolated Auth and Firestore demo emulators.'); +} +const describeEmulator = isolatedEmulators ? describe : describe.skip; +const UID = 'synthetic-connection-service-owner'; +const OTHER = 'synthetic-connection-service-other'; +const MEMBERSHIP_IDS = ['synthetic-connection-member-one', 'synthetic-connection-member-two']; +const requestId = (index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`; +const command = (index = 1, expectedRevision = 0) => ({ + requestId: requestId(index), expectedRevision, adultConfirmed: true, consentVersion: 1, + profile: profile(), consent: { memberDiscovery: true, similarity: true, broadenCircle: false }, +}); + +function membership(uid, id, now) { + const created = createMembershipAuthority({ + membershipAuthoritySchemaVersion: 1, membershipId: id, commandId: 'synthetic-create', + }); + const linked = applyMembershipAuthorityCommand(created, { + membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', + commandId: 'synthetic-link', expectedRevision: 1, uid, + }); + return applyMembershipAuthorityCommand(linked, { + membershipAuthoritySchemaVersion: 1, commandType: 'record_term_decision', + commandId: 'synthetic-term', expectedRevision: 2, termRevision: 1, termState: 'approved', + termId: 'synthetic-term-id', startsAtMs: now - 60000, endsAtMs: now + 3600000, + planRef: 'synthetic-plan', evidenceRef: 'synthetic-approved-evidence', policyVersion: 'synthetic-policy', + }); +} + +// The real callable callback is exercised against real local Auth/Firestore. +// The supplied context stands in for SDK-verified token/App Check claims; this +// is not an HTTP token-verification or hosted App Check rehearsal. +describeEmulator('runner profile callable integration with local Auth and Firestore', () => { + let app; + let db; + let auth; + let clock; + let service; + let context; + let getUser; + + async function cleanup() { + for (const uid of [UID, OTHER]) { + try { await auth.deleteUser(uid); } catch (failure) { + if (failure.code !== 'auth/user-not-found') throw failure; + } + await db.collection(PROFILE_COLLECTION).doc(uid).delete(); + await db.collection(ENTRY_COLLECTION).doc(runnerEntryId(uid)).delete(); + await db.collection('members').doc(uid).delete(); + for (const operation of ['read', 'save', 'withdraw']) { + await db.collection('ratelimits').doc(`runner_connection_${operation}__${accountRateKey(uid)}`).delete(); + } + const audits = await db.collection('auditEvents').where('actorUid', '==', uid).get(); + await Promise.all(audits.docs.map((doc) => doc.ref.delete())); + } + for (const id of MEMBERSHIP_IDS) await db.collection(MEMBERSHIP_COLLECTION).doc(id).delete(); + } + + beforeAll(() => { + app = admin.initializeApp({ projectId: 'demo-functions-test' }); + db = app.firestore(); + auth = app.auth(); + }); + beforeEach(async () => { + await cleanup(); + await auth.createUser({ uid: UID, email: 'synthetic-connections@example.test', emailVerified: true }); + clock = Date.now(); + context = { + app: { appId: 'synthetic-app' }, + auth: { uid: UID, token: { email_verified: true, auth_time: Math.floor(clock / 1000) } }, + rawRequest: { res: { setHeader: jest.fn() } }, + }; + getUser = jest.spyOn(auth, 'getUser'); + service = createRunnerConnectionCallables({ enabled: true, db, auth, now: () => clock }); + }); + afterEach(() => jest.restoreAllMocks()); + afterAll(async () => { await cleanup(); await app.delete(); }); + + async function admit(uid = UID, id = MEMBERSHIP_IDS[0]) { + const record = membership(uid, id, clock); + await db.collection(MEMBERSHIP_COLLECTION).doc(id).set(record); + return record; + } + async function auditCount() { + return (await db.collection('auditEvents').where('actorUid', '==', UID).get()).size; + } + + test('a current canonical member can save/read/withdraw without altering membership or claims', async () => { + const record = await admit(); + const originalAccount = await auth.getUser(UID); + expect(originalAccount.disabled).toBe(false); + expect(originalAccount.emailVerified).toBe(true); + // A newly created, never-revoked emulator account has no revocation time; + // this is valid SDK state, not grounds for denying every new account. + expect(originalAccount.tokensValidAfterTime).toBeUndefined(); + const originalClaims = originalAccount.customClaims; + const saved = await service.saveMyRunnerConnectionProfile.run(command(), context); + expect(saved).toMatchObject({ adultConfirmed: true, revision: 1, profile: { displayName: 'Synthetic Runner' } }); + expect(await service.getMyRunnerConnectionProfile.run({}, context)).toEqual(saved); + const withdrawn = await service.withdrawMyRunnerConnectionProfile.run({ requestId: requestId(2), expectedRevision: 1 }, context); + expect(withdrawn).toMatchObject({ adultConfirmed: false, revision: 2, profile: null }); + expect((await db.collection(MEMBERSHIP_COLLECTION).doc(MEMBERSHIP_IDS[0]).get()).data()).toEqual(record); + expect((await auth.getUser(UID)).customClaims).toEqual(originalClaims); + expect(context.rawRequest.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'private, no-store, max-age=0'); + expect(await auditCount()).toBe(2); + }); + + test.each(['member', 'admin'])('a %s claim and mirrored profile do not replace canonical membership', async (role) => { + await auth.setCustomUserClaims(UID, { role }); + context.auth.token.role = role; + await db.collection('members').doc(UID).set({ role, emailVerified: true, membershipActive: true }); + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'permission-denied' }); + expect((await db.collection(PROFILE_COLLECTION).doc(UID).get()).exists).toBe(false); + expect(await auditCount()).toBe(0); + }); + + test('another account association cannot admit the caller', async () => { + await admit(OTHER); + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'permission-denied' }); + }); + + test.each(['future', 'expired', 'suspended', 'ended', 'pending', 'malformed', 'id-mismatch', 'duplicate'])('%s membership fails closed', async (state) => { + const record = await admit(); + let replacement = record; + if (state === 'future') replacement = { ...record, term: { ...record.term, startsAtMs: clock + 1 } }; + if (state === 'expired') replacement = { ...record, term: { ...record.term, endsAtMs: clock } }; + if (state === 'suspended' || state === 'ended') replacement = { ...record, term: { ...record.term, state } }; + if (state === 'pending') replacement = applyMembershipAuthorityCommand(createMembershipAuthority({ + membershipAuthoritySchemaVersion: 1, membershipId: MEMBERSHIP_IDS[0], commandId: 'synthetic-create', + }), { membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', commandId: 'synthetic-link', expectedRevision: 1, uid: UID }); + if (state === 'malformed') replacement = { ...record, approvedByBrowser: true }; + if (state === 'id-mismatch') replacement = { ...record, membershipId: MEMBERSHIP_IDS[1] }; + if (state === 'duplicate') await admit(UID, MEMBERSHIP_IDS[1]); + await db.collection(MEMBERSHIP_COLLECTION).doc(MEMBERSHIP_IDS[0]).set(replacement); + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'permission-denied' }); + expect(await auditCount()).toBe(0); + }); + + test('term expiry rejects exact save replay but retains owner read and withdrawal', async () => { + const record = await admit(); + await service.saveMyRunnerConnectionProfile.run(command(), context); + clock = record.term.endsAtMs; + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'permission-denied' }); + expect((await service.getMyRunnerConnectionProfile.run({}, context)).revision).toBe(1); + expect(await service.withdrawMyRunnerConnectionProfile.run({ requestId: requestId(2), expectedRevision: 1 }, context)) + .toMatchObject({ adultConfirmed: false, profile: null, revision: 2 }); + }); + + test('membership time is checked after the bounded transaction query returns', async () => { + const record = await admit(); + const delayedDb = { + collection: db.collection.bind(db), + runTransaction: (work) => db.runTransaction((transaction) => work({ + get: async (ref) => { + const result = await transaction.get(ref); + if (typeof ref.where === 'function') clock = record.term.endsAtMs; + return result; + }, + set: transaction.set.bind(transaction), create: transaction.create.bind(transaction), + })), + }; + const delayedService = createRunnerConnectionCallables({ enabled: true, db: delayedDb, auth, now: () => clock }); + await expect(delayedService.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'permission-denied' }); + expect(await auditCount()).toBe(0); + }); + + test.each([null, '', 'not-a-time', 123])('malformed present revocation metadata %p denies access', async (tokensValidAfterTime) => { + const user = await auth.getUser(UID); + getUser.mockResolvedValue({ uid: user.uid, disabled: false, emailVerified: true, tokensValidAfterTime }); + await expect(service.getMyRunnerConnectionProfile.run({}, context)).rejects.toMatchObject({ code: 'permission-denied' }); + }); + + test.each(['disabled', 'deleted', 'unverified', 'revoked'])('fresh %s Auth state denies saved-profile access', async (state) => { + await admit(); + await service.saveMyRunnerConnectionProfile.run(command(), context); + if (state === 'disabled') await auth.updateUser(UID, { disabled: true }); + if (state === 'deleted') await auth.deleteUser(UID); + if (state === 'unverified') await auth.updateUser(UID, { emailVerified: false }); + if (state === 'revoked') { + await auth.revokeRefreshTokens(UID); + const fresh = await auth.getUser(UID); + context.auth.token.auth_time = Math.floor(Date.parse(fresh.tokensValidAfterTime) / 1000) - 1; + } + await expect(service.getMyRunnerConnectionProfile.run({}, context)).rejects.toMatchObject({ code: 'permission-denied' }); + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'permission-denied' }); + expect(await auditCount()).toBe(1); + }); + + test.each(['missing-app', 'missing-auth', 'unverified-token', 'future-auth-time', 'bad-uid', 'missing-no-store-response'])('%s fails before Auth or profile access', async (state) => { + if (state === 'missing-app') delete context.app; + if (state === 'missing-auth') delete context.auth; + if (state === 'unverified-token') context.auth.token.email_verified = false; + if (state === 'future-auth-time') context.auth.token.auth_time += 100; + if (state === 'bad-uid') context.auth.uid = '../synthetic-other'; + if (state === 'missing-no-store-response') delete context.rawRequest; + await expect(service.getMyRunnerConnectionProfile.run({}, context)).rejects.toBeDefined(); + expect(getUser).not.toHaveBeenCalled(); + expect((await db.collection('ratelimits').doc(`runner_connection_read__${accountRateKey(UID)}`).get()).exists).toBe(false); + }); + + test('a request cannot specify a target; invalid input consumes no meter or Auth read', async () => { + await expect(service.saveMyRunnerConnectionProfile.run({ ...command(), uid: OTHER }, context)).rejects.toMatchObject({ code: 'invalid-argument' }); + await expect(service.getMyRunnerConnectionProfile.run({ uid: OTHER }, context)).rejects.toMatchObject({ code: 'invalid-argument' }); + expect(getUser).not.toHaveBeenCalled(); + expect((await db.collection('ratelimits').doc(`runner_connection_save__${accountRateKey(UID)}`).get()).exists).toBe(false); + }); + + test('missing/false adult confirmation never reaches membership checks', async () => { + await admit(); + await expect(service.saveMyRunnerConnectionProfile.run({ ...command(), adultConfirmed: false }, context)).rejects.toMatchObject({ code: 'invalid-argument' }); + expect(getUser).not.toHaveBeenCalled(); + expect(await auditCount()).toBe(0); + }); + + test('owner-only lookup cannot read another saved profile', async () => { + await admit(); + await service.saveMyRunnerConnectionProfile.run(command(), context); + await auth.createUser({ uid: OTHER, email: 'synthetic-connections-other@example.test', emailVerified: true }); + const otherContext = { ...context, auth: { uid: OTHER, token: { email_verified: true, auth_time: Math.floor(Date.now() / 1000) } } }; + clock = Date.now(); + expect(await service.getMyRunnerConnectionProfile.run({}, otherContext)).toMatchObject({ revision: 0, profile: null }); + }); + + test('retries consume a bounded rate budget without blocking the separate withdrawal bucket', async () => { + await admit(); + for (let attempt = 0; attempt < 12; attempt += 1) await service.saveMyRunnerConnectionProfile.run(command(), context); + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'resource-exhausted' }); + expect(await auditCount()).toBe(1); + expect(await service.withdrawMyRunnerConnectionProfile.run({ requestId: requestId(2), expectedRevision: 1 }, context)) + .toMatchObject({ profile: null, adultConfirmed: false }); + const bucket = (await db.collection('ratelimits').doc(`runner_connection_save__${accountRateKey(UID)}`).get()).data(); + expect(bucket.count).toBe(12); + expect(JSON.stringify(bucket)).not.toContain(UID); + }); + + test('Auth outage reports fixed unavailability without changing a profile', async () => { + await admit(); + getUser.mockRejectedValue(new Error('synthetic-private-provider-canary')); + await expect(service.saveMyRunnerConnectionProfile.run(command(), context)).rejects.toMatchObject({ code: 'unavailable', message: 'Runner connection service is unavailable.' }); + expect(await auditCount()).toBe(0); + }); + + test('reads stop at their per-account budget before another Auth lookup', async () => { + for (let attempt = 0; attempt < 30; attempt += 1) await service.getMyRunnerConnectionProfile.run({}, context); + const reads = getUser.mock.calls.length; + await expect(service.getMyRunnerConnectionProfile.run({}, context)).rejects.toMatchObject({ code: 'resource-exhausted' }); + expect(getUser).toHaveBeenCalledTimes(reads); + expect(await auditCount()).toBe(0); + }); +}); diff --git a/functions/runnerConnectionService.js b/functions/runnerConnectionService.js new file mode 100644 index 00000000..c8a3394a --- /dev/null +++ b/functions/runnerConnectionService.js @@ -0,0 +1,166 @@ +'use strict'; + +const { createHash } = require('node:crypto'); +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const { checkRateLimit } = require('./rateLimit'); +const { readExactDataObject, isSafeUid } = require('./memberDirectoryProjection'); +const { deriveMembershipEntitlement } = require('./membershipAuthority'); +const { createRunnerProfileStore, readRunnerProfileRequest } = require('./runnerConnectionProfiles'); +const { createRunnerRecommendationStore, readExclusionRequest, readExclusionLookup } = require('./runnerConnectionRecommendations'); + +// Additive consumer contract, NOT an enrollment/payment writer. Before enabling +// a service, #81/#114/#115 must establish approved canonical records and unique +// account association here. Missing, duplicate or invalid records deny saves. +const MEMBERSHIP_COLLECTION = 'memberships'; +const RUNTIME = Object.freeze({ + enforceAppCheck: true, minInstances: 0, maxInstances: 2, memory: '256MB', timeoutSeconds: 30, +}); +const LIMITS = Object.freeze({ read: 30, save: 12, withdraw: 12, recommend: 6, exclude: 30, exclusionRead: 30 }); +const MESSAGES = Object.freeze({ + 'failed-precondition': 'Runner connections are not available yet.', + unauthenticated: 'Sign in again to use runner connections.', + 'permission-denied': 'Runner connection access is unavailable.', + 'invalid-argument': 'Runner connection request is invalid.', + 'resource-exhausted': 'Too many requests. Please wait before trying again.', + aborted: 'Runner connection profile changed. Reload before trying again.', + unavailable: 'Runner connection service is unavailable.', +}); + +function error(code) { return new functions.https.HttpsError(code, MESSAGES[code]); } + +function accountRateKey(uid) { + return createHash('sha256').update(JSON.stringify(['runner_connections_v1', uid])).digest('hex'); +} + +function requireContext(context, timestamp) { + // context.app/auth are supplied by the callable runtime, never request data. + // Native App Check enforcement is unconditional; no ENFORCE_APP_CHECK switch. + if (typeof context?.app?.appId !== 'string' || !context.app.appId.length) { + throw error('permission-denied'); + } + const uid = context.auth?.uid; + const authTime = context.auth?.token?.auth_time; + if (!isSafeUid(uid) || context.auth.token?.email_verified !== true + || !Number.isSafeInteger(authTime) || authTime <= 0 + || authTime > Math.floor(timestamp / 1000)) throw error('unauthenticated'); + return Object.freeze({ uid, authTime }); +} + +async function currentAccount(auth, subject) { + let user; + try { user = await auth.getUser(subject.uid); } catch (failure) { + if (failure?.code === 'auth/user-not-found') return false; + throw error('unavailable'); + } + // Admin SDK represents a never-revoked account with undefined. Match that + // documented SDK meaning only for absence; malformed present values deny. + const validAfter = user.tokensValidAfterTime === undefined ? 0 + : (typeof user.tokensValidAfterTime === 'string' ? Date.parse(user.tokensValidAfterTime) : NaN); + return user.uid === subject.uid && user.disabled === false && user.emailVerified === true + && Number.isSafeInteger(validAfter) && validAfter >= 0 + // A candidate has no calling session. Only the server supplies null here; + // requireContext still requires a positive auth_time from every caller. + && (subject.authTime === null || subject.authTime * 1000 >= validAfter); +} + +async function currentMembership(db, transaction, uid, now) { + // A two-record bound detects ambiguity without reading the roster. Association + // is explicit, never inferred from email, account role or a browser profile. + const matches = await transaction.get(db.collection(MEMBERSHIP_COLLECTION) + .where('association.uid', '==', uid).limit(2)); + if (matches.size !== 1) return false; + const record = matches.docs[0].data(); + if (record.membershipId !== matches.docs[0].id) return false; + try { + return deriveMembershipEntitlement({ + membershipAuthoritySchemaVersion: 1, record, uid, asOfMs: now(), + }).entitlement === 'current_member'; + } catch { return false; } +} + +/** + * Disabled-by-default callable factory. Not imported/exported by index.js. + * Enabling requires reviewed membership population, App Check and staged proof; + * neither caller input nor environment variables can flip the source gate. + */ +function createRunnerConnectionCallables({ + enabled = false, db, auth, rateLimit = checkRateLimit, now = Date.now, +} = {}) { + function callable(operation) { + return functions.runWith(RUNTIME).https.onCall(async (data, context) => { + try { + const response = context?.rawRequest?.res; + if (typeof response?.setHeader !== 'function') throw error('unavailable'); + response.setHeader('Cache-Control', 'private, no-store, max-age=0'); + response.setHeader('Pragma', 'no-cache'); + if (enabled !== true) throw error('failed-precondition'); + const timestamp = now(); + if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw error('unavailable'); + const subject = requireContext(context, timestamp); + let request; + try { + if (operation === 'read' || operation === 'recommend') request = readExactDataObject(data, []); + else if (operation === 'exclude') request = readExclusionRequest(data); + else if (operation === 'exclusionRead') request = readExclusionLookup(data); + else request = readRunnerProfileRequest(data, operation); + } catch { throw error('invalid-argument'); } + // Meter each network attempt, even retries and denied members. Withdrawal + // has its own bucket so exhausted browsing/save limits cannot strand it. + await rateLimit({ + scope: `runner_connection_${operation}`, key: accountRateKey(subject.uid), + limit: LIMITS[operation], windowMs: 60 * 60 * 1000, + }); + const database = db || admin.firestore(); + const authentication = auth || admin.auth(); + if (['recommend', 'exclude', 'exclusionRead'].includes(operation)) { + const recommendations = createRunnerRecommendationStore({ + db: database, now, + authorize: async ({ uid, operation: attemptedOperation, transaction }) => { + if (uid !== subject.uid || attemptedOperation !== operation + || !await currentAccount(authentication, subject)) return false; + return operation !== 'recommend' + || currentMembership(database, transaction, uid, now); + }, + candidateEligible: async ({ uid, transaction }) => + await currentAccount(authentication, { uid, authTime: null }) + && currentMembership(database, transaction, uid, now), + }); + if (operation === 'recommend') return await recommendations.recommend(subject.uid); + if (operation === 'exclusionRead') return await recommendations.readExclusion(subject.uid, request); + return await recommendations.setExclusion(subject.uid, request); + } + const store = createRunnerProfileStore({ + db: database, now, + authorize: async ({ uid, operation: attemptedOperation, transaction }) => { + if (uid !== subject.uid || attemptedOperation !== operation + || !await currentAccount(authentication, subject)) return false; + // Ownership, current account and a non-revoked session suffice to + // read/remove one's own card after membership expires. No other + // profiles, recommendations or renewed discovery are granted. + return operation !== 'save' + || currentMembership(database, transaction, uid, now); + }, + }); + if (operation === 'read') return await store.readOwn(subject.uid); + if (operation === 'save') return await store.saveOwn(subject.uid, request); + return await store.withdrawOwn(subject.uid, request); + } catch (failure) { + const code = typeof failure?.code === 'string' && Object.hasOwn(MESSAGES, failure.code) + ? failure.code : 'unavailable'; + // Never forward an SDK error's message, details, request or identifiers. + throw error(code); + } + }); + } + return Object.freeze({ + getMyRunnerConnectionProfile: callable('read'), + saveMyRunnerConnectionProfile: callable('save'), + withdrawMyRunnerConnectionProfile: callable('withdraw'), + getMyRunnerConnectionRecommendations: callable('recommend'), + getMyRunnerConnectionExclusion: callable('exclusionRead'), + setMyRunnerConnectionExclusion: callable('exclude'), + }); +} + +module.exports = { MEMBERSHIP_COLLECTION, RUNTIME, accountRateKey, createRunnerConnectionCallables }; diff --git a/functions/runnerConnectionService.test.js b/functions/runnerConnectionService.test.js new file mode 100644 index 00000000..f1584c08 --- /dev/null +++ b/functions/runnerConnectionService.test.js @@ -0,0 +1,62 @@ +'use strict'; + +jest.mock('firebase-functions', () => { + const sdk = jest.requireActual('firebase-functions'); + // Observe configuration while retaining the installed SDK's real builder, + // callable wrapper and deployment metadata (the export is a readonly getter). + return { ...sdk, runWith: jest.fn((options) => sdk.runWith(options)) }; +}); + +const fs = require('node:fs'); +const path = require('node:path'); +const functions = require('firebase-functions'); +const { createRunnerConnectionCallables, RUNTIME, accountRateKey } = require('./runnerConnectionService'); +const { readRunnerProfileRequest } = require('./runnerConnectionProfiles'); +const { profile } = require('./testSupport/runnerConnectionsFixtures'); + +test('native callable builder requires App Check and bounded resources for every operation', () => { + functions.runWith.mockClear(); + const callables = createRunnerConnectionCallables(); + expect(Object.keys(callables)).toHaveLength(6); + expect(functions.runWith).toHaveBeenCalledTimes(6); + for (const [options] of functions.runWith.mock.calls) expect(options).toEqual(RUNTIME); + expect(RUNTIME.enforceAppCheck).toBe(true); + for (const callable of Object.values(callables)) { + expect(callable.__endpoint).toMatchObject({ platform: 'gcfv1', minInstances: 0, maxInstances: 2, availableMemoryMb: 256, timeoutSeconds: 30 }); + expect(callable.__endpoint.callableTrigger).toBeDefined(); + } +}); + +test.each([undefined, false, 'true', 1])('the source gate %p grants no service work', async (enabled) => { + const rateLimit = jest.fn(); + const db = { runTransaction: jest.fn() }; + const auth = { getUser: jest.fn() }; + const callables = createRunnerConnectionCallables({ enabled, db, auth, rateLimit }); + for (const callable of Object.values(callables)) { + await expect(callable.run({}, { rawRequest: { res: { setHeader: jest.fn() } } })).rejects.toMatchObject({ code: 'failed-precondition' }); + } + expect(rateLimit).not.toHaveBeenCalled(); + expect(db.runTransaction).not.toHaveBeenCalled(); + expect(auth.getUser).not.toHaveBeenCalled(); +}); + +test('wire request snapshot cannot change while asynchronous authorization is in progress', () => { + const input = { + requestId: '00000000-0000-4000-8000-000000000001', expectedRevision: 0, + profile: profile(), consent: { memberDiscovery: true, similarity: true, broadenCircle: false }, + consentVersion: 1, adultConfirmed: true, + }; + const request = readRunnerProfileRequest(input, 'save'); + input.profile.displayName = 'Changed Synthetic Name'; + input.consent.memberDiscovery = false; + expect(request.profile.displayName).toBe('Synthetic Runner'); + expect(request.consent.memberDiscovery).toBe(true); + expect(Object.isFrozen(request)).toBe(true); + expect(() => readRunnerProfileRequest(input, 'unknown')).toThrow('Runner connection request is invalid.'); +}); + +test('factory is not imported or exported by the deployment index; no runtime widening', () => { + expect(fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8')).not.toMatch(/runnerConnection/); + expect(accountRateKey('synthetic-a')).toMatch(/^[0-9a-f]{64}$/); + expect(accountRateKey('synthetic-a')).not.toBe(accountRateKey('synthetic-b')); +}); diff --git a/functions/runnerConnections.js b/functions/runnerConnections.js new file mode 100644 index 00000000..ac9d35b4 --- /dev/null +++ b/functions/runnerConnections.js @@ -0,0 +1,243 @@ +'use strict'; + +// Domain logic only: this module cannot authenticate a caller or authorize a +// response. The future server adapter must load and recheck current membership, +// adult eligibility, consent and reciprocal exclusions before delivering cards. +// Nothing here reads the roster, writes Firestore or exports a Cloud Function. +const { types: { isProxy } } = require('node:util'); + +const RANKER_VERSION = 1; +const MAX_CANDIDATES = 48; +const EXPERIENCES = Object.freeze(['beginner', 'returning', 'regular', 'experienced']); +const PROFILE_FIELDS = Object.freeze([ + 'displayName', 'pace', 'distance', 'availability', 'areas', 'terrains', + 'styles', 'goals', 'experience', 'experiencePreferences', 'interests', +]); +const OPTIONS = Object.freeze({ + areas: Object.freeze(['bay_trail', 'san_mateo', 'foster_city', 'belmont', 'redwood_city']), + terrains: Object.freeze(['road', 'trail']), + styles: Object.freeze(['continuous', 'run_walk', 'walk']), + goals: Object.freeze([ + 'consistency', 'first_5k', 'first_10k', 'half_marathon', 'marathon', + 'trails', 'social', 'return_gradually', 'volunteer', 'mentor', + ]), + interests: Object.freeze(['coffee', 'food', 'books', 'music', 'outdoors', 'volunteering']), +}); + +function invalid() { + throw new Error('Runner connection input is invalid.'); +} + +function exactObject(value, fields) { + if (value === null || typeof value !== 'object' || isProxy(value) + || Object.getPrototypeOf(value) !== Object.prototype) invalid(); + const keys = Reflect.ownKeys(value); + if (keys.length !== fields.length || keys.some((key) => !fields.includes(key))) invalid(); + const result = Object.create(null); + for (const key of fields) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalid(); + result[key] = descriptor.value; + } + return result; +} + +function integer(value, min, max) { + if (!Number.isSafeInteger(value) || Object.is(value, -0) || value < min || value > max) invalid(); + return value; +} + +function boundedArray(value, min, max) { + if (isProxy(value) || !Array.isArray(value) + || Object.getPrototypeOf(value) !== Array.prototype) invalid(); + const size = integer(value.length, min, max); + if (Reflect.ownKeys(value).length !== size + 1) invalid(); + const result = []; + for (let index = 0; index < size; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalid(); + result.push(descriptor.value); + } + return result; +} + +function enumList(value, options, min = 1, max = options.length) { + const values = boundedArray(value, min, max); + if (values.some((item) => !options.includes(item)) || new Set(values).size !== values.length) invalid(); + return Object.freeze(values.sort()); +} + +function normalizePace(input) { + const pace = exactObject(input, ['unit', 'fastSeconds', 'slowSeconds']); + if (!['min/km', 'min/mile'].includes(pace.unit)) invalid(); + integer(pace.fastSeconds, 120, 3000); + integer(pace.slowSeconds, pace.fastSeconds, 3000); + // One international mile is exactly 1.609344 km. Inputs are whole seconds in + // the stated unit; canonical values round to the nearest second per km. + const convert = (value) => (pace.unit === 'min/km' + ? value : Math.round(value * 1_000_000 / 1_609_344)); + const fastSecondsPerKm = integer(convert(pace.fastSeconds), 120, 1800); + const slowSecondsPerKm = integer(convert(pace.slowSeconds), fastSecondsPerKm, 1800); + return Object.freeze({ fastSecondsPerKm, slowSecondsPerKm, preferredUnit: pace.unit }); +} + +function readRunnerProfile(input) { + const data = exactObject(input, PROFILE_FIELDS); + if (typeof data.displayName !== 'string' || data.displayName.length > 60 + || !data.displayName.trim() || /[\p{Cc}\p{Cf}\uD800-\uDFFF]/u.test(data.displayName)) invalid(); + const distance = exactObject(data.distance, ['minMetres', 'maxMetres']); + integer(distance.minMetres, 500, 50000); + integer(distance.maxMetres, distance.minMetres, 50000); + const availability = boundedArray(data.availability, 1, 7).map((value) => { + const window = exactObject(value, ['day', 'startMinute', 'endMinute']); + integer(window.day, 0, 6); + integer(window.startMinute, 0, 1439); + integer(window.endMinute, window.startMinute + 1, 1440); + return Object.freeze({ day: window.day, startMinute: window.startMinute, endMinute: window.endMinute }); + }).sort((a, b) => a.day - b.day || a.startMinute - b.startMinute || a.endMinute - b.endMinute); + if (availability.some((window, index) => index > 0 + && window.day === availability[index - 1].day + && window.startMinute < availability[index - 1].endMinute)) invalid(); + if (!EXPERIENCES.includes(data.experience)) invalid(); + return Object.freeze({ + displayName: data.displayName.trim(), + pace: normalizePace(data.pace), + distance: Object.freeze({ minMetres: distance.minMetres, maxMetres: distance.maxMetres }), + availability: Object.freeze(availability), + areas: enumList(data.areas, OPTIONS.areas, 1, 3), + terrains: enumList(data.terrains, OPTIONS.terrains), + styles: enumList(data.styles, OPTIONS.styles), + goals: enumList(data.goals, OPTIONS.goals, 1, 5), + experience: data.experience, + experiencePreferences: enumList(data.experiencePreferences, EXPERIENCES), + interests: enumList(data.interests, OPTIONS.interests, 0, 4), + }); +} + +function readRunner(input) { + const data = exactObject(input, [ + 'entryRef', 'memberEligible', 'adultEligible', 'pairExcluded', 'consent', 'profile', + ]); + if (typeof data.entryRef !== 'string' || !/^runner_[0-9a-f]{64}$/.test(data.entryRef)) invalid(); + const consent = readConnectionConsent(data.consent); + for (const value of [data.memberEligible, data.adultEligible, data.pairExcluded]) { + if (typeof value !== 'boolean') invalid(); + } + return Object.freeze({ + entryRef: data.entryRef, + eligible: data.memberEligible && data.adultEligible && !data.pairExcluded && consent.memberDiscovery, + consent: Object.freeze(consent), + profile: readRunnerProfile(data.profile), + }); +} + +function readConnectionConsent(input) { + const consent = exactObject(input, ['memberDiscovery', 'similarity', 'broadenCircle']); + if (Object.values(consent).some((value) => typeof value !== 'boolean')) invalid(); + if (!consent.memberDiscovery && (consent.similarity || consent.broadenCircle)) invalid(); + return Object.freeze({ + memberDiscovery: consent.memberDiscovery, + similarity: consent.similarity, + broadenCircle: consent.broadenCircle, + }); +} + +function readRunnerProfileInput(input) { + const canonical = readRunnerProfile(input); + const pace = exactObject(exactObject(input, PROFILE_FIELDS).pace, ['unit', 'fastSeconds', 'slowSeconds']); + // Preserve the entered unit/seconds for an exact edit round trip. All other + // fields come from the closed, freshly allocated canonical projection. + return Object.freeze({ + ...canonical, + pace: Object.freeze({ unit: pace.unit, fastSeconds: pace.fastSeconds, slowSeconds: pace.slowSeconds }), + }); +} + +function shared(left, right) { + return left.filter((value) => right.includes(value)); +} + +function compatibility(left, right) { + const fast = Math.max(left.pace.fastSecondsPerKm, right.pace.fastSecondsPerKm); + const slow = Math.min(left.pace.slowSecondsPerKm, right.pace.slowSecondsPerKm); + const distance = Math.max(left.distance.minMetres, right.distance.minMetres); + if (fast > slow || distance > Math.min(left.distance.maxMetres, right.distance.maxMetres) + || !left.experiencePreferences.includes(right.experience) + || !right.experiencePreferences.includes(left.experience)) return null; + const categories = ['areas', 'terrains', 'styles']; + if (categories.some((key) => shared(left[key], right[key]).length === 0)) return null; + const minimumMinutes = Math.ceil(distance * fast / 60000); + const timeFits = left.availability.some((a) => right.availability.some((b) => a.day === b.day + && Math.min(a.endMinute, b.endMinute) - Math.max(a.startMinute, b.startMinute) >= minimumMinutes)); + if (!timeFits) return null; + const goals = shared(left.goals, right.goals); + const interests = shared(left.interests, right.interests); + const reasons = ['pace_overlap', 'shared_availability', 'shared_area', 'shared_distance', 'shared_terrain', 'shared_run_style']; + if (goals.length) reasons.push('shared_goals'); + if (interests.length) reasons.push('shared_interests'); + const differences = []; + if (left.experience !== right.experience) differences.push('different_experience'); + if (!goals.length) differences.push('different_goals'); + if (left.interests.length && right.interests.length && !interests.length) differences.push('different_interests'); + // Optional interests add explanations, not a score penalty when absent. + return { score: goals.length * 4 + (left.experience === right.experience ? 2 : 0), reasons, differences }; +} + +function card(candidate, reasons) { + const p = candidate.profile; + return Object.freeze({ + entryRef: candidate.entryRef, + displayName: p.displayName, + pace: p.pace, + distance: p.distance, + availability: p.availability, + areas: p.areas, + terrains: p.terrains, + styles: p.styles, + goals: p.goals, + experience: p.experience, + interests: p.interests, + reasons: Object.freeze([...reasons]), + }); +} + +function rankRunnerConnections(viewerInput, candidateInputs) { + const viewer = readRunner(viewerInput); + const inputs = boundedArray(candidateInputs, 0, MAX_CANDIDATES); + const response = (similar, broaden) => Object.freeze({ + rankerVersion: RANKER_VERSION, + similar: Object.freeze(similar), + broaden: Object.freeze(broaden), + }); + if (!viewer.eligible) return response([], []); + const seen = new Set(); + const matches = []; + for (const input of inputs) { + let candidate; + try { candidate = readRunner(input); } catch { continue; } + if (seen.has(candidate.entryRef)) invalid(); + seen.add(candidate.entryRef); + if (!candidate.eligible || candidate.entryRef === viewer.entryRef) continue; + const match = compatibility(viewer.profile, candidate.profile); + if (match) matches.push({ candidate, ...match }); + } + const compare = (a, b) => b.score - a.score + || (a.candidate.entryRef < b.candidate.entryRef ? -1 : 1); + matches.sort(compare); + const broadening = viewer.consent.broadenCircle + ? matches.filter((match) => match.candidate.consent.broadenCircle && match.differences.length) + .sort((a, b) => b.differences.length - a.differences.length || compare(a, b))[0] + : undefined; + const similar = viewer.consent.similarity + ? matches.filter((match) => match !== broadening && match.candidate.consent.similarity) + .slice(0, 4).map((match) => card(match.candidate, match.reasons)) + : []; + return response(similar, broadening + ? [card(broadening.candidate, [...broadening.reasons, ...broadening.differences])] + : []); +} + +module.exports = { + RANKER_VERSION, MAX_CANDIDATES, normalizePace, readRunnerProfile, + readRunnerProfileInput, readConnectionConsent, rankRunnerConnections, +}; diff --git a/functions/runnerConnections.test.js b/functions/runnerConnections.test.js new file mode 100644 index 00000000..fe78c738 --- /dev/null +++ b/functions/runnerConnections.test.js @@ -0,0 +1,167 @@ +'use strict'; + +const { + normalizePace, + readRunnerProfile, + rankRunnerConnections, +} = require('./runnerConnections'); + +const { profile, runner } = require('./testSupport/runnerConnectionsFixtures'); + +describe('runner profile boundary', () => { + test('normalizes equivalent metric and imperial easy pace', () => { + expect(normalizePace({ unit: 'min/km', fastSeconds: 300, slowSeconds: 360 })) + .toEqual({ fastSecondsPerKm: 300, slowSecondsPerKm: 360, preferredUnit: 'min/km' }); + expect(normalizePace({ unit: 'min/mile', fastSeconds: 483, slowSeconds: 579 })) + .toEqual({ fastSecondsPerKm: 300, slowSecondsPerKm: 360, preferredUnit: 'min/mile' }); + }); + + test.each([ + { unit: 'mph', fastSeconds: 300, slowSeconds: 400 }, + { unit: 'min/km', fastSeconds: NaN, slowSeconds: 400 }, + { unit: 'min/km', fastSeconds: 300, slowSeconds: Infinity }, + { unit: 'min/km', fastSeconds: '300', slowSeconds: 400 }, + { unit: 'min/km', fastSeconds: 300.5, slowSeconds: 400 }, + { unit: 'min/km', fastSeconds: 420, slowSeconds: 300 }, + { unit: 'min/km', fastSeconds: 0, slowSeconds: 400 }, + { unit: 'min/km', fastSeconds: 300, slowSeconds: 1801 }, + { unit: 'min/mile', fastSeconds: 60, slowSeconds: 100 }, + { unit: 'min/km', fastSeconds: 300, slowSeconds: 400, extra: true }, + ])('rejects invalid pace case %# with a fixed message', (pace) => { + expect(() => normalizePace(pace)).toThrow('Runner connection input is invalid.'); + }); + + test.each([ + { displayName: '' }, + { displayName: 'a'.repeat(61) }, + { displayName: 'Synthetic\nRunner' }, + { goals: [] }, + { goals: ['unknown_goal'] }, + { goals: ['consistency', 'consistency'] }, + { areas: [] }, + { distance: { minMetres: 10000, maxMetres: 4000 } }, + { availability: [{ day: 7, startMinute: 480, endMinute: 600 }] }, + { availability: [{ day: 6, startMinute: 600, endMinute: 480 }] }, + { availability: Array(8).fill({ day: 6, startMinute: 480, endMinute: 600 }) }, + { experiencePreferences: [] }, + { email: 'synthetic@example.test' }, + { demographics: { ageBand: 'synthetic' } }, + { searchableByOfficers: true }, + ])('rejects invalid or unapproved profile field case %#', (overrides) => { + expect(() => readRunnerProfile(profile(overrides))).toThrow('Runner connection input is invalid.'); + }); + + test('does not execute accessors or proxies', () => { + const getter = jest.fn(() => 'Synthetic'); + const input = profile(); + Object.defineProperty(input, 'displayName', { enumerable: true, get: getter }); + expect(() => readRunnerProfile(input)).toThrow('Runner connection input is invalid.'); + expect(getter).not.toHaveBeenCalled(); + const trap = jest.fn(() => { throw new Error('private canary'); }); + expect(() => readRunnerProfile(new Proxy({}, { ownKeys: trap }))).toThrow('Runner connection input is invalid.'); + expect(trap).not.toHaveBeenCalled(); + }); +}); + +describe('bounded deterministic runner recommendations (not authorization)', () => { + test('ranks compatible goals first, preserves inputs and returns only card fields', () => { + const viewer = runner(0); + const candidates = [runner(2, { profile: profile({ goals: ['social'] }) }), runner(1)]; + const before = JSON.stringify(candidates); + const result = rankRunnerConnections(viewer, candidates); + expect(result.rankerVersion).toBe(1); + expect(result.similar.map((card) => card.entryRef)).toEqual([runner(1).entryRef, runner(2).entryRef]); + expect(result.similar[0].reasons).toContain('shared_goals'); + expect(Object.keys(result.similar[0]).sort()).toEqual([ + 'entryRef', 'displayName', 'pace', 'distance', 'availability', 'areas', + 'terrains', 'styles', 'goals', 'experience', 'interests', 'reasons', + ].sort()); + expect(JSON.stringify(result)).not.toMatch(/memberEligible|adultEligible|pairExcluded|experiencePreferences|consent/); + expect(JSON.stringify(candidates)).toBe(before); + expect(Object.isFrozen(result.similar[0].pace)).toBe(true); + }); + + test.each([ + { pace: { unit: 'min/km', fastSeconds: 421, slowSeconds: 500 } }, + { distance: { minMetres: 11000, maxMetres: 15000 } }, + { availability: [{ day: 6, startMinute: 660, endMinute: 700 }] }, + { availability: [{ day: 5, startMinute: 480, endMinute: 660 }] }, + { areas: ['redwood_city'] }, + { terrains: ['trail'] }, + { styles: ['walk'] }, + { experiencePreferences: ['experienced'] }, + ])('excludes hard incompatibility case %# even with broadening', (overrides) => { + const consent = { memberDiscovery: true, similarity: true, broadenCircle: true }; + const result = rankRunnerConnections(runner(0, { consent }), [runner(1, { consent, profile: profile(overrides) })]); + expect(result.similar).toEqual([]); + expect(result.broaden).toEqual([]); + }); + + test('excludes a shared time window too short for the shared run distance', () => { + const candidate = runner(1, { profile: profile({ availability: [{ day: 6, startMinute: 480, endMinute: 490 }] }) }); + expect(rankRunnerConnections(runner(0), [candidate]).similar).toEqual([]); + }); + + test('requires reciprocal experience preference', () => { + const viewer = runner(0, { profile: profile({ experiencePreferences: ['beginner'] }) }); + const candidate = runner(1, { profile: profile({ experience: 'experienced' }) }); + expect(rankRunnerConnections(viewer, [candidate]).similar).toEqual([]); + }); + + test.each([ + { memberEligible: false }, + { adultEligible: false }, + { pairExcluded: true }, + { consent: { memberDiscovery: false, similarity: false, broadenCircle: false } }, + { consent: { searchableByOfficers: true } }, + { consent: { memberDiscovery: true, similarity: false, broadenCircle: false } }, + ])('does not deliver excluded/withdrawn candidate case %#', (overrides) => { + expect(rankRunnerConnections(runner(0), [runner(1, overrides)]).similar).toEqual([]); + }); + + test('loss of viewer eligibility also clears all results', () => { + const result = rankRunnerConnections(runner(0, { memberEligible: false }), [runner(1)]); + expect(result.similar).toEqual([]); + expect(result.broaden).toEqual([]); + }); + + test('both parties must opt into broadening; one different card never duplicates an ordinary card', () => { + const consent = { memberDiscovery: true, similarity: true, broadenCircle: true }; + const candidates = [runner(1), runner(2, { consent, profile: profile({ experience: 'experienced', goals: ['marathon'] }) })]; + const result = rankRunnerConnections(runner(0, { consent }), candidates); + expect(result.broaden.map((card) => card.entryRef)).toEqual([runner(2).entryRef]); + expect(result.broaden[0].reasons).toContain('different_experience'); + expect(result.similar.map((card) => card.entryRef)).toEqual([runner(1).entryRef]); + expect(rankRunnerConnections(runner(0), candidates).broaden).toEqual([]); + expect(rankRunnerConnections(runner(0, { consent }), [runner(2)]).broaden).toEqual([]); + }); + + test('empty optional interests do not create an inferred difference', () => { + const consent = { memberDiscovery: true, similarity: true, broadenCircle: true }; + const candidate = runner(1, { consent, profile: profile({ interests: [] }) }); + const result = rankRunnerConnections(runner(0, { consent }), [candidate]); + expect(result.broaden).toEqual([]); + expect(result.similar).toHaveLength(1); + expect(result.similar[0].reasons).not.toContain('different_interests'); + }); + + test('stable order, bounded output and no manufactured sparse-pool cards', () => { + const candidates = Array.from({ length: 20 }, (_, index) => runner(index + 1)); + const first = rankRunnerConnections(runner(0), candidates); + expect(rankRunnerConnections(runner(0), [...candidates].reverse())).toEqual(first); + expect(first.similar).toHaveLength(4); + expect(first.broaden).toHaveLength(0); + expect(rankRunnerConnections(runner(0), [runner(1)]).similar).toHaveLength(1); + expect(rankRunnerConnections(runner(0), []).similar).toEqual([]); + }); + + test('duplicate candidate identities fail closed rather than filling multiple slots', () => { + expect(() => rankRunnerConnections(runner(0), [runner(1), runner(1)])).toThrow('Runner connection input is invalid.'); + }); + + test('rejects oversized candidate windows; excludes self and malformed stored profiles', () => { + expect(() => rankRunnerConnections(runner(0), Array.from({ length: 49 }, (_, index) => runner(index + 1)))) + .toThrow('Runner connection input is invalid.'); + expect(rankRunnerConnections(runner(0), [runner(0), runner(1, { profile: profile({ email: 'synthetic@example.test' }) })]).similar).toEqual([]); + }); +}); diff --git a/functions/testSupport/runnerConnectionsFixtures.js b/functions/testSupport/runnerConnectionsFixtures.js new file mode 100644 index 00000000..bec15abb --- /dev/null +++ b/functions/testSupport/runnerConnectionsFixtures.js @@ -0,0 +1,33 @@ +'use strict'; + +// Fictional structured profiles only. Never replace these with member records. +function profile(overrides = {}) { + return { + displayName: 'Synthetic Runner', + pace: { unit: 'min/km', fastSeconds: 300, slowSeconds: 420 }, + distance: { minMetres: 4000, maxMetres: 10000 }, + availability: [{ day: 6, startMinute: 480, endMinute: 660 }], + areas: ['bay_trail'], + terrains: ['road'], + styles: ['continuous', 'run_walk'], + goals: ['consistency', 'first_10k'], + experience: 'beginner', + experiencePreferences: ['beginner', 'returning', 'regular', 'experienced'], + interests: ['coffee'], + ...overrides, + }; +} + +function runner(index, overrides = {}) { + return { + entryRef: `runner_${index.toString(16).padStart(64, '0')}`, + memberEligible: true, + adultEligible: true, + pairExcluded: false, + consent: { memberDiscovery: true, similarity: true, broadenCircle: false }, + profile: profile(), + ...overrides, + }; +} + +module.exports = { profile, runner }; diff --git a/functions/testSupport/runnerHttp/admin.js b/functions/testSupport/runnerHttp/admin.js new file mode 100644 index 00000000..9379e03f --- /dev/null +++ b/functions/testSupport/runnerHttp/admin.js @@ -0,0 +1,26 @@ +'use strict'; + +const { generateKeyPairSync } = require('node:crypto'); +const admin = require('firebase-admin'); +const { PROJECT, assertRunnerEmulators } = require('./environment'); + +function initializeRunnerAdmin() { + assertRunnerEmulators(process.env); + // Admin Firestore requires its concrete certificate credential type. Generate + // a throwaway key in memory: never discover/read a developer's ADC file and + // never persist/print this test-only key. The loopback emulators use 'owner'. + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + const credential = admin.credential.cert({ + projectId: PROJECT, + clientEmail: `synthetic@${PROJECT}.iam.gserviceaccount.com`, + privateKey, + }); + credential.getAccessToken = async () => ({ access_token: 'owner', expires_in: 3600 }); + return admin.initializeApp({ projectId: PROJECT, credential }); +} + +module.exports = { initializeRunnerAdmin }; diff --git a/functions/testSupport/runnerHttp/browserPolicy.test.js b/functions/testSupport/runnerHttp/browserPolicy.test.js new file mode 100644 index 00000000..08d97bd5 --- /dev/null +++ b/functions/testSupport/runnerHttp/browserPolicy.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const { route, HEADERS, ORIGIN } = require('../../../tests/runner-browser/policy.cjs'); +test.each(['/', '/bundle.js', '/fixture.json'])('only exact local test assets are served: %s', (url) => { + expect(route('GET', url, '127.0.0.1:9619')).toBe(url); + expect(route('GET', url, '127.0.0.1:9619', ORIGIN)).toBe(url); +}); +test.each([ + ['GET', '/fixture.json', 'localhost:9619', undefined], + ['GET', '/fixture.json', 'synthetic.example:9619', undefined], + ['GET', '/fixture.json', '127.0.0.1:9619', 'https://example.test'], + ['GET', '/../package.json', '127.0.0.1:9619', undefined], + ['GET', '/fixture.json?project=production', '127.0.0.1:9619', undefined], + ['POST', '/finish', '127.0.0.1:9619', undefined], + ['POST', '/finish', '127.0.0.1:9619', 'null'], + ['POST', '/finish', '127.0.0.1:9619', 'https://example.test'], + ['GET', '/finish', '127.0.0.1:9619', ORIGIN], + ['POST', '/fixture.json', '127.0.0.1:9619', ORIGIN], +])('refuses widened path, host, method or origin %#', (...args) => { + expect(route(...args)).toBeNull(); +}); +test('cleanup requires same-origin POST and CSP limits browser connections to loopback', () => { + expect(route('POST', '/finish', '127.0.0.1:9619', ORIGIN)).toBe('/finish'); + expect(HEADERS['Cache-Control']).toBe('no-store'); + expect(HEADERS['Content-Security-Policy']).toContain("connect-src 'self' http://127.0.0.1:9919 http://127.0.0.1:9519;"); + expect(HEADERS['Content-Security-Policy']).toContain("frame-ancestors 'none'"); +}); diff --git a/functions/testSupport/runnerHttp/environment.js b/functions/testSupport/runnerHttp/environment.js new file mode 100644 index 00000000..71c44102 --- /dev/null +++ b/functions/testSupport/runnerHttp/environment.js @@ -0,0 +1,35 @@ +'use strict'; + +const { assertSafeTestEnvironment } = require('../testSafety'); + +const PROJECT = 'demo-runner-http-test'; +const HOSTS = Object.freeze({ + FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9919', + FIRESTORE_EMULATOR_HOST: '127.0.0.1:9819', +}); + +function assertRunnerEmulators(environment, { runtime = false } = {}) { + try { + const checked = { ...environment }; + // Functions CLI adds an Eventarc URL even for HTTP-only exports. This + // harness never uses it. Accept only our exact loopback value, then remove + // it before applying the existing stricter host:port-only network guard. + if (checked.CLOUD_EVENTARC_EMULATOR_HOST !== undefined) { + if (checked.CLOUD_EVENTARC_EMULATOR_HOST !== 'http://127.0.0.1:9319') throw new Error(); + delete checked.CLOUD_EVENTARC_EMULATOR_HOST; + } + assertSafeTestEnvironment(checked); + if (environment.GCLOUD_PROJECT !== PROJECT + || Object.entries(HOSTS).some(([key, value]) => environment[key] !== value) + || (runtime && environment.FUNCTIONS_EMULATOR !== 'true')) throw new Error(); + } catch { + throw new Error('Runner HTTP tests require the exact isolated demo emulators.'); + } +} + +function prepareRunnerEmulators(environment, options) { + assertRunnerEmulators(environment, options); + delete environment.CLOUD_EVENTARC_EMULATOR_HOST; +} + +module.exports = { PROJECT, HOSTS, assertRunnerEmulators, prepareRunnerEmulators }; diff --git a/functions/testSupport/runnerHttp/environment.test.js b/functions/testSupport/runnerHttp/environment.test.js new file mode 100644 index 00000000..d29fed0e --- /dev/null +++ b/functions/testSupport/runnerHttp/environment.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const { PROJECT, HOSTS, assertRunnerEmulators, prepareRunnerEmulators } = require('./environment'); +const safe = () => ({ GCLOUD_PROJECT: PROJECT, ...HOSTS, FUNCTIONS_EMULATOR: 'true' }); + +test('runner HTTP harness accepts only the exact emulator project and hosts', () => { + expect(() => assertRunnerEmulators(safe(), { runtime: true })).not.toThrow(); +}); +test.each([ + { GCLOUD_PROJECT: 'run-mprc-staging' }, + { GCLOUD_PROJECT: 'demo-other-test' }, + { FIRESTORE_EMULATOR_HOST: undefined }, + { FIRESTORE_EMULATOR_HOST: 'remote.example.test:9819' }, + { FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' }, + { FUNCTIONS_EMULATOR: 'false' }, + { FIREBASE_CONFIG: JSON.stringify({ projectId: 'run-mprc-staging' }) }, + { GOOGLE_APPLICATION_CREDENTIALS: 'synthetic-forbidden-path' }, + { CLOUD_EVENTARC_EMULATOR_HOST: 'http://remote.example.test:9319' }, +])('runner HTTP harness rejects unsafe environment %#', (override) => { + expect(() => assertRunnerEmulators({ ...safe(), ...override }, { runtime: true })) + .toThrow('Runner HTTP tests require the exact isolated demo emulators.'); +}); +test('only our unused loopback Eventarc URL is removed before the network guard', () => { + const environment = { ...safe(), CLOUD_EVENTARC_EMULATOR_HOST: 'http://127.0.0.1:9319' }; + prepareRunnerEmulators(environment, { runtime: true }); + expect(environment).toEqual(safe()); +}); +test('harness runtime and SDK declarations match the committed Functions installation', () => { + const harness = require('./package.json'); + const parent = require('../../package.json'); + expect(harness.engines).toEqual(parent.engines); + expect(harness.dependencies).toEqual({ + 'firebase-admin': parent.dependencies['firebase-admin'], + 'firebase-functions': parent.dependencies['firebase-functions'], + }); + const path = require('node:path'); + for (const name of Object.keys(harness.dependencies)) { + expect(require.resolve(name, { paths: [__dirname] })) + .toBe(require.resolve(name, { paths: [path.resolve(__dirname, '../..')] })); + } +}); diff --git a/functions/testSupport/runnerHttp/index.js b/functions/testSupport/runnerHttp/index.js new file mode 100644 index 00000000..e8668cab --- /dev/null +++ b/functions/testSupport/runnerHttp/index.js @@ -0,0 +1,13 @@ +'use strict'; + +// Emulator-only entry point. Never imported by functions/index.js. No production +// feature switch, Auth override or App Check enforcement change is introduced. +const { prepareRunnerEmulators } = require('./environment'); +prepareRunnerEmulators(process.env, { runtime: true }); +const { installNetworkGuard } = require('../testSafety'); +installNetworkGuard(); +const { initializeRunnerAdmin } = require('./admin'); +const { createRunnerConnectionCallables } = require('../../runnerConnectionService'); + +initializeRunnerAdmin(); +module.exports = createRunnerConnectionCallables({ enabled: true }); diff --git a/functions/testSupport/runnerHttp/launcher.test.js b/functions/testSupport/runnerHttp/launcher.test.js new file mode 100644 index 00000000..08bef0d9 --- /dev/null +++ b/functions/testSupport/runnerHttp/launcher.test.js @@ -0,0 +1,121 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); +const root = path.resolve(__dirname, '../../..'); +const starter = fs.readFileSync(path.join(root, 'scripts/run-runner-transport-tests.cjs'), 'utf8'); +const adapter = fs.readFileSync(path.join(root, 'scripts/runner-transport-cli.cjs'), 'utf8'); +const config = '/tmp/mprc-runner-http-config-synthetic'; +const args = [ + 'emulators:exec', '--non-interactive', '--project', 'demo-runner-http-test', + '--config', 'runner-transport.firebase.json', '--only', 'auth,firestore,functions', + 'node --test tests/runner-transport.test.cjs', +]; + +function start(extraArgs = []) { + const child = Object.assign(new EventEmitter(), { kill: jest.fn() }); + const spawn = jest.fn(() => child); + const rmSync = jest.fn(); + const output = jest.fn(); + const process = Object.assign(new EventEmitter(), { + execPath: '/synthetic/node20/bin/node', + argv: ['node', 'script', ...extraArgs], + env: { + PATH: '/usr/bin', JAVA_HOME: '/synthetic/java21', TMPDIR: '/tmp', + HOME: '/synthetic/user', GOOGLE_APPLICATION_CREDENTIALS: '/synthetic/forbidden', + FIREBASE_TOKEN: 'synthetic-forbidden', STRIPE_SECRET: 'synthetic-forbidden', + NODE_OPTIONS: '--require synthetic-forbidden', GCLOUD_PROJECT: 'synthetic-forbidden', + XDG_CONFIG_HOME: '/synthetic/forbidden', + RUNNER_HTTP_BROWSER: '1', + }, + }); + vm.runInNewContext(starter, { + __dirname: path.join(root, 'scripts'), process, console: { error: output }, + require: (name) => { + if (name === 'node:fs') return { mkdtempSync: () => config, realpathSync: () => '/tmp', rmSync }; + if (name === 'node:os') return { tmpdir: () => '/tmp' }; + if (name === 'node:path') return path; + if (name === 'node:child_process') return { spawn }; + throw new Error('Unexpected launcher dependency.'); + }, + }); + return { child, spawn, process, rmSync, output }; +} +test('launcher uses fixed demo command and does not inherit credentials or cloud authority', () => { + const run = start(); + const [node, command, options] = run.spawn.mock.calls[0]; + expect(node).toBe('/synthetic/node20/bin/node'); + expect(command.slice(1)).toEqual(args); + expect(command[0]).toBe(path.join(root, 'scripts/runner-transport-cli.cjs')); + expect(options.env).toEqual({ + PATH: '/synthetic/node20/bin:/usr/bin', JAVA_HOME: '/synthetic/java21', TMPDIR: '/tmp', + CI: 'true', GCLOUD_PROJECT: 'demo-runner-http-test', REQUIRE_RUNNER_HTTP_EMULATOR: '1', + XDG_CONFIG_HOME: config, + }); + run.child.emit('exit', 0); + expect(run.rmSync).toHaveBeenCalledWith(config, { recursive: true, force: true }); + expect(run.process.exitCode).toBe(0); +}); +test('launcher propagates failure, forwards interruption and hides caught diagnostics', () => { + const run = start(); + run.process.emit('SIGINT'); + expect(run.child.kill).toHaveBeenCalledWith('SIGINT'); + run.child.emit('error', new Error('synthetic-private-diagnostic')); + run.child.emit('exit', 1); + expect(run.output).toHaveBeenCalledWith('runner_transport_launch_failed'); + expect(run.output).toHaveBeenCalledTimes(1); + expect(run.process.exitCode).toBe(1); + expect(run.rmSync).toHaveBeenCalledTimes(1); +}); +test('browser rehearsal is one closed alternative, not an arbitrary emulator command', () => { + const run = start(['--browser']); + const [, command, options] = run.spawn.mock.calls[0]; + expect(command.slice(1, -1)).toEqual(args.slice(0, -1)); + expect(command.at(-1)).toBe('node tests/runner-browser/server.cjs'); + expect(options.env.RUNNER_HTTP_BROWSER).toBe('1'); + expect(() => start(['deploy'])).toThrow('only the optional --browser'); + expect(() => start(['--browser', '--project', 'production'])).toThrow('only the optional --browser'); +}); +function adapt({ extraArgs = args, entries = [], environment = {} } = {}) { + const credentials = { hasDefaultCredentials: jest.fn(), getCredentialPathAsync: jest.fn() }; + const cli = jest.fn(); + vm.runInNewContext(adapter, { + process: { + argv: ['node', 'script', ...extraArgs], + env: { GCLOUD_PROJECT: 'demo-runner-http-test', REQUIRE_RUNNER_HTTP_EMULATOR: '1', XDG_CONFIG_HOME: config, ...environment }, + }, + require: (name) => { + if (name === 'node:fs') return { realpathSync: () => '/tmp', readdirSync: () => entries }; + if (name === 'node:os') return { tmpdir: () => '/tmp' }; + if (name === 'node:path') return path; + if (name === 'firebase-tools/lib/defaultCredentials') return credentials; + if (name === 'firebase-tools/lib/bin/firebase') return cli(); + throw new Error('Unexpected adapter dependency.'); + }, + }); + return { credentials, cli }; +} +test('CLI adapter removes credential discovery/export before loading the actual CLI', async () => { + const { credentials, cli } = adapt(); + expect(cli).toHaveBeenCalledTimes(1); + await expect(credentials.hasDefaultCredentials()).resolves.toBe(false); + await expect(credentials.getCredentialPathAsync()).rejects.toThrow('Cloud credentials are forbidden'); +}); +test('CLI adapter binds browser opt-in to the exact local rehearsal command', () => { + const browserArgs = [...args.slice(0, -1), 'node tests/runner-browser/server.cjs']; + expect(adapt({ extraArgs: browserArgs, environment: { RUNNER_HTTP_BROWSER: '1' } }).cli).toHaveBeenCalledTimes(1); + expect(() => adapt({ extraArgs: browserArgs })).toThrow('new disposable configuration'); + expect(() => adapt({ environment: { RUNNER_HTTP_BROWSER: '1' } })).toThrow('new disposable configuration'); + expect(() => adapt({ environment: { RUNNER_HTTP_BROWSER: 'true' } })).toThrow('new disposable configuration'); +}); +test.each([ + { extraArgs: ['deploy'] }, + { extraArgs: args.map((arg) => arg === 'demo-runner-http-test' ? 'run-mprc-staging' : arg) }, + { entries: ['configstore'] }, + { environment: { XDG_CONFIG_HOME: '/synthetic/user' } }, + { environment: { REQUIRE_RUNNER_HTTP_EMULATOR: '0' } }, +])('CLI adapter rejects widened command or existing config %#', (input) => { + expect(() => adapt(input)).toThrow('Runner transport CLI requires a new disposable configuration.'); +}); diff --git a/functions/testSupport/runnerHttp/package.json b/functions/testSupport/runnerHttp/package.json new file mode 100644 index 00000000..dee0d965 --- /dev/null +++ b/functions/testSupport/runnerHttp/package.json @@ -0,0 +1,10 @@ +{ + "name": "mprc-runner-http-emulator-only", + "private": true, + "main": "index.js", + "engines": { "node": "22" }, + "dependencies": { + "firebase-admin": "^12.0.0", + "firebase-functions": "^4.6.0" + } +} diff --git a/runner-transport.firebase.json b/runner-transport.firebase.json new file mode 100644 index 00000000..7fef4111 --- /dev/null +++ b/runner-transport.firebase.json @@ -0,0 +1,16 @@ +{ + "functions": [{ "source": "functions/testSupport/runnerHttp", "codebase": "runner-http-test" }], + "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, + "auth": { "providers": { "emailPassword": true } }, + "emulators": { + "auth": { "host": "127.0.0.1", "port": 9919 }, + "firestore": { "host": "127.0.0.1", "port": 9819, "websocketPort": 9818 }, + "functions": { "host": "127.0.0.1", "port": 9519 }, + "hub": { "host": "127.0.0.1", "port": 9419 }, + "logging": { "host": "127.0.0.1", "port": 9418 }, + "eventarc": { "host": "127.0.0.1", "port": 9319 }, + "tasks": { "host": "127.0.0.1", "port": 9219 }, + "ui": { "enabled": false }, + "singleProjectMode": true + } +} diff --git a/scripts/run-runner-transport-tests.cjs b/scripts/run-runner-transport-tests.cjs new file mode 100644 index 00000000..a1b4ac9e --- /dev/null +++ b/scripts/run-runner-transport-tests.cjs @@ -0,0 +1,48 @@ +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const args = process.argv.slice(2); +if (args.length > 1 || (args.length === 1 && args[0] !== '--browser')) { + throw new Error('Runner transport accepts only the optional --browser rehearsal.'); +} +const browser = args[0] === '--browser'; + +// Deliberately do not inherit HOME, cloud credentials, provider keys, project +// selection, NODE_OPTIONS or Firebase CLI login state. Do not change user's +// settings. The CLI receives an empty, disposable config directory. +const root = path.resolve(__dirname, '..'); +const configDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'mprc-runner-http-config-')); +const environment = { + PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`, + CI: 'true', + GCLOUD_PROJECT: 'demo-runner-http-test', + REQUIRE_RUNNER_HTTP_EMULATOR: '1', + XDG_CONFIG_HOME: configDir, +}; +if (browser) environment.RUNNER_HTTP_BROWSER = '1'; +if (process.env.JAVA_HOME) environment.JAVA_HOME = process.env.JAVA_HOME; +for (const key of ['TMPDIR', 'TMP', 'TEMP']) { + if (process.env[key]) environment[key] = process.env[key]; +} +const child = spawn(process.execPath, [ + path.join(root, 'scripts/runner-transport-cli.cjs'), + 'emulators:exec', '--non-interactive', '--project', 'demo-runner-http-test', + '--config', 'runner-transport.firebase.json', '--only', 'auth,firestore,functions', + browser ? 'node tests/runner-browser/server.cjs' : 'node --test tests/runner-transport.test.cjs', +], { cwd: root, env: environment, stdio: 'inherit' }); +let finished = false; +function cleanup(code) { + if (finished) return; + finished = true; + // Exact directory created by this process; never a user/workspace directory. + fs.rmSync(configDir, { recursive: true, force: true }); + process.exitCode = code; +} +child.once('error', () => { console.error('runner_transport_launch_failed'); cleanup(1); }); +child.once('exit', (code) => cleanup(code === 0 ? 0 : 1)); +process.once('SIGINT', () => child.kill('SIGINT')); +process.once('SIGTERM', () => child.kill('SIGTERM')); diff --git a/scripts/runner-transport-cli.cjs b/scripts/runner-transport-cli.cjs new file mode 100644 index 00000000..54b438e3 --- /dev/null +++ b/scripts/runner-transport-cli.cjs @@ -0,0 +1,30 @@ +'use strict'; + +// Test-only CLI adapter: disable discovery/export of actual Google credentials. +// An empty CLI config alone is insufficient: Google Auth also checks the OS +// home directory. This replaces no service auth, runtime, or enforcement code. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const browser = process.env.RUNNER_HTTP_BROWSER === '1'; +const expectedArgs = [ + 'emulators:exec', '--non-interactive', '--project', 'demo-runner-http-test', + '--config', 'runner-transport.firebase.json', '--only', 'auth,firestore,functions', + browser ? 'node tests/runner-browser/server.cjs' : 'node --test tests/runner-transport.test.cjs', +]; +if (process.env.GCLOUD_PROJECT !== 'demo-runner-http-test' + || (process.env.RUNNER_HTTP_BROWSER !== undefined && !browser) + || process.env.REQUIRE_RUNNER_HTTP_EMULATOR !== '1' + || !process.env.XDG_CONFIG_HOME + || path.dirname(process.env.XDG_CONFIG_HOME) !== fs.realpathSync(os.tmpdir()) + || !path.basename(process.env.XDG_CONFIG_HOME).startsWith('mprc-runner-http-config-') + || fs.readdirSync(process.env.XDG_CONFIG_HOME).length !== 0 + || JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expectedArgs)) { + throw new Error('Runner transport CLI requires a new disposable configuration.'); +} +const credentials = require('firebase-tools/lib/defaultCredentials'); +credentials.hasDefaultCredentials = async () => false; +credentials.getCredentialPathAsync = async () => { + throw new Error('Cloud credentials are forbidden in runner transport tests.'); +}; +require('firebase-tools/lib/bin/firebase'); diff --git a/src/App.jsx b/src/App.jsx index 65f18a66..a9f9f663 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -40,6 +40,7 @@ const AdminMemberDirectory = lazy(() => import('./pages/admin/members/AdminMembe const AdminProducts = lazy(() => import('./pages/admin/shop/AdminProducts')); const AdminProductEditor = lazy(() => import('./pages/admin/shop/AdminProductEditor')); const AdminOrders = lazy(() => import('./pages/admin/shop/AdminOrders')); +const RunnerConnections = lazy(() => import('./pages/account/RunnerConnections')); const AdminFallback = (
Loading admin...
@@ -85,6 +86,7 @@ function App() { } /> } /> } /> + Loading running partners…

}>} /> } /> } /> } /> diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index 5c671753..5cd7cb37 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -24,6 +24,7 @@ import MemberDirectoryProfile from './MemberDirectoryProfile'; import { getLocationReturnPath } from '../login/loginReturnPath'; import { getSpamGuidance } from '../../services/accountEmail/accountEmailSender'; import './Account.css'; +import { RUNNER_CONNECTIONS_AVAILABLE } from '../../services/account/runnerConnectionService'; function tsToDate(ts: Timestamp | null | undefined) { if (!ts) return ''; @@ -1028,6 +1029,7 @@ export function AccountContent({ )} + {RUNNER_CONNECTIONS_AVAILABLE && Find running partners} {profileState === 'ready' && profile && firebaseApp && ( = { + bay_trail: 'Bay Trail', + san_mateo: 'San Mateo', + foster_city: 'Foster City', + belmont: 'Belmont', + redwood_city: 'Redwood City', + road: 'Road', + trail: 'Trail', + continuous: 'Continuous running', + run_walk: 'Run/walk', + walk: 'Walking', + consistency: 'Build consistency', + first_5k: 'First 5K', + first_10k: 'First 10K', + half_marathon: 'Half marathon', + marathon: 'Marathon', + trails: 'Trail running', + social: 'Social running', + return_gradually: 'Return gradually', + volunteer: 'Volunteer', + mentor: 'Welcome other runners', + beginner: 'Beginner', + returning: 'Returning', + regular: 'Regular', + experienced: 'Experienced', + coffee: 'Coffee', + food: 'Food', + books: 'Books', + music: 'Music', + outdoors: 'Outdoors', + volunteering: 'Volunteering', + pace_overlap: 'Overlapping easy pace', + shared_availability: 'Overlapping usual availability', + shared_area: 'Shared running area', + shared_distance: 'Overlapping distance', + shared_terrain: 'Shared terrain', + shared_run_style: 'Shared running style', + shared_goals: 'Shared goals', + shared_interests: 'Shared interests', + different_experience: 'Different running experience', + different_goals: 'Different goals', + different_interests: 'Different interests', +}; +export const label = (value: string) => LABELS[value] || value; +export function paceText(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} +export function timeText(minutes: number): string { + if (!Number.isFinite(minutes)) return ''; + return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; +} +export default function RunnerCard({ + card, +}: { + card: Omit & { reasons?: string[] }; +}) { + const factor = card.pace.preferredUnit === 'min/mile' ? 1.609344 : 1; + const list = (values: string[]) => values.map(label).join(', '); + return ( +
+

{card.displayName}

+
+
+
Easy pace
+
+ {paceText(Math.round(card.pace.fastSecondsPerKm * factor))} + – + {paceText(Math.round(card.pace.slowSecondsPerKm * factor))} + {' '} + {card.pace.preferredUnit} +
+
+
+
Distance
+
+ {card.distance.minMetres / 1000} + – + {card.distance.maxMetres / 1000} + {' '} + km +
+
+
+
Usual availability (Pacific time)
+
+ {card.availability.map((window) => ( +
+ {DAYS[window.day]} + {' '} + {timeText(window.startMinute)} + – + {timeText(window.endMinute)} +
+ ))} +
+
+
+
Areas
+
{list(card.areas)}
+
+
+
Terrain
+
{list(card.terrains)}
+
+
+
Running style
+
{list(card.styles)}
+
+
+
Goals
+
{list(card.goals)}
+
+
+
Experience
+
{label(card.experience)}
+
+
+
Interests
+
{card.interests.length ? list(card.interests) : 'Not supplied'}
+
+
+ {!!card.reasons?.length && ( +

+ Why this suggestion: + {' '} + {list(card.reasons)} + . +

+ )} +
+ ); +} diff --git a/src/pages/account/RunnerConnections.css b/src/pages/account/RunnerConnections.css new file mode 100644 index 00000000..16fe5273 --- /dev/null +++ b/src/pages/account/RunnerConnections.css @@ -0,0 +1,128 @@ +.runner-connections { + max-width: 58rem; + margin: 2rem auto; + padding: 1.25rem; + color: #1f2937; +} +.runner-connections h1 { + font-size: 2rem; +} +.runner-connections h2 { + font-size: 1.4rem; + margin: 1.5rem 0 0.75rem; +} +.runner-connections h3 { + font-size: 1.15rem; +} +.runner-connections p { + line-height: 1.6; + margin: 0.75rem 0; +} +.runner-connections nav, +.runner-actions { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin: 1rem 0; +} +.runner-connections a { + text-decoration: underline; + color: #164e63; +} +.runner-connections button { + border: 1px solid #164e63; + border-radius: 0.35rem; + padding: 0.7rem 1rem; + background: #164e63; + color: white; + min-height: 44px; + margin: 0.4rem 0.4rem 0.4rem 0; +} +.runner-connections button:disabled { + background: #e5e7eb; + color: #4b5563; + border-color: #6b7280; +} +.runner-connections :focus-visible { + outline: 3px solid #0e7490; + outline-offset: 3px; +} +.runner-connections fieldset { + padding: 1rem; + margin: 1.25rem 0; + border: 1px solid #cbd5e1; + border-radius: 0.4rem; + min-width: 0; +} +.runner-connections legend { + font-weight: 600; + padding: 0 0.3rem; +} +.runner-connections label { + display: block; + margin: 0.6rem 0; +} +.runner-connections input:not([type='checkbox']), +.runner-connections select { + display: block; + width: 100%; + max-width: 100%; + padding: 0.6rem; + min-height: 44px; + border: 1px solid #64748b; + border-radius: 0.25rem; + background: white; + color: #111827; +} +.runner-connections input[type='checkbox'] { + width: 1.15rem; + height: 1.15rem; + margin-right: 0.6rem; + vertical-align: middle; +} +.runner-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.8rem; + align-items: end; +} +.runner-options label { + padding: 0.3rem 0; + min-height: 36px; +} +.runner-card { + padding: 1.1rem; + margin: 1rem 0; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 0.5rem; + overflow-wrap: anywhere; +} +.runner-card dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.85rem; + margin: 0.8rem 0; +} +.runner-card dt { + font-weight: 600; +} +.runner-card dd { + margin: 0.2rem 0 0; +} +.runner-notice { + background: #fef3c7; + padding: 1rem; + border: 1px solid #92400e; + border-radius: 0.4rem; +} +@media (max-width: 420px) { + .runner-connections { + margin: 1rem auto; + padding: 0.8rem; + } + .runner-fields, + .runner-card dl { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/account/RunnerConnections.test.tsx b/src/pages/account/RunnerConnections.test.tsx new file mode 100644 index 00000000..5bc57bf5 --- /dev/null +++ b/src/pages/account/RunnerConnections.test.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { + act, fireEvent, render, screen, waitFor, within +} from '@testing-library/react'; +import { + MemoryRouter, Route, Routes, useLocation +} from 'react-router-dom'; +import { useAuth } from '../../services/hooks/useAuth'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import * as service from '../../services/account/runnerConnectionService'; +import { + readProfile, + previewCard, + ProfileState, +} from '../../services/account/runnerConnectionContract'; +import RunnerConnections, { RunnerWorkspace } from './RunnerConnections'; +import RunnerProfileForm from './RunnerProfileForm'; + +const { profile } = require('../../../functions/testSupport/runnerConnectionsFixtures'); + +jest.mock('../../components/SEO', () => () => null); +jest.mock('../../services/hooks/useAuth', () => ({ useAuth: jest.fn() })); +jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: jest.fn() })); +jest.mock('../../services/account/runnerConnectionService', () => ({ + __esModule: true, + ...jest.requireActual('../../services/account/runnerConnectionService'), + createRunnerClient: jest.fn(), + createRunnerRequestId: jest.fn(), + RUNNER_CONNECTIONS_AVAILABLE: false, +})); +const EMPTY: ProfileState = { + schemaVersion: 1, + consentVersion: 1, + revision: 0, + adultConfirmed: false, + profile: null, + consent: { memberDiscovery: false, similarity: false, broadenCircle: false }, +}; +const saved = (): ProfileState => ({ + ...EMPTY, + revision: 1, + adultConfirmed: true, + profile: readProfile(profile()), + consent: { memberDiscovery: true, similarity: true, broadenCircle: true }, +}); +const ref = `runner_${'a'.repeat(64)}`; +const suggestions = () => ({ + rankerVersion: 1 as const, + status: 'ready' as const, + windowEndsAtMs: Date.now() + 3600000, + similar: [ + { + ...previewCard(profile({ displayName: 'Synthetic Partner' })), + entryRef: ref, + reasons: ['pace_overlap'], + }, + ], + broaden: [], +}); +const client = () => ({ + read: jest.fn().mockResolvedValue(saved()), + save: jest + .fn() + .mockImplementation(async (request) => ({ + ...saved(), + ...request, + schemaVersion: 1, + revision: request.expectedRevision + 1, + })), + withdraw: jest.fn().mockResolvedValue({ ...EMPTY, revision: 2 }), + recommend: jest.fn().mockImplementation(async () => suggestions()), + exclusion: jest.fn().mockResolvedValue({ revision: 0, hidden: false, blocked: false }), + exclude: jest + .fn() + .mockImplementation(async (request) => ({ + revision: request.expectedRevision + 1, + hidden: request.hidden, + blocked: request.blocked, + })), +}); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} +function change(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +} +function fillNew() { + change('Display name', 'Synthetic New Runner'); + change('Faster easy pace (m:ss)', '8:00'); + change('Slower easy pace (m:ss)', '10:00'); + change('Shortest run (km)', '4'); + change('Longest run (km)', '10'); + ['Bay Trail', 'Road', 'Continuous running', 'Build consistency'].forEach((name) => fireEvent.click(screen.getByRole('checkbox', { name })),); + change('Your running experience', 'beginner'); +} +beforeEach(() => { + jest.clearAllMocks(); + (service as any).RUNNER_CONNECTIONS_AVAILABLE = false; + (service.createRunnerRequestId as jest.Mock).mockReturnValue( + '00000000-0000-4000-8000-000000000001', + ); + (useAuth as jest.Mock).mockReturnValue({ + user: { uid: 'synthetic-one', role: 'unverified' }, + isLoading: false, + }); + (useServiceLocator as jest.Mock).mockReturnValue({ + isReady: true, + services: { firebaseResources: { app: { name: 'synthetic-app' } } }, + }); +}); +test('disabled direct route never starts private work, regardless of URL flags', () => { + render( + + + , + ); + expect(screen.getByText(/Not available yet/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Public club runs' })).toHaveAttribute( + 'href', + '/activities', + ); + expect(service.createRunnerClient).not.toHaveBeenCalled(); + expect(useAuth).not.toHaveBeenCalled(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); +}); +test('new card starts with all sharing and age choices off and requires an exact preview', () => { + const save = jest.fn(); + render(); + expect(screen.getByLabelText('I confirm I am 18 or older')).not.toBeChecked(); + expect(screen.getByLabelText(/Let participating/)).not.toBeChecked(); + expect(screen.getByLabelText(/similar-runner suggestions/)).not.toBeChecked(); + expect(screen.getByLabelText(/Broaden my circle/)).not.toBeChecked(); + expect(screen.queryByRole('button', { name: /Save.*card/ })).not.toBeInTheDocument(); + fillNew(); + fireEvent.click(screen.getByRole('button', { name: 'Preview my card' })); + const preview = screen.getByRole('region', { name: 'Exact runner card preview' }); + expect( + within(preview).getByRole('heading', { name: 'Synthetic New Runner' }), + ).toBeInTheDocument(); + expect(within(preview).queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save private card' })).toBeDisabled(); + fireEvent.click(screen.getByLabelText('I confirm I am 18 or older')); + fireEvent.click(screen.getByRole('button', { name: 'Save private card' })); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Synthetic New Runner' }), + EMPTY.consent, + ); +}); +test('editing invalidates a preview; unit changes clear both entered paces', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Preview my card' })); + change('Display name', 'Changed synthetic'); + expect( + screen.queryByRole('region', { name: 'Exact runner card preview' }), + ).not.toBeInTheDocument(); + change('Pace unit', 'min/mile'); + expect(screen.getByLabelText('Faster easy pace (m:ss)')).toHaveValue(''); + expect(screen.getByLabelText('Slower easy pace (m:ss)')).toHaveValue(''); +}); +test('turning discovery off clears both recommendation permissions; re-enabling does not recheck them', () => { + render(); + fireEvent.click(screen.getByLabelText(/Let participating/)); + expect(screen.getByLabelText(/similar-runner suggestions/)).not.toBeChecked(); + expect(screen.getByLabelText(/Broaden my circle/)).not.toBeChecked(); + fireEvent.click(screen.getByLabelText(/Let participating/)); + expect(screen.getByLabelText(/similar-runner suggestions/)).not.toBeChecked(); +}); +test('overlapping windows and reversed pace reject preview without calling save', () => { + const save = jest.fn(); + render(); + change('Faster easy pace (m:ss)', '10:00'); + change('Slower easy pace (m:ss)', '5:00'); + fireEvent.click(screen.getByRole('button', { name: 'Preview my card' })); + expect(screen.getByRole('alert')).toHaveTextContent(/Review your name/); + expect( + screen.queryByRole('button', { name: 'Save and share my card' }), + ).not.toBeInTheDocument(); + expect(save).not.toHaveBeenCalled(); + change('Faster easy pace (m:ss)', '5:00'); + change('Slower easy pace (m:ss)', '10:00'); + fireEvent.click(screen.getByRole('button', { name: 'Add time window' })); + change('Day 2', '6'); + fireEvent.click(screen.getByRole('button', { name: 'Preview my card' })); + expect(screen.getByRole('alert')).toHaveTextContent(/Review your name/); + expect(screen.queryByRole('region', { name: 'Exact runner card preview' })).not.toBeInTheDocument(); + expect(save).not.toHaveBeenCalled(); +}); +test('midnight availability survives editing without silent truncation', () => { + const initial = saved(); + initial.profile!.availability[0].endMinute = 1440; + render(); + expect(screen.getByLabelText(/End/)).toHaveValue('1440'); + fireEvent.click(screen.getByRole('button', { name: 'Preview my card' })); + expect(screen.getByRole('region', { name: 'Exact runner card preview' })).toHaveTextContent( + '24:00', + ); +}); +test('withdrawal resets card, adult confirmation and discovery rather than silently reenrolling', async () => { + const api = client(); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove my runner card' })); + await waitFor(() => expect(screen.getByLabelText('I confirm I am 18 or older')).not.toBeChecked(),); + expect(screen.getByLabelText(/Let participating/)).not.toBeChecked(); + expect(screen.getByLabelText('Display name')).toHaveValue(''); + expect(api.withdraw).toHaveBeenCalledWith({ + requestId: '00000000-0000-4000-8000-000000000001', + expectedRevision: 1, + }); + expect(screen.getByRole('status')).toHaveTextContent( + 'Your card and sharing choices were removed', + ); +}); +test('uncertain save blocks edits and retries exactly the original request, without false success', async () => { + const api = client(); + api.save.mockRejectedValueOnce(new Error('synthetic-private-provider-detail')); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Preview my card' })); + fireEvent.click(screen.getByRole('button', { name: 'Save and share my card' })); + const retry = await screen.findByRole('button', { name: 'Retry the same change' }); + expect(screen.getByLabelText('Display name')).toBeDisabled(); + expect(screen.getByRole('status')).toHaveTextContent('could not confirm whether'); + expect(screen.queryByText('synthetic-private-provider-detail')).not.toBeInTheDocument(); + fireEvent.click(retry); + await screen.findByText('Your runner card was saved with discovery on.'); + expect(api.save).toHaveBeenCalledTimes(2); + expect(api.save.mock.calls[1][0]).toBe(api.save.mock.calls[0][0]); + expect(service.createRunnerRequestId).toHaveBeenCalledTimes(1); +}); +test('definitive conflict offers a fresh read, not a success or an automatic overwrite', async () => { + const api = client(); + api.withdraw.mockRejectedValue({ code: 'functions/aborted' }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove my runner card' })); + await screen.findByRole('button', { name: 'Reload runner settings' }); + expect( + screen.queryByText(/Your card and sharing choices were removed/), + ).not.toBeInTheDocument(); + expect(api.withdraw).toHaveBeenCalledTimes(1); +}); +test('read failure has no fallback profile, fictitious members or success message', async () => { + const api = client(); + api.read.mockRejectedValue(new Error('synthetic-private')); + render(); + await screen.findByRole('button', { name: 'Reload runner settings' }); + expect(screen.queryByLabelText('Display name')).not.toBeInTheDocument(); + expect(api.recommend).not.toHaveBeenCalled(); +}); +test('sparse results describe a limited daily selection, and an outage clears old cards', async () => { + const api = client(); + render(); + const check = await screen.findByRole('button', { name: 'Check today’s suggestions' }); + fireEvent.click(check); + await screen.findByRole('heading', { name: 'Synthetic Partner' }); + api.recommend.mockRejectedValueOnce(new Error('synthetic-private')); + fireEvent.click(check); + await screen.findByText(/Suggestions are unavailable/); + expect(screen.queryByRole('heading', { name: 'Synthetic Partner' })).not.toBeInTheDocument(); + api.recommend.mockResolvedValueOnce({ ...suggestions(), similar: [] }); + fireEvent.click(check); + await screen.findByText(/No matches in today’s selection/); +}); +test('hiding uses the current exclusion revision and does not request replacement cards', async () => { + const api = client(); + api.exclusion.mockResolvedValue({ revision: 2, hidden: false, blocked: true }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Check today’s suggestions' })); + fireEvent.click(await screen.findByRole('button', { name: /Hide.*Synthetic Partner/ })); + await screen.findByText(/Your privacy choice was saved/); + expect(api.exclude).toHaveBeenCalledWith( + expect.objectContaining({ + entryRef: ref, + expectedRevision: 2, + hidden: true, + blocked: true, + }), + ); + expect(api.recommend).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('heading', { name: 'Synthetic Partner' })).not.toBeInTheDocument(); +}); +test('blur clears displayed suggestions and prevents an in-flight reply from repopulating them', async () => { + const api = client(); + const pending = deferred(); + api.recommend.mockReturnValueOnce(pending.promise); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Check today’s suggestions' })); + fireEvent.blur(window); + await act(async () => { + pending.resolve(suggestions()); + }); + expect(screen.queryByRole('heading', { name: 'Synthetic Partner' })).not.toBeInTheDocument(); +}); +test('unmounted work cannot disclose an old profile into another mounted workspace', async () => { + const old = client(); + const pending = deferred(); + old.read.mockReturnValue(pending.promise); + const rendered = render(); + const fresh = client(); + fresh.read.mockResolvedValue(EMPTY); + rendered.rerender(); + await screen.findByLabelText('Display name'); + await act(async () => { + pending.resolve(saved()); + }); + expect(screen.getByLabelText('Display name')).toHaveValue(''); +}); +test('enabled route requires sign-in and preserves its intended return path', () => { + function LoginProbe() { + const location = useLocation(); + return

{`Login screen ${location.state?.from}`}

; + } + (service as any).RUNNER_CONNECTIONS_AVAILABLE = true; + (useAuth as jest.Mock).mockReturnValue({ user: null, isLoading: false }); + render( + + + } /> + } /> + + , + ); + expect(screen.getByText(/Login screen/)).toHaveTextContent( + 'Login screen /account/running-partners', + ); + expect(service.createRunnerClient).not.toHaveBeenCalled(); +}); +test('source route discards private state when either UID or Firebase app changes', async () => { + (service as any).RUNNER_CONNECTIONS_AVAILABLE = true; + const first = client(); + const second = client(); + second.read.mockResolvedValue(EMPTY); + (service.createRunnerClient as jest.Mock).mockReturnValueOnce(first).mockReturnValue(second); + const rendered = render( + + + , + ); + await waitFor(() => expect(screen.getByLabelText('Display name')).toHaveValue('Synthetic Runner'),); + (useAuth as jest.Mock).mockReturnValue({ + user: { uid: 'synthetic-two', role: 'admin' }, + isLoading: false, + }); + rendered.rerender( + + + , + ); + await waitFor(() => expect(screen.getByLabelText('Display name')).toHaveValue('')); + change('Display name', 'Unsent synthetic draft'); + (useServiceLocator as jest.Mock).mockReturnValue({ + isReady: true, + services: { firebaseResources: { app: { name: 'same-name-different-object' } } }, + }); + rendered.rerender( + + + , + ); + await waitFor(() => expect(screen.getByLabelText('Display name')).toHaveValue('')); + expect(service.createRunnerClient).toHaveBeenCalledTimes(3); +}); diff --git a/src/pages/account/RunnerConnections.tsx b/src/pages/account/RunnerConnections.tsx new file mode 100644 index 00000000..9813dca8 --- /dev/null +++ b/src/pages/account/RunnerConnections.tsx @@ -0,0 +1,444 @@ +import React, { + useCallback, useEffect, useMemo, useRef, useState +} from 'react'; +import { FirebaseApp } from 'firebase/app'; +import { Link, Navigate } from 'react-router-dom'; +import SEO from '../../components/SEO'; +import { useAuth } from '../../services/hooks/useAuth'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import { + createRunnerClient, + createRunnerRequestId, + definitiveRejection, + RUNNER_CONNECTIONS_AVAILABLE, + RunnerClient, +} from '../../services/account/runnerConnectionService'; +import { + Consent, + Exclusion, + ProfileState, + Recommendations, + RunnerProfile, +} from '../../services/account/runnerConnectionContract'; +import RunnerCard from './RunnerCard'; +import RunnerProfileForm from './RunnerProfileForm'; +import './RunnerConnections.css'; + +type Change = { + run: () => Promise; + accept: (result: ProfileState | Exclusion) => void; +}; +type RecentChoice = { entryRef: string; name: string; state: Exclusion }; +function choiceLabel(state: Exclusion): string { + if (state.blocked) return 'blocked'; + return state.hidden ? 'hidden' : 'your choice cleared'; +} + +// Exported for synthetic UI tests only; the application always uses the +// source-gated route below. No query-string, local-storage or environment switch. +export function RunnerWorkspace({ client }: { client: RunnerClient }) { + const [profile, setProfile] = useState(null); + const [results, setResults] = useState(null); + const [recent, setRecent] = useState([]); + const [phase, setPhase] = useState<'loading' | 'ready' | 'busy' | 'unavailable' | 'unknown'>( + 'loading', + ); + const [message, setMessage] = useState(''); + const [unknown, setUnknown] = useState(null); + const generation = useRef(0); + const recommendationGeneration = useRef(0); + const locked = useRef(false); + const status = useRef(null); + + const load = useCallback(async () => { + generation.current += 1; + const attempt = generation.current; + locked.current = true; + setProfile(null); + setResults(null); + setRecent([]); + setMessage(''); + setPhase('loading'); + try { + const state = await client.read(); + if (generation.current !== attempt) return; + setProfile(state); + setPhase('ready'); + } catch { + if (generation.current === attempt) setPhase('unavailable'); + } finally { + if (generation.current === attempt) locked.current = false; + } + }, [client]); + + useEffect(() => { + load(); + return () => { + generation.current += 1; + locked.current = true; + }; + }, [load]); + useEffect(() => { + if (message) status.current?.focus(); + }, [message]); + useEffect(() => { + const clear = () => { + recommendationGeneration.current += 1; + setResults(null); + }; + const hide = () => { + if (document.visibilityState === 'hidden') clear(); + }; + window.addEventListener('blur', clear); + document.addEventListener('visibilitychange', hide); + const timer = results + ? window.setTimeout( + clear, + Math.max(0, Math.min(results.windowEndsAtMs - Date.now(), 86400000)), + ) + : undefined; + return () => { + window.removeEventListener('blur', clear); + document.removeEventListener('visibilitychange', hide); + window.clearTimeout(timer); + }; + }, [results]); + + async function apply(change: Change) { + if (locked.current) return; + locked.current = true; + const attempt = generation.current; + setResults(null); + setMessage(''); + setPhase('busy'); + try { + const result = await change.run(); + if (generation.current !== attempt) return; + change.accept(result); + setUnknown(null); + setPhase('ready'); + } catch (failure) { + if (generation.current !== attempt) return; + if (definitiveRejection(failure)) { + setUnknown(null); + setProfile(null); + setRecent([]); + setPhase('unavailable'); + setMessage( + 'That change could not be applied or confirmed. Reload the current settings before trying another change.', + ); + } else { + setUnknown(change); + setPhase('unknown'); + setMessage( + 'We could not confirm whether that change was saved. Do not submit a different change. Retry the same request to recover its result.', + ); + } + } finally { + if (generation.current === attempt) locked.current = false; + } + } + const save = (card: RunnerProfile, consent: Consent) => { + if (!profile || locked.current) return; + try { + const request = { + requestId: createRunnerRequestId(), + expectedRevision: profile.revision, + profile: card, + consent, + adultConfirmed: true as const, + consentVersion: 1 as const, + }; + apply({ + run: () => client.save(request), + accept: (result) => { + const saved = result as ProfileState; + setProfile(saved); + setMessage( + saved.consent.memberDiscovery + ? 'Your runner card was saved with discovery on.' + : 'Your runner card was saved privately. Discovery is off.', + ); + }, + }); + } catch { + setMessage('This browser could not safely start a change. Nothing was sent.'); + } + }; + function withdraw() { + if (!profile || locked.current) return; + try { + const request = { + requestId: createRunnerRequestId(), + expectedRevision: profile.revision, + }; + apply({ + run: () => client.withdraw(request), + accept: (result) => { + setProfile(result as ProfileState); + setRecent([]); + setMessage( + 'Your card and sharing choices were removed. Returning requires a new 18+ confirmation. Private safety receipts are retained pending the approved retention process; this is not account deletion.', + ); + }, + }); + } catch { + setMessage('This browser could not safely start a change. Nothing was sent.'); + } + } + async function recommendations() { + if (locked.current) return; + locked.current = true; + const attempt = generation.current; + recommendationGeneration.current += 1; + const recommendationAttempt = recommendationGeneration.current; + setResults(null); + setMessage(''); + setPhase('busy'); + try { + const response = await client.recommend(); + if (generation.current !== attempt) return; + if (recommendationGeneration.current === recommendationAttempt) setResults(response); + setPhase('ready'); + } catch { + if (generation.current === attempt) { + setPhase('ready'); + setMessage( + 'Suggestions are unavailable. Current membership, a verified account and request limits are checked by the server. No cached or example runners are shown.', + ); + } + } finally { + if (generation.current === attempt) locked.current = false; + } + } + async function exclusion(entryRef: string, name: string, action: 'hide' | 'block' | 'undo') { + if (locked.current) return; + locked.current = true; + const attempt = generation.current; + setResults(null); + setMessage(''); + setPhase('busy'); + let state: Exclusion; + let requestId: string; + try { + state = await client.exclusion(entryRef); + requestId = createRunnerRequestId(); + } catch { + if (generation.current === attempt) { + locked.current = false; + setPhase('ready'); + setMessage( + 'The privacy change could not be started. No change was submitted. Try again later.', + ); + } + return; + } + if (generation.current !== attempt) return; + locked.current = false; + const request = { + requestId, + expectedRevision: state.revision, + entryRef, + hidden: action === 'hide' || (action === 'block' && state.hidden), + blocked: action === 'block' || (action === 'hide' && state.blocked), + }; + await apply({ + run: () => client.exclude(request), + accept: (result) => { + setRecent((previous) => [ + { entryRef, name, state: result as Exclusion }, + ...previous.filter((item) => item.entryRef !== entryRef), + ].slice(0, 5),); + setMessage( + action === 'undo' + ? 'Your own privacy choice was cleared. Another person’s block, if any, is unchanged.' + : 'Your privacy choice was saved. Refreshing will not replace removed suggestions today.', + ); + }, + }); + } + + return ( + <> + {message && ( +

+ {message} +

+ )} + {phase === 'loading' &&

Loading your runner settings…

} + {phase === 'busy' &&

Checking with the server…

} + {phase === 'unavailable' && ( +
+

+ Runner settings are unavailable. Nothing here establishes current club membership. + You can still find public club runs. +

+ +
+ )} + {phase === 'unknown' && unknown && ( + + )} + {profile && ( + <> + + {profile.profile && ( +
+

Leave runner discovery

+

+ Remove your card, sharing choices and age confirmation. This does not delete + your account or change dues. +

+ +
+ )} +
+

Today’s running suggestions

+

+ At most four ordinary suggestions and one opted-in broadening suggestion each + day. Refreshing, editing or hiding will not reveal more people that day. A + suggestion does not confirm attendance. Meet at a public club run; private + introductions and messaging are not available here. +

+ + {!profile.consent.memberDiscovery && ( +

Save your card with discovery on to request suggestions.

+ )} + {results?.status === 'profile_required' && ( +

Your shared profile is no longer available. Reload settings to check it.

+ )} + {results?.status === 'ready' + && results.similar.length + results.broaden.length === 0 && ( +

+ No matches in today’s selection. This is a limited sample, not a search of + every club member. Try another day or join a public club run. +

+ )} + {results + && [ + { title: 'Similar runners', items: results.similar }, + { title: 'Broaden your circle', items: results.broaden }, + ].map( + (group) => group.items.length > 0 && ( +
+

{group.title}

+ {group.items.map((card) => ( +
+ +
+ + +
+
+ ))} +
+ ), + )} +
+ {recent.length > 0 && ( +
+

Privacy choices from this visit

+

+ Hide affects your results. Block excludes the pair in both directions. Undo + changes only your choice. Full history management is not available yet. +

+ {recent.map((item) => ( +
+

+ {item.name} + : + {' '} + {choiceLabel(item.state)} +

+ {(item.state.hidden || item.state.blocked) && ( + + )} +
+ ))} +
+ )} + + )} + + ); +} +const appIds = new WeakMap(); +let nextAppId = 1; +function appKey(app: FirebaseApp): number { + if (!appIds.has(app)) { + appIds.set(app, nextAppId); + nextAppId += 1; + } + return appIds.get(app) as number; +} +function Session({ app, uid }: { app: FirebaseApp; uid: string }) { + const client = useMemo(() => createRunnerClient(app, uid), [app, uid]); + return ; +} +function ConnectedRoute() { + const { user, isLoading } = useAuth(); + const { services, isReady } = useServiceLocator(); + if (isLoading) return

Checking your account…

; + if (!user) return ; + if (!isReady || !services) return

Runner connections are unavailable.

; + const { app } = services.firebaseResources; + return ; +} +export default function RunnerConnections() { + return ( +
+ +

Find running partners

+

+ Choose a separate runner card to meet compatible adult club members. Nothing is copied + from your account or the officer finder. +

+ + {RUNNER_CONNECTIONS_AVAILABLE ? ( + + ) : ( +

+ Not available yet. Runner cards, suggestions and privacy controls are not connected + on this website. No runner information is loaded or saved from this page. +

+ )} +
+ ); +} diff --git a/src/pages/account/RunnerProfileForm.tsx b/src/pages/account/RunnerProfileForm.tsx new file mode 100644 index 00000000..fef2f934 --- /dev/null +++ b/src/pages/account/RunnerProfileForm.tsx @@ -0,0 +1,459 @@ +import React, { useState } from 'react'; +import { + Consent, + OPTIONS, + previewCard, + ProfileState, + readProfile, + RunnerProfile, +} from '../../services/account/runnerConnectionContract'; +import RunnerCard, { + DAYS, label, paceText, timeText +} from './RunnerCard'; + +function blankProfile(): RunnerProfile { + return { + displayName: '', + pace: { unit: 'min/mile', fastSeconds: 0, slowSeconds: 0 }, + distance: { minMetres: 0, maxMetres: 0 }, + availability: [{ day: 6, startMinute: 480, endMinute: 660 }], + areas: [], + terrains: [], + styles: [], + goals: [], + experience: '', + experiencePreferences: [...OPTIONS.experience], + interests: [], + }; +} +function seconds(value: string): number { + if (!/^\d{1,2}:[0-5]\d$/.test(value)) return NaN; + const [paceMinutes, remainder] = value.split(':').map(Number); + return paceMinutes * 60 + remainder; +} +function minutes(value: string): number { + if (!/^\d{2}:[0-5]\d$/.test(value)) return NaN; + const [hours, remainder] = value.split(':').map(Number); + return hours * 60 + remainder; +} +function Choices({ + name, + title, + options, + selected, + maximum, + onChange, +}: { + name: string; + title: string; + options: readonly string[]; + selected: string[]; + maximum: number; + onChange: (next: string[]) => void; +}) { + return ( +
+ {title} + {options.map((option) => ( + + ))} +
+ ); +} +export default function RunnerProfileForm({ + initial, + disabled, + onSave, +}: { + initial: ProfileState; + disabled: boolean; + onSave: (profile: RunnerProfile, consent: Consent) => void; +}) { + const [draft, setDraft] = useState(() => initial.profile || blankProfile()); + const [fast, setFast] = useState( + initial.profile ? paceText(initial.profile.pace.fastSeconds) : '', + ); + const [slow, setSlow] = useState( + initial.profile ? paceText(initial.profile.pace.slowSeconds) : '', + ); + const [consent, setConsent] = useState(initial.consent); + const [adult, setAdult] = useState(initial.adultConfirmed); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(''); + function update(next: RunnerProfile) { + setDraft(next); + setPreview(null); + setError(''); + } + function showPreview(event: React.FormEvent) { + event.preventDefault(); + try { + const profile = readProfile({ + ...draft, + pace: { ...draft.pace, fastSeconds: seconds(fast), slowSeconds: seconds(slow) }, + }); + setPreview(profile); + setError(''); + } catch { + setPreview(null); + setError( + 'Review your name, pace (minutes:seconds), distance, time windows and required choices. Ranges must be ordered and time windows must not overlap.', + ); + } + } + return ( +
+
+ Your runner card +

+ Only these chosen card fields are shared. Do not put contact details or private + information in your display name. +

+ +
+ + + + + +
+

+ Use your comfortable easy pace, not a race best. Supported pace is equivalent to + 2:00–30:00 min/km. Changing units clears the pace fields so they cannot be + misinterpreted. +

+
+ Usual availability — Pacific time +

+ Review the suggested Saturday window. Availability does not confirm attendance at a + club run. +

+ {draft.availability.map((window, index) => ( + // Windows have positional identity only while editing this bounded form. + // eslint-disable-next-line react/no-array-index-key +
+ + + + +
+ ))} + +
+ update({ ...draft, areas })} + /> + update({ ...draft, terrains })} + /> + update({ ...draft, styles })} + /> + update({ ...draft, goals })} + /> + + update({ ...draft, experiencePreferences })} + /> + update({ ...draft, interests })} + /> +
+ Age and sharing choices + +

+ This is your confirmation, not age or identity verification. Current club + membership is checked separately. +

+ + + +

+ Broadening still requires a feasible shared run and both people to opt in. These + choices do not change the separate officer finder. Saving with discovery off keeps + the card private. +

+
+ {error &&

{error}

} + + {preview && ( +
+

Your card preview

+

+ This is the card people can see if you turn on discovery. No account photo, + email, birth date or private experience preference is included. Pace is rounded + to whole seconds for matching. +

+ + + {!adult &&

Confirm you are 18 or older before saving.

} +
+ )} +
+
+ ); +} diff --git a/src/services/account/runnerConnectionContract.ts b/src/services/account/runnerConnectionContract.ts new file mode 100644 index 00000000..42ba47ac --- /dev/null +++ b/src/services/account/runnerConnectionContract.ts @@ -0,0 +1,344 @@ +// Display/input hygiene only. The server independently validates and authorizes +// every operation. Do not derive membership from any browser value here. +export const OPTIONS = { + areas: ['bay_trail', 'san_mateo', 'foster_city', 'belmont', 'redwood_city'], + terrains: ['road', 'trail'], + styles: ['continuous', 'run_walk', 'walk'], + goals: [ + 'consistency', + 'first_5k', + 'first_10k', + 'half_marathon', + 'marathon', + 'trails', + 'social', + 'return_gradually', + 'volunteer', + 'mentor', + ], + experience: ['beginner', 'returning', 'regular', 'experienced'], + interests: ['coffee', 'food', 'books', 'music', 'outdoors', 'volunteering'], +} as const; +export const REASONS = [ + 'pace_overlap', + 'shared_availability', + 'shared_area', + 'shared_distance', + 'shared_terrain', + 'shared_run_style', + 'shared_goals', + 'shared_interests', + 'different_experience', + 'different_goals', + 'different_interests', +] as const; +export interface RunnerProfile { + displayName: string; + pace: { unit: 'min/km' | 'min/mile'; fastSeconds: number; slowSeconds: number }; + distance: { minMetres: number; maxMetres: number }; + availability: { day: number; startMinute: number; endMinute: number }[]; + areas: string[]; + terrains: string[]; + styles: string[]; + goals: string[]; + experience: string; + experiencePreferences: string[]; + interests: string[]; +} +export interface Consent { + memberDiscovery: boolean; + similarity: boolean; + broadenCircle: boolean; +} +export interface ProfileState { + schemaVersion: 1; + consentVersion: 1; + revision: number; + adultConfirmed: boolean; + profile: RunnerProfile | null; + consent: Consent; +} +export interface RunnerCard extends Omit { + entryRef: string; + pace: { + fastSecondsPerKm: number; + slowSecondsPerKm: number; + preferredUnit: 'min/km' | 'min/mile'; + }; + reasons: string[]; +} +export interface Recommendations { + rankerVersion: 1; + status: 'ready' | 'profile_required'; + windowEndsAtMs: number; + similar: RunnerCard[]; + broaden: RunnerCard[]; +} +export interface Exclusion { + revision: number; + hidden: boolean; + blocked: boolean; +} +export interface Mutation { + requestId: string; + expectedRevision: number; +} +export interface SaveRequest extends Mutation { + profile: RunnerProfile; + consent: Consent; + adultConfirmed: true; + consentVersion: 1; +} +export interface ExclusionRequest extends Mutation { + entryRef: string; + hidden: boolean; + blocked: boolean; +} +const FIELDS = [ + 'displayName', + 'pace', + 'distance', + 'availability', + 'areas', + 'terrains', + 'styles', + 'goals', + 'experience', + 'experiencePreferences', + 'interests', +]; +export function invalid(): never { + throw new Error('Runner connection data is unavailable.'); +} +export function exact(value: unknown, fields: string[]): Record { + try { + if ( + value === null + || typeof value !== 'object' + || Object.getPrototypeOf(value) !== Object.prototype + ) return invalid(); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== fields.length + || keys.some((key) => typeof key !== 'string' || !fields.includes(key)) + ) return invalid(); + const result: Record = {}; + fields.forEach((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) invalid(); + result[key] = descriptor.value; + }); + return result; + } catch { + return invalid(); + } +} +function integer(value: unknown, min: number, max: number): number { + if ( + typeof value !== 'number' + || !Number.isSafeInteger(value) + || Object.is(value, -0) + || value < min + || value > max + ) return invalid(); + return value; +} +function array(value: unknown, min: number, max: number): unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return invalid(); + integer(value.length, min, max); + if (Reflect.ownKeys(value).length !== value.length + 1) return invalid(); + return Array.from({ length: value.length }, (_, index) => { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + !descriptor + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) return invalid(); + return descriptor.value; + }); +} +function choices( + value: unknown, + options: readonly string[], + min = 1, + max = options.length, +): string[] { + const values = array(value, min, max); + if ( + values.some((item) => typeof item !== 'string' || !options.includes(item)) + || new Set(values).size !== values.length + ) return invalid(); + return (values as string[]).sort(); +} +export function readProfile(value: unknown): RunnerProfile { + const data = exact(value, FIELDS); + if ( + typeof data.displayName !== 'string' + || !data.displayName.trim() + || data.displayName.length > 60 + || /[\p{Cc}\p{Cf}\uD800-\uDFFF]/u.test(data.displayName) + ) return invalid(); + const pace = exact(data.pace, ['unit', 'fastSeconds', 'slowSeconds']); + if (pace.unit !== 'min/km' && pace.unit !== 'min/mile') return invalid(); + const fastSeconds = integer(pace.fastSeconds, 120, 3000); + const slowSeconds = integer(pace.slowSeconds, fastSeconds, 3000); + const convert = (seconds: number) => Math.round(seconds * (pace.unit === 'min/mile' ? 1000000 / 1609344 : 1)); + integer(convert(fastSeconds), 120, 1800); + integer(convert(slowSeconds), convert(fastSeconds), 1800); + const distance = exact(data.distance, ['minMetres', 'maxMetres']); + const minMetres = integer(distance.minMetres, 500, 50000); + const maxMetres = integer(distance.maxMetres, minMetres, 50000); + const availability = array(data.availability, 1, 7) + .map((input) => { + const window = exact(input, ['day', 'startMinute', 'endMinute']); + const day = integer(window.day, 0, 6); + const startMinute = integer(window.startMinute, 0, 1439); + return { day, startMinute, endMinute: integer(window.endMinute, startMinute + 1, 1440) }; + }) + .sort((a, b) => a.day - b.day || a.startMinute - b.startMinute); + if ( + availability.some( + (window, index) => index > 0 + && window.day === availability[index - 1].day + && window.startMinute < availability[index - 1].endMinute, + ) + ) return invalid(); + if (!OPTIONS.experience.some((item) => item === data.experience)) return invalid(); + return { + displayName: data.displayName.trim(), + pace: { unit: pace.unit, fastSeconds, slowSeconds }, + distance: { minMetres, maxMetres }, + availability, + areas: choices(data.areas, OPTIONS.areas, 1, 3), + terrains: choices(data.terrains, OPTIONS.terrains), + styles: choices(data.styles, OPTIONS.styles), + goals: choices(data.goals, OPTIONS.goals, 1, 5), + experience: data.experience as string, + experiencePreferences: choices(data.experiencePreferences, OPTIONS.experience), + interests: choices(data.interests, OPTIONS.interests, 0, 4), + }; +} +export function readConsent(value: unknown): Consent { + const data = exact(value, ['memberDiscovery', 'similarity', 'broadenCircle']); + if ( + Object.values(data).some((item) => typeof item !== 'boolean') + || (!data.memberDiscovery && (data.similarity || data.broadenCircle)) + ) return invalid(); + return { + memberDiscovery: data.memberDiscovery as boolean, + similarity: data.similarity as boolean, + broadenCircle: data.broadenCircle as boolean, + }; +} +export function readProfileState(value: unknown): ProfileState { + const data = exact(value, [ + 'schemaVersion', + 'consentVersion', + 'revision', + 'adultConfirmed', + 'profile', + 'consent', + ]); + if (data.schemaVersion !== 1 || data.consentVersion !== 1) return invalid(); + const revision = integer(data.revision, 0, Number.MAX_SAFE_INTEGER - 1); + const profile = data.profile === null ? null : readProfile(data.profile); + const consent = readConsent(data.consent); + if ( + data.adultConfirmed !== (profile !== null) + || (!profile && consent.memberDiscovery) + || (revision === 0 && profile !== null) + ) return invalid(); + return { + schemaVersion: 1, + consentVersion: 1, + revision, + adultConfirmed: data.adultConfirmed as boolean, + profile, + consent, + }; +} +export function previewCard(profile: RunnerProfile): Omit { + const { experiencePreferences, pace, ...shared } = readProfile(profile); + const convert = (seconds: number) => Math.round(seconds * (pace.unit === 'min/mile' ? 1000000 / 1609344 : 1)); + return { + ...shared, + pace: { + fastSecondsPerKm: convert(pace.fastSeconds), + slowSecondsPerKm: convert(pace.slowSeconds), + preferredUnit: pace.unit, + }, + }; +} +export function readReference(value: unknown): string { + if (typeof value !== 'string' || !/^runner_[0-9a-f]{64}$/.test(value)) return invalid(); + return value; +} +function readCard(value: unknown): RunnerCard { + const data = exact(value, [ + ...FIELDS.filter((field) => field !== 'experiencePreferences'), + 'entryRef', + 'reasons', + ]); + const pace = exact(data.pace, ['fastSecondsPerKm', 'slowSecondsPerKm', 'preferredUnit']); + const fast = integer(pace.fastSecondsPerKm, 120, 1800); + const slow = integer(pace.slowSecondsPerKm, fast, 1800); + if (pace.preferredUnit !== 'min/km' && pace.preferredUnit !== 'min/mile') return invalid(); + const { entryRef, reasons, ...fields } = data; + const validated = previewCard( + readProfile({ + ...fields, + pace: { unit: 'min/km', fastSeconds: fast, slowSeconds: slow }, + experiencePreferences: [...OPTIONS.experience], + }), + ); + return { + ...validated, + pace: { + fastSecondsPerKm: fast, + slowSecondsPerKm: slow, + preferredUnit: pace.preferredUnit, + }, + entryRef: readReference(entryRef), + reasons: choices(reasons, REASONS, 1), + }; +} +export function readRecommendations(value: unknown): Recommendations { + const data = exact(value, [ + 'rankerVersion', + 'status', + 'windowEndsAtMs', + 'similar', + 'broaden', + ]); + if ( + data.rankerVersion !== 1 + || (data.status !== 'ready' && data.status !== 'profile_required') + ) return invalid(); + const similar = array(data.similar, 0, 4).map(readCard); + const broaden = array(data.broaden, 0, 1).map(readCard); + const refs = [...similar, ...broaden].map((card) => card.entryRef); + if ( + new Set(refs).size !== refs.length + || (data.status === 'profile_required' && refs.length > 0) + ) return invalid(); + return { + rankerVersion: 1, + status: data.status, + windowEndsAtMs: integer(data.windowEndsAtMs, 1, Number.MAX_SAFE_INTEGER), + similar, + broaden, + }; +} +export function readExclusion(value: unknown): Exclusion { + const data = exact(value, ['revision', 'hidden', 'blocked']); + if (typeof data.hidden !== 'boolean' || typeof data.blocked !== 'boolean') return invalid(); + return { + revision: integer(data.revision, 0, Number.MAX_SAFE_INTEGER - 1), + hidden: data.hidden, + blocked: data.blocked, + }; +} diff --git a/src/services/account/runnerConnectionService.test.ts b/src/services/account/runnerConnectionService.test.ts new file mode 100644 index 00000000..c71779f9 --- /dev/null +++ b/src/services/account/runnerConnectionService.test.ts @@ -0,0 +1,241 @@ +import { getAuth } from 'firebase/auth'; +import { getFunctions, httpsCallable } from 'firebase/functions'; +import { + createRunnerClient, + definitiveRejection, + RUNNER_CONNECTIONS_AVAILABLE, +} from './runnerConnectionService'; +import { + previewCard, + readConsent, + readExclusion, + readProfile, + readProfileState, + readRecommendations, +} from './runnerConnectionContract'; + +// Test-only server imports bind the browser contract to the actual pure ranker. +// Nothing from functions/ is included in the browser's runtime import graph. +const { + profile, + runner, +} = require('../../../functions/testSupport/runnerConnectionsFixtures'); +const { + normalizePace, + readRunnerProfileInput, + rankRunnerConnections, +} = require('../../../functions/runnerConnections'); + +jest.mock('firebase/auth', () => ({ getAuth: jest.fn() })); +jest.mock('firebase/functions', () => ({ getFunctions: jest.fn(), httpsCallable: jest.fn() })); +const UID = 'synthetic-browser-runner'; +const app = { name: 'synthetic-app' } as any; +const authState = { currentUser: { uid: UID } as { uid: string } | null }; +const invoke = jest.fn(); +const consent = { memberDiscovery: true, similarity: true, broadenCircle: true }; +const input = () => ({ + requestId: '00000000-0000-4000-8000-000000000001', + expectedRevision: 0, + profile: profile(), + consent, + adultConfirmed: true as const, + consentVersion: 1 as const, +}); +const state = () => ({ + schemaVersion: 1, + consentVersion: 1, + revision: 1, + adultConfirmed: true, + profile: readProfile(profile()), + consent, +}); +const result = () => ({ + ...rankRunnerConnections(runner(1, { consent }), [runner(2, { consent })]), + status: 'ready', + windowEndsAtMs: Date.now() + 3600000, +}); + +beforeEach(() => { + jest.clearAllMocks(); + authState.currentUser = { uid: UID }; + (getAuth as jest.Mock).mockReturnValue(authState); + (getFunctions as jest.Mock).mockReturnValue({ synthetic: true }); + (httpsCallable as jest.Mock).mockReturnValue(invoke); +}); + +test('capability is literally disabled, not enabled from a caller or environment value', () => { + expect(RUNNER_CONNECTIONS_AVAILABLE).toBe(false); +}); +test('browser profile normalization and preview match the actual server projection', () => { + ['min/km', 'min/mile'].forEach((unit) => { + for (let seconds = 120; seconds <= 3000; seconds += 1) { + const value = profile({ pace: { unit, fastSeconds: seconds, slowSeconds: seconds } }); + let canonical; + try { + canonical = readRunnerProfileInput(value); + } catch { + canonical = null; + } + if (canonical) { + expect(readProfile(value)).toEqual(canonical); + expect(previewCard(value).pace).toEqual(normalizePace(value.pace)); + } else { + expect(() => readProfile(value)).toThrow(); + } + } + }); + const wire = result(); + expect(readRecommendations(wire).similar[0]).toEqual({ + ...wire.similar[0], + reasons: [...wire.similar[0].reasons].sort(), + }); + const { entryRef, reasons, ...publicCard } = wire.similar[0]; + expect(previewCard(profile())).toEqual(publicCard); + expect(previewCard(profile())).not.toHaveProperty('experiencePreferences'); +}); +test.each([ + { displayName: '' }, + { displayName: 'x'.repeat(61) }, + { displayName: 'Synthetic\u200b' }, + { email: 'synthetic@example.test' }, + { experience: 'coach' }, + { areas: [] }, + { areas: ['bay_trail', 'bay_trail'] }, + { interests: ['unknown'] }, + { distance: { minMetres: 500, maxMetres: 499 } }, + { + availability: [ + { day: 6, startMinute: 480, endMinute: 660 }, + { day: 6, startMinute: 500, endMinute: 700 }, + ], + }, + { pace: { unit: 'min/mile', fastSeconds: NaN, slowSeconds: 600 } }, +])('malformed profile case %# is rejected on both boundaries', (overrides) => { + expect(() => readProfile(profile(overrides))).toThrow(); + expect(() => readRunnerProfileInput(profile(overrides))).toThrow(); +}); +test('consent, age and missing-profile invariants fail closed', () => { + expect(() => readConsent({ ...consent, memberDiscovery: false })).toThrow(); + expect(() => readProfileState({ ...state(), adultConfirmed: false })).toThrow(); + expect(() => readProfileState({ ...state(), profile: null })).toThrow(); + expect(() => readProfileState({ ...state(), revision: -0 })).toThrow(); + expect(() => readExclusion({ revision: 1, hidden: 'true', blocked: false })).toThrow(); +}); +test('oversized, duplicate or private recommendation data never reaches rendering', () => { + const valid = result(); + [ + { ...valid, uid: UID }, + { ...valid, similar: Array(5).fill(valid.similar[0]) }, + { ...valid, broaden: [valid.similar[0]] }, + { ...valid, status: 'profile_required' }, + { ...valid, similar: [{ ...valid.similar[0], email: 'synthetic@example.test' }] }, + { ...valid, similar: [{ ...valid.similar[0], reasons: ['verified_identity'] }] }, + ].forEach((bad) => expect(() => readRecommendations(bad)).toThrow()); +}); +test('hostile response getters are not invoked or forwarded', () => { + const getter = jest.fn(() => 'synthetic-private'); + const bad = state(); + Object.defineProperty(bad, 'profile', { enumerable: true, get: getter }); + expect(() => readProfileState(bad)).toThrow('Runner connection data is unavailable.'); + expect(getter).not.toHaveBeenCalled(); +}); +test('service calls the exact scoped endpoint and snapshots a save before awaiting', async () => { + const request = input(); + let resolve!: (data: unknown) => void; + invoke.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const pending = createRunnerClient(app, UID).save(request); + request.profile.displayName = 'Later synthetic name'; + expect(httpsCallable).toHaveBeenCalledWith( + { synthetic: true }, + 'saveMyRunnerConnectionProfile', + ); + expect(invoke.mock.calls[0][0]).toEqual({ ...input(), profile: readProfile(profile()) }); + resolve({ data: state() }); + await expect(pending).resolves.toEqual(state()); +}); +test('missing 18+ or forged authority/target is rejected before SDK work', () => { + const client = createRunnerClient(app, UID); + expect(() => client.save({ ...input(), adultConfirmed: false } as any)).toThrow(); + expect(() => client.save({ ...input(), uid: 'other' } as any)).toThrow(); + expect(() => client.withdraw({ requestId: input().requestId, expectedRevision: -1 }),).toThrow(); + expect(httpsCallable).not.toHaveBeenCalled(); +}); +test('account changes reject before sending and discard a result after await', async () => { + const client = createRunnerClient(app, UID); + authState.currentUser = null; + await expect(client.read()).rejects.toThrow(); + expect(invoke).not.toHaveBeenCalled(); + authState.currentUser = { uid: UID }; + invoke.mockImplementation(async () => { + authState.currentUser = { uid: 'synthetic-other' }; + return { data: state() }; + }); + await expect(client.read()).rejects.toThrow(); +}); +test('mismatched mutation responses are unknown outcomes, not successful saves', async () => { + const client = createRunnerClient(app, UID); + invoke.mockResolvedValue({ data: { ...state(), revision: 9 } }); + await expect(client.save(input())).rejects.toThrow(); + invoke.mockResolvedValue({ data: state() }); + await expect( + client.withdraw({ requestId: input().requestId, expectedRevision: 0 }), + ).rejects.toThrow(); + invoke.mockResolvedValue({ data: { revision: 1, hidden: false, blocked: false } }); + await expect( + client.exclude({ + requestId: input().requestId, + expectedRevision: 0, + entryRef: `runner_${'a'.repeat(64)}`, + hidden: true, + blocked: false, + }), + ).rejects.toThrow(); +}); +test('all six client operations use their existing exact callable contracts', async () => { + const client = createRunnerClient(app, UID); + invoke.mockResolvedValueOnce({ data: state() }); + await client.read(); + invoke.mockResolvedValueOnce({ data: result() }); + await client.recommend(); + invoke.mockResolvedValueOnce({ data: state() }); + await client.save(input()); + invoke.mockResolvedValueOnce({ + data: { + ...state(), + profile: null, + adultConfirmed: false, + consent: { memberDiscovery: false, similarity: false, broadenCircle: false }, + }, + }); + await client.withdraw({ requestId: input().requestId, expectedRevision: 0 }); + const ref = `runner_${'a'.repeat(64)}`; + invoke.mockResolvedValueOnce({ data: { revision: 0, hidden: false, blocked: false } }); + await client.exclusion(ref); + invoke.mockResolvedValueOnce({ data: { revision: 1, hidden: true, blocked: false } }); + await client.exclude({ + requestId: input().requestId, + expectedRevision: 0, + entryRef: ref, + hidden: true, + blocked: false, + }); + expect((httpsCallable as jest.Mock).mock.calls.map((call) => call[1])).toEqual([ + 'getMyRunnerConnectionProfile', + 'getMyRunnerConnectionRecommendations', + 'saveMyRunnerConnectionProfile', + 'withdrawMyRunnerConnectionProfile', + 'getMyRunnerConnectionExclusion', + 'setMyRunnerConnectionExclusion', + ]); +}); +test('error classification reads only a safe code, never raw messages or hostile getters', () => { + expect(definitiveRejection({ code: 'functions/aborted' })).toBe(true); + expect(definitiveRejection({ code: 'functions/unavailable' })).toBe(false); + const getter = jest.fn(); + expect(definitiveRejection(Object.defineProperty({}, 'code', { get: getter }))).toBe(false); + expect(getter).not.toHaveBeenCalled(); +}); diff --git a/src/services/account/runnerConnectionService.ts b/src/services/account/runnerConnectionService.ts new file mode 100644 index 00000000..61e5324b --- /dev/null +++ b/src/services/account/runnerConnectionService.ts @@ -0,0 +1,133 @@ +import { FirebaseApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth'; +import { getFunctions, httpsCallable } from 'firebase/functions'; +import { createMemberDirectoryRequestId } from './memberDirectoryService'; +import { + exact, + invalid, + readConsent, + readExclusion, + readProfile, + readProfileState, + readRecommendations, + readReference, + Mutation, + SaveRequest, + ExclusionRequest, +} from './runnerConnectionContract'; + +// UI capability only, never server authority. Enabling needs a reviewed release. +export const RUNNER_CONNECTIONS_AVAILABLE: boolean = false; +export const createRunnerRequestId = createMemberDirectoryRequestId; +export function definitiveRejection(failure: unknown): boolean { + try { + if (!failure || typeof failure !== 'object') return false; + const code = Object.getOwnPropertyDescriptor(failure, 'code'); + return ( + !!code + && Object.prototype.hasOwnProperty.call(code, 'value') + && [ + 'functions/aborted', + 'functions/failed-precondition', + 'functions/invalid-argument', + 'functions/permission-denied', + 'functions/resource-exhausted', + 'functions/unauthenticated', + ].includes(code.value) + ); + } catch { + return false; + } +} +function mutation(request: Mutation): Mutation { + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + request.requestId, + ) + || !Number.isSafeInteger(request.expectedRevision) + || Object.is(request.expectedRevision, -0) + || request.expectedRevision < 0 + || request.expectedRevision >= Number.MAX_SAFE_INTEGER - 1 + ) return invalid(); + return { requestId: request.requestId, expectedRevision: request.expectedRevision }; +} +export function createRunnerClient(app: FirebaseApp, uid: string) { + async function invoke( + name: string, + request: object, + parse: (data: unknown) => T, + ): Promise { + // Prevent queued work/results from crossing an account change. Actual + // authorization remains in Firebase's callable runtime and the server. + if (!uid || getAuth(app).currentUser?.uid !== uid) return invalid(); + const result = await httpsCallable(getFunctions(app), name)(request); + if (getAuth(app).currentUser?.uid !== uid) return invalid(); + return parse(result.data); + } + return { + read: () => invoke('getMyRunnerConnectionProfile', {}, readProfileState), + recommend: () => invoke('getMyRunnerConnectionRecommendations', {}, readRecommendations), + save: (input: SaveRequest) => { + exact(input, [ + 'requestId', + 'expectedRevision', + 'profile', + 'consent', + 'adultConfirmed', + 'consentVersion', + ]); + if (input.adultConfirmed !== true || input.consentVersion !== 1) return invalid(); + const request = { + ...mutation(input), + profile: readProfile(input.profile), + consent: readConsent(input.consent), + adultConfirmed: true, + consentVersion: 1, + }; + return invoke('saveMyRunnerConnectionProfile', request, (data) => { + const state = readProfileState(data); + if ( + state.revision !== request.expectedRevision + 1 + || JSON.stringify(state.profile) !== JSON.stringify(request.profile) + || JSON.stringify(state.consent) !== JSON.stringify(request.consent) + ) return invalid(); + return state; + }); + }, + withdraw: (input: Mutation) => { + exact(input, ['requestId', 'expectedRevision']); + const request = mutation(input); + return invoke('withdrawMyRunnerConnectionProfile', request, (data) => { + const state = readProfileState(data); + if (state.revision !== request.expectedRevision + 1 + || state.profile !== null) return invalid(); + return state; + }); + }, + exclusion: (entryRef: string) => invoke( + 'getMyRunnerConnectionExclusion', + { entryRef: readReference(entryRef) }, + readExclusion, + ), + exclude: (input: ExclusionRequest) => { + exact(input, ['requestId', 'expectedRevision', 'entryRef', 'hidden', 'blocked']); + if (typeof input.hidden !== 'boolean' || typeof input.blocked !== 'boolean') return invalid(); + const request = { + ...mutation(input), + entryRef: readReference(input.entryRef), + hidden: input.hidden, + blocked: input.blocked, + }; + return invoke('setMyRunnerConnectionExclusion', request, (data) => { + const state = readExclusion(data); + if ( + state.revision !== request.expectedRevision + 1 + || state.hidden !== request.hidden + || state.blocked !== request.blocked + ) return invalid(); + return state; + }); + }, + }; +} +export type RunnerClient = ReturnType; diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index fed5ee86..fd86bb10 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -43,7 +43,7 @@ const CLEAN_COMMANDS = Object.freeze([ 'test -z "$frontend_git_status"', ]); const LINT_SCRIPT = 'node .github/scripts/check-frontend-lint.cjs'; -const EXPECTED_LINT_FILES = 120; +const EXPECTED_LINT_FILES = 127; const EXPECTED_LINT_ERRORS = 113; const EXPECTED_LINT_WARNINGS = 6; const YAML_TO_JSON = [ @@ -173,6 +173,29 @@ function expectedFirestoreRulesJob() { }, { run: 'npm ci --legacy-peer-deps --ignore-scripts' }, { name: FIRESTORE_RULES_STEP_NAME, run: 'npm run test:rules' }, + { + name: 'Install Functions dependencies for runner profile tests', + run: 'npm --prefix functions ci --ignore-scripts', + }, + { + name: 'Run runner profile persistence tests against the emulator', + env: { + REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1', + REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1', + REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR: '1', + }, + run: 'npx --no-install firebase emulators:exec ' + + '--project demo-functions-test --only firestore,auth ' + + '"npm --prefix functions run test:run -- ' + + '--runInBand runnerConnectionProfiles.emulator.test.js ' + + 'runnerConnectionService.emulator.test.js ' + + 'runnerConnectionRecommendations.emulator.test.js"', + }, + { + name: 'Run runner client HTTP transport tests', + 'timeout-minutes': 5, + run: 'node scripts/run-runner-transport-tests.cjs', + }, ], }; } @@ -867,6 +890,43 @@ test('guard rejects unsafe Firestore Rules runtime or lockfile mutations', () => }); }); +test('guard rejects skipped, misdirected or weakened runner-profile emulator execution', () => { + const stepName = 'Run runner profile persistence tests against the emulator'; + const runnerJob = jobBlock(ciWorkflow, FIRESTORE_JOB_ID); + const mutateRunner = (from, to) => ciWorkflow.replace( + runnerJob, runnerJob.replace(from, to), + ); + const mutations = [ + mutateRunner("REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '0'"), + mutateRunner("REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '0'"), + mutateRunner("REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR: '0'"), + mutateRunner('--only firestore,auth', '--only firestore'), + mutateRunner('runnerConnectionService.emulator.test.js', 'runnerConnectionService.test.js'), + mutateRunner('runnerConnectionRecommendations.emulator.test.js', 'runnerConnections.test.js'), + mutateRunner('--project demo-functions-test', '--project synthetic-hosted-project'), + mutateRunner('runnerConnectionProfiles.emulator.test.js', 'runnerConnections.test.js'), + mutateRunner(` - name: ${stepName}`, ` - name: ${stepName}\n if: ${NEVER_RUN}`), + mutateRunner(` - name: ${stepName}`, ` - name: ${stepName}\n continue-on-error: true`), + ]; + mutations.forEach((mutated) => { + assert.notEqual(mutated, ciWorkflow); + assert.notDeepEqual(firestoreRulesErrors(mutated), []); + }); +}); + +test('guard requires exact runner HTTP transport execution without bypass or omission', () => { + const step = ' - name: Run runner client HTTP transport tests\n' + + ' timeout-minutes: 5\n' + + ' run: node scripts/run-runner-transport-tests.cjs\n'; + assert.ok(ciWorkflow.includes(step)); + [ + ciWorkflow.replace(step, ''), + ciWorkflow.replace(step, step.replace(' timeout', ` if: ${NEVER_RUN}\n timeout`)), + ciWorkflow.replace(step, step.replace(' timeout', ' continue-on-error: true\n timeout')), + ciWorkflow.replace('run: node scripts/run-runner-transport-tests.cjs', 'run: echo skipped'), + ].forEach((mutated) => assert.notDeepEqual(firestoreRulesErrors(mutated), [])); +}); + test('guard rejects omission from either protected-release recheck', () => { const requiredLine = ` "${JOB_NAME}" \\\n`; const withoutFirst = releaseWorkflow.replace(requiredLine, ''); diff --git a/tests/firestore-rules/runner-connections.test.js b/tests/firestore-rules/runner-connections.test.js new file mode 100644 index 00000000..6c87f90f --- /dev/null +++ b/tests/firestore-rules/runner-connections.test.js @@ -0,0 +1,39 @@ +/* eslint-env jest */ + +const { clearFirestore, teardown, db, seed, assertFails } = require('./setup'); + +const actors = [ + ['anonymous', undefined], + ['owner without member authority', { uid: 'synthetic-runner' }], + ['verified member owner', { uid: 'synthetic-runner', role: 'member', emailVerified: true }], + ['another verified member', { uid: 'synthetic-other', role: 'member', emailVerified: true }], + ['verified browser admin', { uid: 'synthetic-admin', role: 'admin', emailVerified: true }], +]; + +beforeEach(clearFirestore); +afterAll(teardown); + +describe.each(actors)('runner connection profile boundary — %s', (_label, auth) => { + test.each([ + 'runnerConnectionProfiles', + 'runnerConnectionProfiles/synthetic-runner/privateRecords', + 'memberships', + 'memberships/synthetic-runner/privateRecords', + 'runnerConnectionEntries', + 'runnerConnectionEntries/synthetic-runner/privateRecords', + 'runnerConnectionWindows', + 'runnerConnectionWindows/synthetic-runner/seen', + 'runnerConnectionExclusions', + 'runnerConnectionExclusions/synthetic-runner/privateRecords', + ])('cannot read, enumerate or mutate %s', async (collectionPath) => { + const documentPath = `${collectionPath}/synthetic-runner`; + await seed(documentPath, { marker: 'synthetic-private-card' }); + const client = await db(auth); + await assertFails(client.doc(documentPath).get()); + await assertFails(client.collection(collectionPath).get()); + await assertFails(client.collectionGroup(collectionPath.split('/').pop()).get()); + await assertFails(client.doc(`${collectionPath}/synthetic-new`).set({ marker: 'synthetic-forged' })); + await assertFails(client.doc(documentPath).update({ discoverable: true })); + await assertFails(client.doc(documentPath).delete()); + }); +}); diff --git a/tests/runner-browser/entry.tsx b/tests/runner-browser/entry.tsx new file mode 100644 index 00000000..68ac9808 --- /dev/null +++ b/tests/runner-browser/entry.tsx @@ -0,0 +1,89 @@ +// Test-only browser entry. Never imported by src or a hosted build. +import React, { useMemo, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { initializeApp, deleteApp } from 'firebase/app'; +import { + initializeAuth, inMemoryPersistence, connectAuthEmulator, + signInWithEmailAndPassword, signOut, +} from 'firebase/auth'; +import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; +import { initializeAppCheck, CustomProvider } from 'firebase/app-check'; +import { RunnerWorkspace } from '../../src/pages/account/RunnerConnections'; +import { createRunnerClient } from '../../src/services/account/runnerConnectionService'; + +if (window.location.origin !== 'http://127.0.0.1:9619') { + throw new Error('Synthetic rehearsal requires the exact loopback origin.'); +} +const app = initializeApp({ + projectId: 'demo-runner-http-test', apiKey: 'synthetic-local-api-key', appId: 'synthetic-browser-app', +}, 'synthetic-runner-browser'); +const auth = initializeAuth(app, { persistence: inMemoryPersistence }); +connectAuthEmulator(auth, 'http://127.0.0.1:9919', { disableWarnings: true }); +connectFunctionsEmulator(getFunctions(app), '127.0.0.1', 9519); +initializeAppCheck(app, { + provider: new CustomProvider({ getToken: async () => { + const now = Math.floor(Date.now() / 1000); + const encode = (value: unknown) => btoa(JSON.stringify(value)).replace(/=+$/, '') + .replace(/\+/g, '-').replace(/\//g, '_'); + // Emulator decoder only: deliberately not signed, attested or hosted proof. + return { + token: `${encode({ alg: 'none' })}.${encode({ sub: 'synthetic-browser-app', iat: now, exp: now + 3600 })}.`, + expireTimeMillis: Date.now() + 3600000, + }; + } }), + isTokenAutoRefreshEnabled: false, +}); + +type Fixture = { actors: { owner: string; partner: string; outsider: string }; password: string }; +function Workspace({ uid }: { uid: string }) { + const client = useMemo(() => createRunnerClient(app, uid), [uid]); + return ; +} +function Rehearsal({ fixture }: { fixture: Fixture }) { + const [uid, setUid] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState('No synthetic account selected.'); + async function select(actor: keyof Fixture['actors']) { + setBusy(true); + setUid(null); + try { + await signOut(auth); + const result = await signInWithEmailAndPassword( + auth, `${fixture.actors[actor]}@example.test`, fixture.password, + ); + setUid(result.user.uid); + setMessage(`Synthetic ${actor} signed in through local Auth.`); + } catch { setMessage('Synthetic sign-in failed.'); } + finally { setBusy(false); } + } + async function finish() { + setBusy(true); + setUid(null); + try { + await signOut(auth); + await deleteApp(app); + const response = await fetch('/finish', { method: 'POST' }); + if (!response.ok) throw new Error(); + setMessage('Rehearsal finished; synthetic records and accounts removed. Close this tab.'); + } catch { setMessage('Cleanup not confirmed. Ask the maintainer to inspect the local process.'); } + } + return
+

Synthetic runner backend rehearsal

+

LOCAL TEST ONLY. Made-up accounts and records. Real member UI and callable client; + loopback Auth, Functions and Firestore. No hosted token-signature proof.

+
+ {(['owner', 'partner', 'outsider'] as const).map((actor) => )} + +
+

{message}

+ {uid && } +
; +} +fetch('/fixture.json', { cache: 'no-store' }).then(async (response) => { + if (!response.ok) throw new Error(); + const fixture = await response.json() as Fixture; + createRoot(document.getElementById('root')!).render(); +}).catch(() => { + document.getElementById('root')!.textContent = 'Synthetic fixture unavailable.'; +}); diff --git a/tests/runner-browser/policy.cjs b/tests/runner-browser/policy.cjs new file mode 100644 index 00000000..bbdf5e80 --- /dev/null +++ b/tests/runner-browser/policy.cjs @@ -0,0 +1,18 @@ +'use strict'; + +const ORIGIN = 'http://127.0.0.1:9619'; +const HEADERS = Object.freeze({ + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self' http://127.0.0.1:9919 http://127.0.0.1:9519; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', +}); +function route(method, url, host, origin) { + // Literal Host rejects DNS rebinding; exact Origin rejects cross-site cleanup + // and fixture access. There are no filesystem paths or arbitrary Admin APIs. + if (host !== '127.0.0.1:9619' || (origin !== undefined && origin !== ORIGIN)) return null; + if (method === 'GET' && ['/', '/bundle.js', '/fixture.json'].includes(url)) return url; + if (method === 'POST' && url === '/finish' && origin === ORIGIN) return url; + return null; +} +module.exports = { ORIGIN, HEADERS, route }; diff --git a/tests/runner-browser/server.cjs b/tests/runner-browser/server.cjs new file mode 100644 index 00000000..a7a337c8 --- /dev/null +++ b/tests/runner-browser/server.cjs @@ -0,0 +1,166 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const http = require('node:http'); +const { randomUUID } = require('node:crypto'); +const { prepareRunnerEmulators } = require('../../functions/testSupport/runnerHttp/environment'); +prepareRunnerEmulators(process.env); +if (process.env.RUNNER_HTTP_BROWSER !== '1' || process.env.REQUIRE_RUNNER_HTTP_EMULATOR !== '1') { + throw new Error('Synthetic browser rehearsal requires the isolated launcher.'); +} +process.env.FUNCTIONS_EMULATOR_HOST = '127.0.0.1:9519'; +require('../../functions/testSupport/testSafety').installNetworkGuard(); +process.env.NODE_ENV = 'production'; +const { initializeRunnerAdmin } = require('../../functions/testSupport/runnerHttp/admin'); +const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('../../functions/membershipAuthority'); +const { createRunnerProfileStore } = require('../../functions/runnerConnectionProfiles'); +const { profile } = require('../../functions/testSupport/runnerConnectionsFixtures'); +const { runnerEntryId, exclusionId } = require('../../functions/runnerConnectionReferences'); +const { accountRateKey } = require('../../functions/runnerConnectionService'); +const { ORIGIN, HEADERS, route } = require('./policy.cjs'); + +const root = path.resolve(__dirname, '../..'); +const seed = randomUUID(); +const actors = Object.fromEntries(['owner', 'partner', 'outsider'].map((role) => [role, `synthetic-browser-${role}-${seed}`])); +const password = 'synthetic-local-test-password-only'; +const app = initializeRunnerAdmin(); +const db = app.firestore(); +const auth = app.auth(); +let server; +let timer; +let stopping; +let startup; +let fixturesStarted = false; + +function membership(uid) { + const created = createMembershipAuthority({ + membershipAuthoritySchemaVersion: 1, membershipId: uid, commandId: 'synthetic-create', + }); + const linked = applyMembershipAuthorityCommand(created, { + membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', + commandId: 'synthetic-link', expectedRevision: 1, uid, + }); + return applyMembershipAuthorityCommand(linked, { + membershipAuthoritySchemaVersion: 1, commandType: 'record_term_decision', + commandId: 'synthetic-term', expectedRevision: 2, termRevision: 1, termState: 'approved', + termId: 'synthetic-term-id', startsAtMs: Date.now() - 60000, endsAtMs: Date.now() + 3600000, + planRef: 'synthetic-plan', evidenceRef: 'synthetic-evidence', policyVersion: 'synthetic-policy', + }); +} +async function build() { + const webpack = require('webpack'); + const { createFsFromVolume, Volume } = require('memfs'); + const memory = createFsFromVolume(new Volume()); + const compiler = webpack({ + mode: 'production', context: root, entry: path.join(__dirname, 'entry.tsx'), + output: { path: '/synthetic-runner-browser', filename: 'bundle.js' }, + devtool: false, cache: false, performance: false, + optimization: { minimize: false }, + resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'] }, + module: { rules: [ + { test: /\.[jt]sx?$/, include: [path.join(root, 'src'), __dirname], + loader: require.resolve('babel-loader'), + options: { babelrc: false, configFile: false, cacheDirectory: false, + presets: [[require.resolve('babel-preset-react-app'), { runtime: 'automatic' }]] } }, + { test: /\.css$/, use: [require.resolve('style-loader'), require.resolve('css-loader')] }, + ] }, + }); + compiler.outputFileSystem = memory; + return new Promise((resolve, reject) => compiler.run((error, stats) => { + compiler.close(() => { + if (error || !stats || stats.hasErrors()) return reject(new Error('Synthetic browser bundle failed.')); + resolve(memory.readFileSync('/synthetic-runner-browser/bundle.js')); + }); + })); +} +async function cleanup() { + if (fixturesStarted) { + // Only this run's unpredictable, synthetic IDs. Never clear a collection or + // use the Auth emulator's delete-all endpoint. + for (const uid of Object.values(actors)) { + for (const [collection, id] of [ + ['memberships', uid], ['runnerConnectionProfiles', uid], + ['runnerConnectionEntries', runnerEntryId(uid)], ['runnerConnectionWindows', uid], + ]) await db.recursiveDelete(db.collection(collection).doc(id)); + const receipts = await db.collection('auditEvents').where('actorUid', '==', uid).get(); + for (const receipt of receipts.docs) await receipt.ref.delete(); + for (const operation of ['read', 'save', 'withdraw', 'recommend', 'exclusionRead', 'exclude']) { + await db.collection('ratelimits').doc(`runner_connection_${operation}__${accountRateKey(uid)}`).delete(); + } + for (const target of Object.values(actors)) { + await db.collection('runnerConnectionExclusions').doc(exclusionId(uid, target)).delete(); + } + try { await auth.deleteUser(uid); } catch (failure) { + if (failure.code !== 'auth/user-not-found') throw failure; + } + } + assert.equal((await db.listCollections()).length, 0); + assert.equal((await auth.listUsers(1)).users.length, 0); + console.log('runner_browser_cleanup_verified: users=0 collections=0'); + } +} +function stop(code, response) { + if (stopping) { + if (response) { response.writeHead(409); response.end('Already stopping.'); } + return stopping; + } + stopping = Promise.resolve().then(async () => { + try { + // A stop during compilation/seeding must not race cleanup with later + // fixture creation. Reject new HTTP work immediately, then drain startup. + await startup.catch(() => {}); + clearTimeout(timer); + try { await cleanup(); } finally { await app.delete(); } + if (response) { response.writeHead(200); response.end('Synthetic cleanup verified.'); } + process.exitCode = code; + } catch { + if (response) { response.writeHead(500); response.end('Cleanup not verified.'); } + console.error('runner_browser_cleanup_not_verified'); + process.exitCode = 1; + } finally { + if (server?.listening) { server.close(); server.closeIdleConnections(); } + } + }); + return stopping; +} +async function main() { + const bundle = await build(); + // Refuse an occupied environment before creating or deleting any fixtures. + assert.equal((await db.listCollections()).length, 0); + assert.equal((await auth.listUsers(1)).users.length, 0); + fixturesStarted = true; + for (const uid of Object.values(actors)) { + await auth.createUser({ uid, email: `${uid}@example.test`, emailVerified: true, password }); + } + for (const uid of [actors.owner, actors.partner]) await db.collection('memberships').doc(uid).set(membership(uid)); + // A declared fixture, not user sign-up or membership administration. Only the + // partner begins with a card; owner and outsider exercise the actual form. + const store = createRunnerProfileStore({ db, authorize: async ({ uid }) => uid === actors.partner }); + await store.saveOwn(actors.partner, { + requestId: randomUUID(), expectedRevision: 0, profile: profile({ displayName: 'Synthetic Browser Partner' }), + adultConfirmed: true, consentVersion: 1, + consent: { memberDiscovery: true, similarity: true, broadenCircle: false }, + }); + const html = 'Synthetic runner backend rehearsal
'; + server = http.createServer((request, response) => { + for (const [name, value] of Object.entries(HEADERS)) response.setHeader(name, value); + const selected = route(request.method, request.url, request.headers.host, request.headers.origin); + if (!selected || stopping) { response.writeHead(403); response.end('Unavailable.'); return; } + if (selected === '/finish') { request.resume(); void stop(0, response); return; } + const [type, body] = selected === '/' ? ['text/html; charset=utf-8', html] + : selected === '/bundle.js' ? ['text/javascript; charset=utf-8', bundle] + : ['application/json', JSON.stringify({ actors, password })]; + response.writeHead(200, { 'Content-Type': type }); + response.end(body); + }); + server.requestTimeout = 10000; + server.headersTimeout = 10000; + server.on('error', () => { console.error('runner_browser_server_failed'); void stop(1); }); + server.listen(9619, '127.0.0.1', () => console.log(`runner_browser_ready: ${ORIGIN}`)); + timer = setTimeout(() => { console.error('runner_browser_expired'); void stop(1); }, 30 * 60 * 1000); +} +process.once('SIGINT', () => { void stop(1); }); +process.once('SIGTERM', () => { void stop(1); }); +startup = main(); +startup.catch(() => { console.error('runner_browser_start_failed'); void stop(1); }); diff --git a/tests/runner-transport.test.cjs b/tests/runner-transport.test.cjs new file mode 100644 index 00000000..e2300d1b --- /dev/null +++ b/tests/runner-transport.test.cjs @@ -0,0 +1,260 @@ +'use strict'; + +// Real Web SDK -> loopback Functions HTTP -> local Admin Auth/Firestore. +// Emulator token decoding is NOT cryptographic Auth/App Check verification. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { test, before, after } = require('node:test'); +const { PROJECT, prepareRunnerEmulators } = require('../functions/testSupport/runnerHttp/environment'); +const { installNetworkGuard } = require('../functions/testSupport/testSafety'); + +prepareRunnerEmulators(process.env); +if (process.env.REQUIRE_RUNNER_HTTP_EMULATOR !== '1') throw new Error('Runner HTTP test opt-in required.'); +process.env.FUNCTIONS_EMULATOR_HOST = '127.0.0.1:9519'; +installNetworkGuard(); + +// Compile only these reviewed local TS modules in memory. No generated client +// or duplicated implementation: tests use the shipped createRunnerClient code. +const ts = require('typescript'); +const clientPaths = new Set([ + 'runnerConnectionService.ts', 'runnerConnectionContract.ts', 'memberDirectoryService.ts', +].map((name) => path.resolve(__dirname, '../src/services/account', name))); +const previousTsLoader = require.extensions['.ts']; +require.extensions['.ts'] = (module, filename) => { + if (!clientPaths.has(filename)) throw new Error('Unexpected transport test module.'); + const result = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + fileName: filename, + }); + module._compile(result.outputText, filename); +}; +const { createRunnerClient } = require('../src/services/account/runnerConnectionService.ts'); +if (previousTsLoader) require.extensions['.ts'] = previousTsLoader; +else delete require.extensions['.ts']; + +const { initializeApp, deleteApp } = require('firebase/app'); +const { initializeAuth, inMemoryPersistence, connectAuthEmulator, signInWithEmailAndPassword, signOut } = require('firebase/auth'); +const { getFunctions, connectFunctionsEmulator, httpsCallable } = require('firebase/functions'); +const { initializeAppCheck, CustomProvider } = require('firebase/app-check'); +const { initializeRunnerAdmin } = require('../functions/testSupport/runnerHttp/admin'); +const { createMembershipAuthority, applyMembershipAuthorityCommand } = require('../functions/membershipAuthority'); +const { profile } = require('../functions/testSupport/runnerConnectionsFixtures'); +const { runnerEntryId, exclusionId } = require('../functions/runnerConnectionReferences'); +const { accountRateKey } = require('../functions/runnerConnectionService'); + +const seed = randomUUID(); +const owner = `synthetic-http-owner-${seed}`; +const partner = `synthetic-http-partner-${seed}`; +const outsider = `synthetic-http-outsider-${seed}`; +const actors = [owner, partner, outsider]; +const apps = []; +const password = 'synthetic-local-test-password-only'; +let adminApp; +let db; +let auth; +let client; +let webAuth; +let ownerApp; + +function membership(uid) { + const created = createMembershipAuthority({ + membershipAuthoritySchemaVersion: 1, membershipId: uid, commandId: 'synthetic-create', + }); + const linked = applyMembershipAuthorityCommand(created, { + membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', + commandId: 'synthetic-link', expectedRevision: 1, uid, + }); + return applyMembershipAuthorityCommand(linked, { + membershipAuthoritySchemaVersion: 1, commandType: 'record_term_decision', + commandId: 'synthetic-term', expectedRevision: 2, termRevision: 1, termState: 'approved', + termId: 'synthetic-term-id', startsAtMs: Date.now() - 60000, endsAtMs: Date.now() + 3600000, + planRef: 'synthetic-plan', evidenceRef: 'synthetic-evidence', policyVersion: 'synthetic-policy', + }); +} +function appCheckToken() { + // Unsigned test token accepted ONLY by the emulator's own debug decoder. + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const now = Math.floor(Date.now() / 1000); + return `${encode({ alg: 'none' })}.${encode({ sub: 'synthetic-http-app', iat: now, exp: now + 3600 })}.`; +} +async function browser(uid, appCheck = true) { + const app = initializeApp({ + projectId: PROJECT, apiKey: 'synthetic-local-api-key', appId: 'synthetic-http-app', + }, `${uid}-${apps.length}`); + apps.push(app); + const authentication = initializeAuth(app, { persistence: inMemoryPersistence }); + connectAuthEmulator(authentication, 'http://127.0.0.1:9919', { disableWarnings: true }); + connectFunctionsEmulator(getFunctions(app), '127.0.0.1', 9519); + if (appCheck) initializeAppCheck(app, { + provider: new CustomProvider({ getToken: async () => ({ + token: appCheckToken(), expireTimeMillis: Date.now() + 3600000, + }) }), + isTokenAutoRefreshEnabled: false, + }); + await signInWithEmailAndPassword(authentication, `${uid}@example.test`, password); + return { app, authentication, client: createRunnerClient(app, uid) }; +} +const save = (expectedRevision = 0, displayName = 'Synthetic HTTP Owner') => ({ + requestId: randomUUID(), expectedRevision, profile: profile({ displayName }), + adultConfirmed: true, consentVersion: 1, + consent: { memberDiscovery: true, similarity: true, broadenCircle: false }, +}); +async function audits(uid) { + return (await db.collection('auditEvents').where('actorUid', '==', uid).get()).size; +} + +before(async () => { + adminApp = initializeRunnerAdmin(); + db = adminApp.firestore(); + auth = adminApp.auth(); + for (const uid of actors) { + await auth.createUser({ uid, email: `${uid}@example.test`, emailVerified: true, password }); + } + for (const uid of [owner, partner]) await db.collection('memberships').doc(uid).set(membership(uid)); + const first = await browser(owner); + ({ client, app: ownerApp, authentication: webAuth } = first); +}); +after(async () => { + for (const app of apps) await deleteApp(app); + if (!db || !auth) return; + for (const uid of actors) { + for (const [collection, id] of [ + ['memberships', uid], ['runnerConnectionProfiles', uid], + ['runnerConnectionEntries', runnerEntryId(uid)], ['runnerConnectionWindows', uid], + ]) await db.recursiveDelete(db.collection(collection).doc(id)); + const receipts = await db.collection('auditEvents').where('actorUid', '==', uid).get(); + for (const receipt of receipts.docs) await receipt.ref.delete(); + for (const operation of ['read', 'save', 'withdraw', 'recommend', 'exclusionRead', 'exclude']) { + await db.collection('ratelimits').doc(`runner_connection_${operation}__${accountRateKey(uid)}`).delete(); + } + for (const target of actors) { + await db.collection('runnerConnectionExclusions').doc(exclusionId(uid, target)).delete(); + } + try { await auth.deleteUser(uid); } catch (failure) { + if (failure.code !== 'auth/user-not-found') throw failure; + } + } + assert.equal((await db.listCollections()).length, 0, 'Synthetic database cleanup incomplete.'); + assert.equal((await auth.listUsers(1)).users.length, 0, 'Synthetic account cleanup incomplete.'); + await adminApp.delete(); +}); + +test('actual browser client recovers the same command after losing a committed HTTP reply', async () => { + const empty = await client.read(); + assert.equal(empty.profile, null); + assert.equal(empty.adultConfirmed, false); + const command = save(); + const originalFetch = global.fetch; + let lost = false; + global.fetch = async (...args) => { + const response = await originalFetch(...args); + if (String(args[0]).endsWith('/saveMyRunnerConnectionProfile') && !lost) { + assert.equal(response.status, 200); + await response.arrayBuffer(); + lost = true; + throw new Error('Synthetic lost reply after server commit.'); + } + return response; + }; + try { + await assert.rejects(client.save(command), { code: 'functions/internal' }); + } finally { global.fetch = originalFetch; } + assert.equal(lost, true); + assert.equal(await audits(owner), 1); + const saved = await client.save(command); + assert.equal(saved.revision, 1); + assert.equal(saved.adultConfirmed, true); + assert.deepEqual(await client.save(command), saved); + assert.equal(await audits(owner), 1); + assert.deepEqual(await client.read(), saved); +}); +test('missing App Check and signed-out HTTP requests are denied', async () => { + const missing = await browser(outsider, false); + await assert.rejects(missing.client.read(), { code: 'functions/unauthenticated' }); + const signedOut = await browser(outsider); + await signOut(signedOut.authentication); + await assert.rejects(httpsCallable(getFunctions(signedOut.app), 'getMyRunnerConnectionProfile')({}), + { code: 'functions/unauthenticated' }); + assert.equal(await audits(outsider), 0); +}); +test('unapproved membership is denied by server, not a browser role', async () => { + await auth.setCustomUserClaims(outsider, { role: 'admin' }); + const member = await browser(outsider); + await assert.rejects(member.client.save(save()), { code: 'functions/permission-denied' }); + assert.equal(await audits(outsider), 0); +}); +test('server invalid-argument and revision-conflict responses traverse the actual SDK', async () => { + await assert.rejects(httpsCallable(getFunctions(ownerApp), 'saveMyRunnerConnectionProfile')({ + ...save(1), adultConfirmed: false, + }), { code: 'functions/invalid-argument' }); + await assert.rejects(client.save(save(0)), { code: 'functions/aborted' }); + assert.equal(await audits(owner), 1); +}); +test('HTTP replies are private/no-store and contain only the closed owner projection', async () => { + const response = await fetch(`http://127.0.0.1:9519/${PROJECT}/us-central1/getMyRunnerConnectionProfile`, { + method: 'POST', headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await webAuth.currentUser.getIdToken()}`, + 'X-Firebase-AppCheck': appCheckToken(), + }, body: JSON.stringify({ data: {} }), + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'private, no-store, max-age=0'); + assert.equal(response.headers.get('pragma'), 'no-cache'); + const body = await response.json(); + assert.deepEqual(body.result, await client.read()); + assert.deepEqual(Object.keys(body.result).sort(), [ + 'adultConfirmed', 'consent', 'consentVersion', 'profile', 'revision', 'schemaVersion', + ]); +}); +test('recommendation, directed privacy and same-choice retry work across real HTTP calls', async () => { + const other = await browser(partner); + await other.client.save(save(0, 'Synthetic HTTP Partner')); + const result = await client.recommend(); + assert.equal(result.similar.length, 1); + assert.equal(result.similar[0].displayName, 'Synthetic HTTP Partner'); + assert.equal(result.similar[0].email, undefined); + assert.equal(result.similar[0].uid, undefined); + assert.equal(result.similar[0].experiencePreferences, undefined); + assert.equal((await other.client.recommend()).similar.length, 1); + const { entryRef } = result.similar[0]; + assert.deepEqual(await client.exclusion(entryRef), { revision: 0, hidden: false, blocked: false }); + const block = { requestId: randomUUID(), expectedRevision: 0, entryRef, hidden: false, blocked: true }; + const blocked = await client.exclude(block); + assert.deepEqual(blocked, { revision: 1, hidden: false, blocked: true }); + assert.deepEqual(await client.exclude(block), blocked); + assert.deepEqual(await client.exclusion(entryRef), blocked); + assert.equal((await client.recommend()).similar.length, 0); + assert.equal((await other.client.recommend()).similar.length, 0); + const undo = { ...block, requestId: randomUUID(), expectedRevision: 1, blocked: false }; + assert.deepEqual(await client.exclude(undo), { revision: 2, hidden: false, blocked: false }); + assert.equal((await client.recommend()).similar[0].displayName, 'Synthetic HTTP Partner'); + // Withdrawal by the candidate is respected on the next transport request. + await other.client.withdraw({ requestId: randomUUID(), expectedRevision: 1 }); + assert.equal((await client.recommend()).similar.length, 0); +}); +test('cached Auth tokens cannot bypass a current disabled or unverified account', async () => { + await auth.updateUser(owner, { disabled: true }); + await assert.rejects(client.read(), { code: 'functions/permission-denied' }); + await auth.updateUser(owner, { disabled: false, emailVerified: false }); + await assert.rejects(client.read(), { code: 'functions/permission-denied' }); + await auth.updateUser(owner, { emailVerified: true }); + assert.equal((await client.read()).revision, 1); +}); +test('expired membership denies saves/recommendations but permits own read and withdrawal', async () => { + const ref = db.collection('memberships').doc(owner); + const record = (await ref.get()).data(); + await ref.set({ ...record, term: { ...record.term, endsAtMs: Date.now() - 1000 } }); + await assert.rejects(client.save(save(1)), { code: 'functions/permission-denied' }); + await assert.rejects(client.recommend(), { code: 'functions/permission-denied' }); + assert.equal((await client.read()).revision, 1); + const command = { requestId: randomUUID(), expectedRevision: 1 }; + const result = await client.withdraw(command); + assert.deepEqual(await client.withdraw(command), result); + assert.equal(result.profile, null); + assert.equal(result.adultConfirmed, false); + assert.deepEqual(result.consent, { memberDiscovery: false, similarity: false, broadenCircle: false }); + assert.equal((await client.read()).revision, 2); +});