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 = (
+ Why this suggestion: + {' '} + {list(card.reasons)} + . +
+ )} +{`Login screen ${location.state?.from}`}
; + } + (service as any).RUNNER_CONNECTIONS_AVAILABLE = true; + (useAuth as jest.Mock).mockReturnValue({ user: null, isLoading: false }); + render( ++ {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. +
+ ++ Remove your card, sharing choices and age confirmation. This does not delete + your account or change dues. +
+ ++ 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 && ( ++ 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) && ( + + )} +Checking your account…
; + if (!user) returnRunner connections are unavailable.
; + const { app } = services.firebaseResources; + return+ 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. +
+ )} +