From 3139ba4d8f4d4b95d2a549f45af9698b56a487f1 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 27 Sep 2026 01:49:03 -0700 Subject: [PATCH 1/7] COMMUNITY-001: begin bounded opt-in runner matching with synthetic tests --- GITHUB_ISSUES.md | 6 + IMPLEMENTATION_PLAN.md | 5 + SYSTEM_DESIGN.md | 7 + docs/RUNNER_CONNECTIONS.md | 84 +++++++++++ docs/officers/README.md | 1 + docs/officers/RUNNER_CONNECTIONS.md | 45 ++++++ functions/runnerConnections.js | 219 ++++++++++++++++++++++++++++ functions/runnerConnections.test.js | 194 ++++++++++++++++++++++++ 8 files changed, 561 insertions(+) create mode 100644 docs/RUNNER_CONNECTIONS.md create mode 100644 docs/officers/RUNNER_CONNECTIONS.md create mode 100644 functions/runnerConnections.js create mode 100644 functions/runnerConnections.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 54b1067..60a6c00 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -28,6 +28,12 @@ Before creating or claiming anything from this catalog, search GitHub, confirm t ## 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. This is not a live feature or permission to deploy additional +Functions through the two-profile-Function release. + ### Labels - Priority: `priority:P0`, `priority:P1`, `priority:P2` diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1877a4e..42c6cfb 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -9,6 +9,11 @@ This plan turns the target architecture into a dependency-ordered delivery progr ## 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 tests from outstanding persistence, access rechecks, member UI +and live-release work. The existing small profile deployment scope is unchanged. + 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/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 4617913..f332347 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -18,6 +18,13 @@ COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) expli ### 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-only +validator/ranker, not a connected member service. Member-discovery consent is +separate from officer lookup. Payment processing, demographic matching and live +release remain separate decisions; no existing member is automatically enrolled. + - 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. diff --git a/docs/RUNNER_CONNECTIONS.md b/docs/RUNNER_CONNECTIONS.md new file mode 100644 index 0000000..92cc212 --- /dev/null +++ b/docs/RUNNER_CONNECTIONS.md @@ -0,0 +1,84 @@ +# 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. + +## Integration still required before the first slice is complete + +1. Define the authoritative current member/adult eligibility adapter without + trusting request fields, a profile role mirror or officer-directory consent. +2. Persist separate opt-in profile/consent records through versioned, retry-safe + server operations, with explicit pause/removal and no automatic enrollment. +3. Add requester-scoped exclusions, block/hide, bounded reads and abuse limits. + Recheck current identity, eligibility, visibility and blocks before delivery; + a cache or prior ranker result cannot authorize a card. +4. Prove these private collections deny all direct browser reads/writes and that + server behavior passes concurrent, negative and withdrawal emulator tests. +5. Add the member form, exact-card preview, explicit consent and recommendation + interface with empty/unavailable/uncertain-save behavior. Keep its capability + disabled until backend-first deployment 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"] + Future["NOT IMPLEMENTED: member UI and trusted server persistence"] -.-> Recheck["NOT IMPLEMENTED: current access and withdrawal rechecks"] + Recheck -.-> Core +``` + +Text alternative: only synthetic fixtures currently reach the validator and +ranker; the member interface, persistence and current-access checks remain to be +implemented before any real recommendation can be returned. + +## 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. They do **not** prove deployed +authorization, adult verification, withdrawal during computation, stable +server-side candidate windows, request throttling, persistence, the member UI or +real connections. The issue remains open until the integrated acceptance cases +pass. No live member data is needed for development. + +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. diff --git a/docs/officers/README.md b/docs/officers/README.md index 4cbd54f..9f253f9 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -38,6 +38,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 0000000..1308f2d --- /dev/null +++ b/docs/officers/RUNNER_CONNECTIONS.md @@ -0,0 +1,45 @@ +# 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, adult eligibility and live pilot. + +**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 + +Only an internal validator and matching calculation with made-up runners exist. +There is no member form, published card, recommendation service or stored runner +profile. The officer lookup setting does not authorize member discovery. + +## 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 and private-data protection. +5. Request separate website and Firebase deployment evidence before announcing + the feature. A calculation test or green build is not proof of availability. + +**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, a minor is included without an approved design, or anyone claims +that the current code is a live service. + +**Success proof:** for now, the recorded synthetic calculation tests only. The +integrated member and server acceptance tests and live pilot remain outstanding. + +**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/functions/runnerConnections.js b/functions/runnerConnections.js new file mode 100644 index 0000000..f40b77d --- /dev/null +++ b/functions/runnerConnections.js @@ -0,0 +1,219 @@ +'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 = exactObject(data.consent, ['memberDiscovery', 'similarity', 'broadenCircle']); + for (const value of [data.memberEligible, data.adultEligible, data.pairExcluded, ...Object.values(consent)]) { + if (typeof value !== 'boolean') invalid(); + } + if (!consent.memberDiscovery && (consent.similarity || consent.broadenCircle)) 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 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, rankRunnerConnections }; diff --git a/functions/runnerConnections.test.js b/functions/runnerConnections.test.js new file mode 100644 index 0000000..2bc86cb --- /dev/null +++ b/functions/runnerConnections.test.js @@ -0,0 +1,194 @@ +'use strict'; + +const { + normalizePace, + readRunnerProfile, + rankRunnerConnections, +} = require('./runnerConnections'); + +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, + }; +} + +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([]); + }); +}); From e27c8073248cc10f93be060d54a8c557859d6246 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 27 Sep 2026 02:11:12 -0700 Subject: [PATCH 2/7] Add private runner profile persistence and adult self-attestation --- .github/workflows/ci.yml | 11 + GITHUB_ISSUES.md | 6 +- IMPLEMENTATION_PLAN.md | 6 +- OPERATIONS_RUNBOOK.md | 9 + SECURITY.md | 10 + SYSTEM_DESIGN.md | 7 +- docs/RUNNER_CONNECTIONS.md | 91 +++++- docs/officers/RUNNER_CONNECTIONS.md | 26 +- firestore.rules | 11 + .../runnerConnectionProfiles.emulator.test.js | 302 ++++++++++++++++++ functions/runnerConnectionProfiles.js | 186 +++++++++++ functions/runnerConnections.js | 32 +- functions/runnerConnections.test.js | 29 +- .../testSupport/runnerConnectionsFixtures.js | 33 ++ tests/ci-workflow.test.js | 27 ++ .../runner-connections.test.js | 31 ++ 16 files changed, 755 insertions(+), 62 deletions(-) create mode 100644 functions/runnerConnectionProfiles.emulator.test.js create mode 100644 functions/runnerConnectionProfiles.js create mode 100644 functions/testSupport/runnerConnectionsFixtures.js create mode 100644 tests/firestore-rules/runner-connections.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a776a6..35cbab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,3 +159,14 @@ 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' + run: >- + npx --no-install firebase emulators:exec + --project demo-functions-test + --only firestore + "npm --prefix functions run test:run -- + --runInBand runnerConnectionProfiles.emulator.test.js" diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 60a6c00..47627e9 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -31,8 +31,10 @@ Before creating or claiming anything from this catalog, search GitHub, confirm t 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. This is not a live feature or permission to deploy additional -Functions through the two-profile-Function release. +acceptance cases. The draft source adds a tested private storage primitive and +member-confirmed 18+ with no DOB; membership authorization and the connected +member service remain incomplete. This is not a live feature or permission to +deploy additional Functions through the two-profile-Function release. ### Labels diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 42c6cfb..9882e1c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -11,8 +11,10 @@ This plan turns the target architecture into a dependency-ordered delivery progr 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 tests from outstanding persistence, access rechecks, member UI -and live-release work. The existing small profile deployment scope is unchanged. +synthetic domain/persistence tests from outstanding authenticated integration, +access rechecks, member UI and live-release work. Explicit 18+ self-attestation +is selected; no birth date is collected. The existing small profile deployment +scope is unchanged. MPRC can open a race or merchandise item for sale only when the platform can: diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 3e81aac..7892762 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -6,6 +6,15 @@ 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. Membership checks, +App Check-enforced service integration, member UI and a protected pilot remain +outstanding. + 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. ## 1. Ownership roster diff --git a/SECURITY.md b/SECURITY.md index 9905856..830878d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,16 @@ 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 injected storage authorization callback is not a completed +membership policy. Auth/App Check, current membership, abuse limits, exclusion +and delivery-time rechecks, retention approval and protected release proof are +still required; see the [implementation record](./docs/RUNNER_CONNECTIONS.md). + ## 2. Reporting a vulnerability Do not disclose a suspected vulnerability, secret, customer record, payment reference, or exploit in a public GitHub issue. Until MPRC publishes a dedicated security address: diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index f332347..5a39f6f 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -20,8 +20,11 @@ COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) expli 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-only -validator/ranker, not a connected member service. Member-discovery consent is +[implementation record](./docs/RUNNER_CONNECTIONS.md) contains a synthetic-tested +validator/ranker and private transactional storage primitive, not a connected +member service. 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. diff --git a/docs/RUNNER_CONNECTIONS.md b/docs/RUNNER_CONNECTIONS.md index 92cc212..3ec9741 100644 --- a/docs/RUNNER_CONNECTIONS.md +++ b/docs/RUNNER_CONNECTIONS.md @@ -39,17 +39,53 @@ current membership/adult eligibility**. It is not imported by the application. 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. Synthetic tests supply a fake policy, not membership proof. + +- 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. + ## Integration still required before the first slice is complete -1. Define the authoritative current member/adult eligibility adapter without - trusting request fields, a profile role mirror or officer-directory consent. -2. Persist separate opt-in profile/consent records through versioned, retry-safe - server operations, with explicit pause/removal and no automatic enrollment. +1. Connect authoritative current membership and the saved adult affirmation + without trusting request roles, a profile role mirror or officer consent. +2. Wrap the storage primitive in authenticated, App Check-enforced, rate-limited + server operations. Permit a safe withdrawal path even after membership ends. 3. Add requester-scoped exclusions, block/hide, bounded reads and abuse limits. Recheck current identity, eligibility, visibility and blocks before delivery; a cache or prior ranker result cannot authorize a card. -4. Prove these private collections deny all direct browser reads/writes and that - server behavior passes concurrent, negative and withdrawal emulator tests. +4. Extend the passing persistence/Rules tests to the real admission adapter and + concurrent withdrawal/blocking during recommendation delivery. 5. Add the member form, exact-card preview, explicit consent and recommendation interface with empty/unavailable/uncertain-save behavior. Keep its capability disabled until backend-first deployment and privacy/release review pass. @@ -60,23 +96,48 @@ defaults for synthetic evaluation. They are not approved club policy. flowchart LR Fixture["Synthetic profile and eligibility assertions"] --> Core["Implemented bounded validator and ranker"] Core --> Cards["Synthetic card results only"] - Future["NOT IMPLEMENTED: member UI and trusted server persistence"] -.-> Recheck["NOT IMPLEMENTED: current access and withdrawal rechecks"] + Commands["Synthetic commands and injected test authorization"] --> Store["Implemented transaction storage primitive"] + Store --> Private["Local emulator: private profiles and minimal audit receipts"] + Future["NOT IMPLEMENTED: member UI and authenticated service"] -.-> Store + Future -.-> Recheck["NOT IMPLEMENTED: current membership, exclusions and delivery rechecks"] Recheck -.-> Core ``` -Text alternative: only synthetic fixtures currently reach the validator and -ranker; the member interface, persistence and current-access checks remain to be -implemented before any real recommendation can be returned. +Text alternative: synthetic fixtures exercise the ranker and emulator storage; +the member interface, authenticated service, current membership and delivery +checks remain to be implemented before any real recommendation can be returned. ## 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. They do **not** prove deployed -authorization, adult verification, withdrawal during computation, stable -server-side candidate windows, request throttling, persistence, the member UI or -real connections. The issue remains open until the integrated acceptance cases -pass. No live member data is needed for development. +results, bounded input and deterministic output. The September 27 storage +checkpoint also passes 35 Firestore-emulator persistence cases and 428 Rules +tests, including ten new browser-denial cases. The full Functions unit run +passes 7,622 cases; emulator-only cases are skipped there, not counted as passes. +CI explicitly requires and runs the new persistence suite in the existing +Firestore job, separately from the ordinary unit run. + +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. + +These results do **not** prove deployed authorization, verified age, withdrawal +during recommendation computation, stable server-side candidate windows, +request throttling, the member UI or real connections. The issue and draft PR +remain open until integrated acceptance cases pass. No live member data is +needed for development. + +With Node 20, Java 21 and committed lockfile installs, run the focused storage +check with: + +```sh +REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR=1 npx --no-install firebase emulators:exec --project demo-functions-test --only firestore "npm --prefix functions run test:run -- --runInBand runnerConnectionProfiles.emulator.test.js" +npm run test:rules +``` The separate September 27 backend preflight found staging billing disabled and zero billing accounts accessible to the authorized club account. The existing diff --git a/docs/officers/RUNNER_CONNECTIONS.md b/docs/officers/RUNNER_CONNECTIONS.md index 1308f2d..ec5fef3 100644 --- a/docs/officers/RUNNER_CONNECTIONS.md +++ b/docs/officers/RUNNER_CONNECTIONS.md @@ -4,7 +4,8 @@ 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, adult eligibility and live pilot. +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 @@ -12,9 +13,15 @@ release. A specialist is still required for implementation and release. ## What works now -Only an internal validator and matching calculation with made-up runners exist. -There is no member form, published card, recommendation service or stored runner -profile. The officer lookup setting does not authorize member discovery. +An internal matching calculation and private profile-storage code pass tests +with made-up runners in a local test database. There is no member form, published +card or connected recommendation service. Nothing in this change is deployed. +The officer lookup setting does not authorize member discovery. + +The planned form will ask members to confirm they are 18 or older. It will not +collect a birth date or claim to verify age or identity. The box must start +unchecked. Withdrawal clears the confirmation, so joining again requires a new +confirmation. Club membership must still be checked separately by the server. ## Review steps @@ -22,7 +29,7 @@ profile. The officer lookup setting does not authorize member discovery. 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 and private-data protection. + current member access, the unchecked 18+ confirmation and private-data protection. 5. Request separate website and Firebase deployment evidence before announcing the feature. A calculation test or green build is not proof of availability. @@ -31,11 +38,12 @@ 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, a minor is included without an approved design, or anyone claims -that the current code is a live service. +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 tests only. The -integrated member and server acceptance tests and live pilot remain outstanding. +**Success proof:** for now, the recorded synthetic calculation, local storage +and browser-denial tests. Integrated member/server tests and a live pilot remain +outstanding. A specialist is still needed for every implementation/release step. **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. diff --git a/firestore.rules b/firestore.rules index bfe960a..b7e3c39 100644 --- a/firestore.rules +++ b/firestore.rules @@ -499,6 +499,17 @@ service cloud.firestore { } } + // 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; + } + } + 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 0000000..43438a2 --- /dev/null +++ b/functions/runnerConnectionProfiles.emulator.test.js @@ -0,0 +1,302 @@ +'use strict'; + +const admin = require('firebase-admin'); +const { createRunnerProfileStore, PROFILE_COLLECTION } = require('./runnerConnectionProfiles'); +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(); + 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(); + 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)); + 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(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); + }); + + 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 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 0000000..be97938 --- /dev/null +++ b/functions/runnerConnectionProfiles.js @@ -0,0 +1,186 @@ +'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 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'); } +} + +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. Callable auth/App Check, rate limiting and the actual + * membership/adult policy adapter remain required before this is a service. + */ +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); + 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 }; diff --git a/functions/runnerConnections.js b/functions/runnerConnections.js index f40b77d..ac9d35b 100644 --- a/functions/runnerConnections.js +++ b/functions/runnerConnections.js @@ -119,11 +119,10 @@ function readRunner(input) { 'entryRef', 'memberEligible', 'adultEligible', 'pairExcluded', 'consent', 'profile', ]); if (typeof data.entryRef !== 'string' || !/^runner_[0-9a-f]{64}$/.test(data.entryRef)) invalid(); - const consent = exactObject(data.consent, ['memberDiscovery', 'similarity', 'broadenCircle']); - for (const value of [data.memberEligible, data.adultEligible, data.pairExcluded, ...Object.values(consent)]) { + const consent = readConnectionConsent(data.consent); + for (const value of [data.memberEligible, data.adultEligible, data.pairExcluded]) { if (typeof value !== 'boolean') invalid(); } - if (!consent.memberDiscovery && (consent.similarity || consent.broadenCircle)) invalid(); return Object.freeze({ entryRef: data.entryRef, eligible: data.memberEligible && data.adultEligible && !data.pairExcluded && consent.memberDiscovery, @@ -132,6 +131,28 @@ function readRunner(input) { }); } +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)); } @@ -216,4 +237,7 @@ function rankRunnerConnections(viewerInput, candidateInputs) { : []); } -module.exports = { RANKER_VERSION, MAX_CANDIDATES, normalizePace, readRunnerProfile, rankRunnerConnections }; +module.exports = { + RANKER_VERSION, MAX_CANDIDATES, normalizePace, readRunnerProfile, + readRunnerProfileInput, readConnectionConsent, rankRunnerConnections, +}; diff --git a/functions/runnerConnections.test.js b/functions/runnerConnections.test.js index 2bc86cb..fe78c73 100644 --- a/functions/runnerConnections.test.js +++ b/functions/runnerConnections.test.js @@ -6,34 +6,7 @@ const { rankRunnerConnections, } = require('./runnerConnections'); -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, - }; -} +const { profile, runner } = require('./testSupport/runnerConnectionsFixtures'); describe('runner profile boundary', () => { test('normalizes equivalent metric and imperial easy pace', () => { diff --git a/functions/testSupport/runnerConnectionsFixtures.js b/functions/testSupport/runnerConnectionsFixtures.js new file mode 100644 index 0000000..bec15ab --- /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/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 8611c74..f1537cf 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -173,6 +173,18 @@ function expectedFirestoreRulesJob() { }, { run: 'npm ci --legacy-peer-deps --ignore-scripts' }, { name: FIRESTORE_RULES_STEP_NAME, run: 'npm run test:rules' }, + { + name: 'Install Functions dependencies for runner profile tests', + run: 'npm --prefix functions ci --ignore-scripts', + }, + { + name: 'Run runner profile persistence tests against the emulator', + env: { REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1' }, + run: 'npx --no-install firebase emulators:exec ' + + '--project demo-functions-test --only firestore ' + + '"npm --prefix functions run test:run -- ' + + '--runInBand runnerConnectionProfiles.emulator.test.js"', + }, ], }; } @@ -795,6 +807,21 @@ test('guard rejects unsafe Firestore Rules runtime or lockfile mutations', () => }); }); +test('guard rejects skipped, misdirected or weakened runner-profile emulator execution', () => { + const stepName = 'Run runner profile persistence tests against the emulator'; + const mutations = [ + ciWorkflow.replace("REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '0'"), + ciWorkflow.replace('--project demo-functions-test', '--project synthetic-hosted-project'), + ciWorkflow.replace('runnerConnectionProfiles.emulator.test.js', 'runnerConnections.test.js'), + ciWorkflow.replace(` - name: ${stepName}`, ` - name: ${stepName}\n if: ${NEVER_RUN}`), + ciWorkflow.replace(` - name: ${stepName}`, ` - name: ${stepName}\n continue-on-error: true`), + ]; + mutations.forEach((mutated) => { + assert.notEqual(mutated, ciWorkflow); + assert.notDeepEqual(firestoreRulesErrors(mutated), []); + }); +}); + test('guard rejects omission from either protected-release recheck', () => { const requiredLine = ` "${JOB_NAME}" \\\n`; const withoutFirst = releaseWorkflow.replace(requiredLine, ''); diff --git a/tests/firestore-rules/runner-connections.test.js b/tests/firestore-rules/runner-connections.test.js new file mode 100644 index 0000000..157653f --- /dev/null +++ b/tests/firestore-rules/runner-connections.test.js @@ -0,0 +1,31 @@ +/* eslint-env jest */ + +const { clearFirestore, teardown, db, seed, assertFails } = require('./setup'); + +const actors = [ + ['anonymous', undefined], + ['owner without member authority', { uid: 'synthetic-runner' }], + ['verified member owner', { uid: 'synthetic-runner', role: 'member', emailVerified: true }], + ['another verified member', { uid: 'synthetic-other', role: 'member', emailVerified: true }], + ['verified browser admin', { uid: 'synthetic-admin', role: 'admin', emailVerified: true }], +]; + +beforeEach(clearFirestore); +afterAll(teardown); + +describe.each(actors)('runner connection profile boundary — %s', (_label, auth) => { + test.each([ + 'runnerConnectionProfiles', + 'runnerConnectionProfiles/synthetic-runner/privateRecords', + ])('cannot read, enumerate or mutate %s', async (collectionPath) => { + const documentPath = `${collectionPath}/synthetic-runner`; + await seed(documentPath, { marker: 'synthetic-private-card' }); + const client = await db(auth); + await assertFails(client.doc(documentPath).get()); + await assertFails(client.collection(collectionPath).get()); + await assertFails(client.collectionGroup(collectionPath.split('/').pop()).get()); + await assertFails(client.doc(`${collectionPath}/synthetic-new`).set({ marker: 'synthetic-forged' })); + await assertFails(client.doc(documentPath).update({ discoverable: true })); + await assertFails(client.doc(documentPath).delete()); + }); +}); From bf61fa0f15f2b2572d4598c1c1848075b1d56bfb Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 27 Sep 2026 02:31:38 -0700 Subject: [PATCH 3/7] Connect runner profiles to current account and membership checks --- .github/workflows/ci.yml | 6 +- GITHUB_ISSUES.md | 6 +- IMPLEMENTATION_PLAN.md | 5 +- OPERATIONS_RUNBOOK.md | 7 +- SECURITY.md | 10 +- SYSTEM_DESIGN.md | 9 +- docs/RUNNER_CONNECTIONS.md | 110 ++++++-- docs/officers/RUNNER_CONNECTIONS.md | 21 +- firestore.rules | 10 + functions/runnerConnectionProfiles.js | 24 +- .../runnerConnectionService.emulator.test.js | 261 ++++++++++++++++++ functions/runnerConnectionService.js | 141 ++++++++++ functions/runnerConnectionService.test.js | 62 +++++ tests/ci-workflow.test.js | 13 +- .../runner-connections.test.js | 2 + 15 files changed, 639 insertions(+), 48 deletions(-) create mode 100644 functions/runnerConnectionService.emulator.test.js create mode 100644 functions/runnerConnectionService.js create mode 100644 functions/runnerConnectionService.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35cbab4..e82aff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,9 +164,11 @@ jobs: - name: Run runner profile persistence tests against the emulator env: REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1' + REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1' run: >- npx --no-install firebase emulators:exec --project demo-functions-test - --only firestore + --only firestore,auth "npm --prefix functions run test:run -- - --runInBand runnerConnectionProfiles.emulator.test.js" + --runInBand runnerConnectionProfiles.emulator.test.js + runnerConnectionService.emulator.test.js" diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 47627e9..c474ee8 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -32,8 +32,10 @@ September 27 addition: [COMMUNITY-001 / #689](https://github.com/Run-MPRC/Run-MP 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; membership authorization and the connected -member service remain incomplete. This is not a live feature or permission to +member-confirmed 18+ with no DOB. A disabled callable factory now checks current +Auth and canonical membership before saves; approved membership population, +recommendation delivery and the member interface remain incomplete. This is +not a live feature or permission to deploy additional Functions through the two-profile-Function release. ### Labels diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9882e1c..ebdbb55 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -11,8 +11,9 @@ This plan turns the target architecture into a dependency-ordered delivery progr 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 tests from outstanding authenticated integration, -access rechecks, member UI and live-release work. Explicit 18+ self-attestation +synthetic domain/persistence and disabled profile-service tests from outstanding +canonical membership population, candidate/delivery checks, member UI and +live-release work. Explicit 18+ self-attestation is selected; no birth date is collected. The existing small profile deployment scope is unchanged. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 7892762..7d4b9d7 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -11,9 +11,10 @@ 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. Membership checks, -App Check-enforced service integration, member UI and a protected pilot remain -outstanding. +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, recommendation service, member UI and +a protected pilot remain outstanding. 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. diff --git a/SECURITY.md b/SECURITY.md index 830878d..b6a75e1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,10 +15,12 @@ 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 injected storage authorization callback is not a completed -membership policy. Auth/App Check, current membership, abuse limits, exclusion -and delivery-time rechecks, retention approval and protected release proof are -still required; see the [implementation record](./docs/RUNNER_CONNECTIONS.md). +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. +Approved membership population, candidate/exclusion/delivery-time rechecks, +retention approval and protected release proof remain required; see the +[implementation record](./docs/RUNNER_CONNECTIONS.md). ## 2. Reporting a vulnerability diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 5a39f6f..6508afa 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -21,8 +21,11 @@ COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) expli 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 and private transactional storage primitive, not a connected -member service. The selected 18+ policy is explicit member self-attestation, +validator/ranker, private transactional storage and a disabled profile callable +factory with current Auth/canonical-membership reads, not a connected member +recommendation service. 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 @@ -807,7 +810,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 index 3ec9741..fb0e267 100644 --- a/docs/RUNNER_CONNECTIONS.md +++ b/docs/RUNNER_CONNECTIONS.md @@ -45,7 +45,8 @@ defaults for synthetic evaluation. They are not approved club policy. 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. Synthetic tests supply a fake policy, not membership proof. +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 @@ -75,17 +76,69 @@ idempotent retries. Synthetic tests supply a fake policy, not membership proof. 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 store to three callable +handlers for reading, saving and withdrawing one's own card. 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 must be reviewed separately. + ## Integration still required before the first slice is complete -1. Connect authoritative current membership and the saved adult affirmation - without trusting request roles, a profile role mirror or officer consent. -2. Wrap the storage primitive in authenticated, App Check-enforced, rate-limited - server operations. Permit a safe withdrawal path even after membership ends. +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. Add requester-scoped exclusions, block/hide, bounded reads and abuse limits. Recheck current identity, eligibility, visibility and blocks before delivery; a cache or prior ranker result cannot authorize a card. -4. Extend the passing persistence/Rules tests to the real admission adapter and - concurrent withdrawal/blocking during recommendation delivery. +4. Extend the passing profile-service tests to candidate admission and concurrent + withdrawal/blocking during recommendation delivery. 5. Add the member form, exact-card preview, explicit consent and recommendation interface with empty/unavailable/uncertain-save behavior. Keep its capability disabled until backend-first deployment and privacy/release review pass. @@ -96,27 +149,36 @@ client contract change, new dependency or Cloud Function export is introduced. flowchart LR Fixture["Synthetic profile and eligibility assertions"] --> Core["Implemented bounded validator and ranker"] Core --> Cards["Synthetic card results only"] - Commands["Synthetic commands and injected test authorization"] --> Store["Implemented transaction storage primitive"] + Commands["Synthetic callable context"] --> Service["Disabled profile callable factory"] + 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 and minimal audit receipts"] - Future["NOT IMPLEMENTED: member UI and authenticated service"] -.-> Store - Future -.-> Recheck["NOT IMPLEMENTED: current membership, exclusions and delivery rechecks"] + Future["NOT IMPLEMENTED: member UI and recommendations service"] -.-> Service + Future -.-> Recheck["NOT IMPLEMENTED: candidate membership, exclusions and delivery rechecks"] Recheck -.-> Core ``` -Text alternative: synthetic fixtures exercise the ranker and emulator storage; -the member interface, authenticated service, current membership and delivery -checks remain to be implemented before any real recommendation can be returned. +Text alternative: synthetic callbacks exercise current account/membership checks +and profile storage in local emulators; the member interface, recommendation +service and delivery checks remain unimplemented and nothing is enabled live. ## 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. The September 27 storage -checkpoint also passes 35 Firestore-emulator persistence cases and 428 Rules -tests, including ten new browser-denial cases. The full Functions unit run -passes 7,622 cases; emulator-only cases are skipped there, not counted as passes. -CI explicitly requires and runs the new persistence suite in the existing -Firestore job, separately from the ordinary unit run. +results, bounded input and deterministic output. Current local Node 20/Java 21 +checks pass 35 persistence plus 34 service-emulator cases, 438 Rules cases, +and 134 workflow/release/security checks. Functions lint passes. The ordinary +Functions run passes 7,629 cases with 133 emulator-only skips; the Auth/Firestore +run passes 7,699 with 63 separately opted-in commerce-journal cases skipped. +Skipped tests are not counted as passes. CI now requires both Auth and Firestore +for the two runner emulator suites, separately from the ordinary unit run. + +The preceding storage checkpoint's exact CI run 36308568014 passed all five +jobs, including the actually executed persistence step. That run is not evidence +for later revisions; use the PR's exact-head checks for the new service change. The focused storage review found no actionable defect. Its suggested concurrent save/withdrawal and forced authorization-loss-on-retry cases now pass. A @@ -127,7 +189,7 @@ live revocation policy. Review does not complete the outstanding integration. These results do **not** prove deployed authorization, verified age, withdrawal during recommendation computation, stable server-side candidate windows, -request throttling, the member UI or real connections. The issue and draft PR +provider billing limits, the member UI or real connections. The issue and draft PR remain open until integrated acceptance cases pass. No live member data is needed for development. @@ -135,10 +197,16 @@ With Node 20, Java 21 and committed lockfile installs, run the focused storage check with: ```sh -REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR=1 npx --no-install firebase emulators:exec --project demo-functions-test --only firestore "npm --prefix functions run test:run -- --runInBand runnerConnectionProfiles.emulator.test.js" +REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR=1 REQUIRE_RUNNER_CONNECTION_SERVICE_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" 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 diff --git a/docs/officers/RUNNER_CONNECTIONS.md b/docs/officers/RUNNER_CONNECTIONS.md index ec5fef3..f2e3dca 100644 --- a/docs/officers/RUNNER_CONNECTIONS.md +++ b/docs/officers/RUNNER_CONNECTIONS.md @@ -13,15 +13,25 @@ release. A specialist is still required for implementation and release. ## What works now -An internal matching calculation and private profile-storage code pass tests -with made-up runners in a local test database. There is no member form, published +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. There is no member form, published card or connected recommendation service. Nothing in this change is deployed. The officer lookup setting does not authorize member discovery. The planned form will ask members to confirm they are 18 or older. It will not collect a birth date or claim to verify age or identity. The box must start unchecked. Withdrawal clears the confirmation, so joining again requires a new -confirmation. Club membership must still be checked separately by the server. +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. ## Review steps @@ -41,8 +51,9 @@ 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 storage -and browser-denial tests. Integrated member/server tests and a live pilot remain +**Success proof:** for now, the recorded synthetic calculation, local account, +membership, storage, request-limit and browser-denial tests. The member interface, +recommendations, actual hosted authentication checks and a live pilot remain outstanding. A specialist is still needed for every implementation/release step. **Undo:** keep the capability unavailable and request a reviewed code revert if diff --git a/firestore.rules b/firestore.rules index b7e3c39..493e5a1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -499,6 +499,16 @@ 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. diff --git a/functions/runnerConnectionProfiles.js b/functions/runnerConnectionProfiles.js index be97938..cebc939 100644 --- a/functions/runnerConnectionProfiles.js +++ b/functions/runnerConnectionProfiles.js @@ -58,6 +58,21 @@ function readCommand(input, operation) { } 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, @@ -104,8 +119,9 @@ function readAudit(value) { * 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. Callable auth/App Check, rate limiting and the actual - * membership/adult policy adapter remain required before this is a service. + * 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' @@ -183,4 +199,6 @@ function createRunnerProfileStore({ db, authorize, now = Date.now } = {}) { }); } -module.exports = { PROFILE_COLLECTION, CONSENT_VERSION, createRunnerProfileStore }; +module.exports = { + PROFILE_COLLECTION, CONSENT_VERSION, createRunnerProfileStore, readRunnerProfileRequest, +}; diff --git a/functions/runnerConnectionService.emulator.test.js b/functions/runnerConnectionService.emulator.test.js new file mode 100644 index 0000000..c440236 --- /dev/null +++ b/functions/runnerConnectionService.emulator.test.js @@ -0,0 +1,261 @@ +'use strict'; + +const admin = require('firebase-admin'); +const { createRunnerConnectionCallables, MEMBERSHIP_COLLECTION, accountRateKey } = require('./runnerConnectionService'); +const { PROFILE_COLLECTION } = require('./runnerConnectionProfiles'); +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('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 0000000..1fbc8d1 --- /dev/null +++ b/functions/runnerConnectionService.js @@ -0,0 +1,141 @@ +'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'); + +// 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 }); +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 + && 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 { + request = operation === 'read' + ? readExactDataObject(data, []) : 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(); + 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'), + }); +} + +module.exports = { MEMBERSHIP_COLLECTION, RUNTIME, accountRateKey, createRunnerConnectionCallables }; diff --git a/functions/runnerConnectionService.test.js b/functions/runnerConnectionService.test.js new file mode 100644 index 0000000..b1a3fdb --- /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(3); + expect(functions.runWith).toHaveBeenCalledTimes(3); + 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/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index f1537cf..c5e8dd7 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -179,11 +179,15 @@ function expectedFirestoreRulesJob() { }, { name: 'Run runner profile persistence tests against the emulator', - env: { REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1' }, + env: { + REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1', + REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1', + }, run: 'npx --no-install firebase emulators:exec ' - + '--project demo-functions-test --only firestore ' + + '--project demo-functions-test --only firestore,auth ' + '"npm --prefix functions run test:run -- ' - + '--runInBand runnerConnectionProfiles.emulator.test.js"', + + '--runInBand runnerConnectionProfiles.emulator.test.js ' + + 'runnerConnectionService.emulator.test.js"', }, ], }; @@ -811,6 +815,9 @@ test('guard rejects skipped, misdirected or weakened runner-profile emulator exe const stepName = 'Run runner profile persistence tests against the emulator'; const mutations = [ ciWorkflow.replace("REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '0'"), + ciWorkflow.replace("REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '0'"), + ciWorkflow.replace('--only firestore,auth', '--only firestore'), + ciWorkflow.replace('runnerConnectionService.emulator.test.js', 'runnerConnectionService.test.js'), ciWorkflow.replace('--project demo-functions-test', '--project synthetic-hosted-project'), ciWorkflow.replace('runnerConnectionProfiles.emulator.test.js', 'runnerConnections.test.js'), ciWorkflow.replace(` - name: ${stepName}`, ` - name: ${stepName}\n if: ${NEVER_RUN}`), diff --git a/tests/firestore-rules/runner-connections.test.js b/tests/firestore-rules/runner-connections.test.js index 157653f..259ee3c 100644 --- a/tests/firestore-rules/runner-connections.test.js +++ b/tests/firestore-rules/runner-connections.test.js @@ -17,6 +17,8 @@ describe.each(actors)('runner connection profile boundary — %s', (_label, auth test.each([ 'runnerConnectionProfiles', 'runnerConnectionProfiles/synthetic-runner/privateRecords', + 'memberships', + 'memberships/synthetic-runner/privateRecords', ])('cannot read, enumerate or mutate %s', async (collectionPath) => { const documentPath = `${collectionPath}/synthetic-runner`; await seed(documentPath, { marker: 'synthetic-private-card' }); From e57ad41bef0e92b8c40bcc8765929b352afc74a9 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 27 Sep 2026 02:57:24 -0700 Subject: [PATCH 4/7] Bound runner suggestions and add private hide/block controls Officer impact: No live change; documents disabled daily suggestions and privacy controls.\nOfficer documentation: docs/officers/RUNNER_CONNECTIONS.md\nDeployment evidence: source/emulators only; website, Firebase and providers unchanged. --- .github/workflows/ci.yml | 4 +- GITHUB_ISSUES.md | 6 +- IMPLEMENTATION_PLAN.md | 5 +- OPERATIONS_RUNBOOK.md | 7 +- SECURITY.md | 7 +- SYSTEM_DESIGN.md | 7 +- docs/RUNNER_CONNECTIONS.md | 125 ++++- docs/officers/RUNNER_CONNECTIONS.md | 23 +- firestore.rules | 12 + .../runnerConnectionProfiles.emulator.test.js | 16 +- functions/runnerConnectionProfiles.js | 7 + ...ConnectionRecommendations.emulator.test.js | 445 ++++++++++++++++++ functions/runnerConnectionRecommendations.js | 254 ++++++++++ functions/runnerConnectionReferences.js | 19 + .../runnerConnectionService.emulator.test.js | 2 + functions/runnerConnectionService.js | 33 +- functions/runnerConnectionService.test.js | 4 +- tests/ci-workflow.test.js | 6 +- .../runner-connections.test.js | 6 + 19 files changed, 936 insertions(+), 52 deletions(-) create mode 100644 functions/runnerConnectionRecommendations.emulator.test.js create mode 100644 functions/runnerConnectionRecommendations.js create mode 100644 functions/runnerConnectionReferences.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e82aff8..43cac3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,10 +165,12 @@ jobs: 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" + runnerConnectionService.emulator.test.js + runnerConnectionRecommendations.emulator.test.js" diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index c474ee8..b8011b4 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -33,8 +33,10 @@ 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; approved membership population, -recommendation delivery and the member interface remain incomplete. This is +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 and the member interface remain +incomplete. This is not a live feature or permission to deploy additional Functions through the two-profile-Function release. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ebdbb55..12f210b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -11,8 +11,9 @@ This plan turns the target architecture into a dependency-ordered delivery progr 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-service tests from outstanding -canonical membership population, candidate/delivery checks, member UI and +synthetic domain/persistence and disabled profile/recommendation-service tests +(including bounded windows, hide/block and delivery rechecks) from outstanding +canonical membership population, retention/account-deletion approval, member UI and live-release work. Explicit 18+ self-attestation is selected; no birth date is collected. The existing small profile deployment scope is unchanged. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 7d4b9d7..e929132 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -13,8 +13,11 @@ 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, recommendation service, member UI and -a protected pilot remain outstanding. +population, hosted Auth/App Check proof, member UI 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. 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. diff --git a/SECURITY.md b/SECURITY.md index b6a75e1..ce195b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,8 +18,11 @@ 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. -Approved membership population, candidate/exclusion/delivery-time rechecks, -retention approval and protected release proof remain required; see the +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. Approved membership population, retention/account-deletion +approval, member UI and protected release proof remain required; see the [implementation record](./docs/RUNNER_CONNECTIONS.md). ## 2. Reporting a vulnerability diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 6508afa..0f3dba6 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -21,9 +21,10 @@ COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) expli 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 profile callable -factory with current Auth/canonical-membership reads, not a connected member -recommendation service. Approved membership population remains a release +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 interface is still absent and 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 diff --git a/docs/RUNNER_CONNECTIONS.md b/docs/RUNNER_CONNECTIONS.md index fb0e267..05f0a8b 100644 --- a/docs/RUNNER_CONNECTIONS.md +++ b/docs/RUNNER_CONNECTIONS.md @@ -78,8 +78,9 @@ client contract change, new dependency or Cloud Function export is introduced. ### Profile callable integration — disabled source, local emulator only -`functions/runnerConnectionService.js` connects the store to three callable -handlers for reading, saving and withdrawing one's own card. Its factory defaults +`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 @@ -126,7 +127,70 @@ 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 must be reviewed separately. +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; the future UI 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. ## Integration still required before the first slice is complete @@ -134,11 +198,10 @@ The recommendation delivery boundary must be reviewed separately. 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. Add requester-scoped exclusions, block/hide, bounded reads and abuse limits. - Recheck current identity, eligibility, visibility and blocks before delivery; - a cache or prior ranker result cannot authorize a card. -4. Extend the passing profile-service tests to candidate admission and concurrent - withdrawal/blocking during recommendation delivery. +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. Independently review the integrated recommendation/privacy interface and + failure cases; backend callback tests alone are not end-to-end acceptance. 5. Add the member form, exact-card preview, explicit consent and recommendation interface with empty/unavailable/uncertain-save behavior. Keep its capability disabled until backend-first deployment and privacy/release review pass. @@ -149,36 +212,41 @@ The recommendation delivery boundary must be reviewed separately. 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["Disabled profile callable factory"] + Commands["Synthetic callable context"] --> Service["Disabled six-handler callable factory"] 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 and minimal audit receipts"] - Future["NOT IMPLEMENTED: member UI and recommendations service"] -.-> Service - Future -.-> Recheck["NOT IMPLEMENTED: candidate membership, exclusions and delivery rechecks"] - Recheck -.-> Core + 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 + Future["NOT IMPLEMENTED: member interface"] -.-> Service ``` Text alternative: synthetic callbacks exercise current account/membership checks -and profile storage in local emulators; the member interface, recommendation -service and delivery checks remain unimplemented and nothing is enabled live. +and private profile storage, bounded recommendation windows and fresh privacy +checks in local emulators; the member interface is missing and nothing is live. ## 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 plus 34 service-emulator cases, 438 Rules cases, +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 run passes 7,629 cases with 133 emulator-only skips; the Auth/Firestore -run passes 7,699 with 63 separately opted-in commerce-journal cases skipped. -Skipped tests are not counted as passes. CI now requires both Auth and Firestore -for the two runner emulator suites, separately from the ordinary unit run. +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 storage checkpoint's exact CI run 36308568014 passed all five -jobs, including the actually executed persistence step. That run is not evidence -for later revisions; use the PR's exact-head checks for the new service change. +The preceding profile-service checkpoint's exact CI run 36309661314 passed all +five jobs, including the actually executed emulator and build/artifact steps. +That run is not evidence for later revisions; use the PR's exact-head checks for +the new recommendation change. The focused storage review found no actionable defect. Its suggested concurrent save/withdrawal and forced authorization-loss-on-retry cases now pass. A @@ -187,8 +255,13 @@ 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. -These results do **not** prove deployed authorization, verified age, withdrawal -during recommendation computation, stable server-side candidate windows, +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, 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, the member UI or real connections. The issue and draft PR remain open until integrated acceptance cases pass. No live member data is needed for development. @@ -197,7 +270,7 @@ 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 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" +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 ``` diff --git a/docs/officers/RUNNER_CONNECTIONS.md b/docs/officers/RUNNER_CONNECTIONS.md index f2e3dca..7535895 100644 --- a/docs/officers/RUNNER_CONNECTIONS.md +++ b/docs/officers/RUNNER_CONNECTIONS.md @@ -16,8 +16,9 @@ release. A specialist is still required for implementation and release. 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. There is no member form, published -card or connected recommendation service. Nothing in this change is deployed. +access. The disabled recommendation service also has local tests for daily +suggestions, hide/block controls and privacy checks before returning a card. +There is no member form or published card. Nothing in this change is deployed. The officer lookup setting does not authorize member discovery. The planned form will ask members to confirm they are 18 or older. It will not @@ -33,6 +34,16 @@ 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 future interface still +needs a clear way to review existing privacy choices. Retention and account +deletion procedures must be approved before a pilot; no retention period has +been selected by this code. + ## Review steps 1. Ask the platform owner for the issue's current source and test record. @@ -40,7 +51,9 @@ the save limit has been reached. These are source behaviors, not live tools. 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 separate website and Firebase deployment evidence before announcing +5. Request a demonstration that hiding, blocking, withdrawal and membership loss + remove suggestions without filling the space with new people that day. +6. Request separate website and Firebase deployment evidence before announcing the feature. A calculation test or green build is not proof of availability. **Expected result:** officers can distinguish source development from a usable @@ -52,8 +65,8 @@ 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, request-limit and browser-denial tests. The member interface, -recommendations, actual hosted authentication checks and a live pilot remain +membership, storage, bounded recommendations, privacy-control, request-limit and +browser-denial tests. The member interface, actual hosted authentication checks and a live pilot remain outstanding. A specialist is still needed for every implementation/release step. **Undo:** keep the capability unavailable and request a reviewed code revert if diff --git a/firestore.rules b/firestore.rules index 493e5a1..95640ac 100644 --- a/firestore.rules +++ b/firestore.rules @@ -520,6 +520,18 @@ service cloud.firestore { } } + // 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 index 43438a2..1e82db7 100644 --- a/functions/runnerConnectionProfiles.emulator.test.js +++ b/functions/runnerConnectionProfiles.emulator.test.js @@ -2,6 +2,7 @@ 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 @@ -40,7 +41,10 @@ describeEmulator('runner profile persistence against local 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(); + 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; @@ -51,7 +55,10 @@ describeEmulator('runner profile persistence against local Firestore', () => { }); afterAll(async () => { - for (const uid of [UID, OTHER]) await db.collection(PROFILE_COLLECTION).doc(uid).delete(); + 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(); @@ -129,6 +136,8 @@ describeEmulator('runner profile persistence against local Firestore', () => { 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: { @@ -136,6 +145,7 @@ describeEmulator('runner profile persistence against local Firestore', () => { } }); 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' }); @@ -204,6 +214,7 @@ describeEmulator('runner profile persistence against local Firestore', () => { 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([ @@ -282,6 +293,7 @@ describeEmulator('runner profile persistence against local Firestore', () => { 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); }); diff --git a/functions/runnerConnectionProfiles.js b/functions/runnerConnectionProfiles.js index cebc939..f656765 100644 --- a/functions/runnerConnectionProfiles.js +++ b/functions/runnerConnectionProfiles.js @@ -4,6 +4,7 @@ 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. @@ -178,6 +179,11 @@ function createRunnerProfileStore({ db, authorize, now = Date.now } = {}) { // 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, @@ -201,4 +207,5 @@ function createRunnerProfileStore({ db, authorize, now = Date.now } = {}) { 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 0000000..7597705 --- /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 0000000..a715355 --- /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 0000000..1ab1271 --- /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 index c440236..2f0e513 100644 --- a/functions/runnerConnectionService.emulator.test.js +++ b/functions/runnerConnectionService.emulator.test.js @@ -3,6 +3,7 @@ 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'); @@ -57,6 +58,7 @@ describeEmulator('runner profile callable integration with local Auth and Firest 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(); diff --git a/functions/runnerConnectionService.js b/functions/runnerConnectionService.js index 1fbc8d1..c8a3394 100644 --- a/functions/runnerConnectionService.js +++ b/functions/runnerConnectionService.js @@ -7,6 +7,7 @@ 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 @@ -15,7 +16,7 @@ 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 }); +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.', @@ -58,7 +59,9 @@ async function currentAccount(auth, subject) { : (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 - && subject.authTime * 1000 >= validAfter; + // 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) { @@ -97,8 +100,10 @@ function createRunnerConnectionCallables({ const subject = requireContext(context, timestamp); let request; try { - request = operation === 'read' - ? readExactDataObject(data, []) : readRunnerProfileRequest(data, operation); + 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. @@ -108,6 +113,23 @@ function createRunnerConnectionCallables({ }); 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 }) => { @@ -135,6 +157,9 @@ function createRunnerConnectionCallables({ getMyRunnerConnectionProfile: callable('read'), saveMyRunnerConnectionProfile: callable('save'), withdrawMyRunnerConnectionProfile: callable('withdraw'), + getMyRunnerConnectionRecommendations: callable('recommend'), + getMyRunnerConnectionExclusion: callable('exclusionRead'), + setMyRunnerConnectionExclusion: callable('exclude'), }); } diff --git a/functions/runnerConnectionService.test.js b/functions/runnerConnectionService.test.js index b1a3fdb..f1584c0 100644 --- a/functions/runnerConnectionService.test.js +++ b/functions/runnerConnectionService.test.js @@ -17,8 +17,8 @@ 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(3); - expect(functions.runWith).toHaveBeenCalledTimes(3); + 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)) { diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index c5e8dd7..71ae54a 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -182,12 +182,14 @@ function expectedFirestoreRulesJob() { 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"', + + 'runnerConnectionService.emulator.test.js ' + + 'runnerConnectionRecommendations.emulator.test.js"', }, ], }; @@ -816,8 +818,10 @@ test('guard rejects skipped, misdirected or weakened runner-profile emulator exe const mutations = [ ciWorkflow.replace("REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_PROFILES_EMULATOR: '0'"), ciWorkflow.replace("REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_SERVICE_EMULATOR: '0'"), + ciWorkflow.replace("REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR: '1'", "REQUIRE_RUNNER_CONNECTION_RECOMMENDATIONS_EMULATOR: '0'"), ciWorkflow.replace('--only firestore,auth', '--only firestore'), ciWorkflow.replace('runnerConnectionService.emulator.test.js', 'runnerConnectionService.test.js'), + ciWorkflow.replace('runnerConnectionRecommendations.emulator.test.js', 'runnerConnections.test.js'), ciWorkflow.replace('--project demo-functions-test', '--project synthetic-hosted-project'), ciWorkflow.replace('runnerConnectionProfiles.emulator.test.js', 'runnerConnections.test.js'), ciWorkflow.replace(` - name: ${stepName}`, ` - name: ${stepName}\n if: ${NEVER_RUN}`), diff --git a/tests/firestore-rules/runner-connections.test.js b/tests/firestore-rules/runner-connections.test.js index 259ee3c..6c87f90 100644 --- a/tests/firestore-rules/runner-connections.test.js +++ b/tests/firestore-rules/runner-connections.test.js @@ -19,6 +19,12 @@ describe.each(actors)('runner connection profile boundary — %s', (_label, auth 'runnerConnectionProfiles/synthetic-runner/privateRecords', 'memberships', 'memberships/synthetic-runner/privateRecords', + 'runnerConnectionEntries', + 'runnerConnectionEntries/synthetic-runner/privateRecords', + 'runnerConnectionWindows', + 'runnerConnectionWindows/synthetic-runner/seen', + 'runnerConnectionExclusions', + 'runnerConnectionExclusions/synthetic-runner/privateRecords', ])('cannot read, enumerate or mutate %s', async (collectionPath) => { const documentPath = `${collectionPath}/synthetic-runner`; await seed(documentPath, { marker: 'synthetic-private-card' }); From 0d270ee32bcc8753cb402dd6d49fa2d5feeefbb0 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 27 Sep 2026 03:32:24 -0700 Subject: [PATCH 5/7] feat: add disabled opt-in runner connection interface --- .github/lint-baseline.json | 15 +- GITHUB_ISSUES.md | 6 +- IMPLEMENTATION_PLAN.md | 7 +- OPERATIONS_RUNBOOK.md | 13 +- SECURITY.md | 8 +- SYSTEM_DESIGN.md | 6 +- docs/RUNNER_CONNECTIONS.md | 87 +++- docs/officers/RUNNER_CONNECTIONS.md | 44 +- src/App.jsx | 2 + src/pages/account/Account.tsx | 2 + src/pages/account/RunnerCard.tsx | 144 ++++++ src/pages/account/RunnerConnections.css | 128 +++++ src/pages/account/RunnerConnections.test.tsx | 366 ++++++++++++++ src/pages/account/RunnerConnections.tsx | 444 +++++++++++++++++ src/pages/account/RunnerProfileForm.tsx | 459 ++++++++++++++++++ .../account/runnerConnectionContract.ts | 344 +++++++++++++ .../account/runnerConnectionService.test.ts | 241 +++++++++ .../account/runnerConnectionService.ts | 133 +++++ tests/ci-workflow.test.js | 2 +- 19 files changed, 2413 insertions(+), 38 deletions(-) create mode 100644 src/pages/account/RunnerCard.tsx create mode 100644 src/pages/account/RunnerConnections.css create mode 100644 src/pages/account/RunnerConnections.test.tsx create mode 100644 src/pages/account/RunnerConnections.tsx create mode 100644 src/pages/account/RunnerProfileForm.tsx create mode 100644 src/services/account/runnerConnectionContract.ts create mode 100644 src/services/account/runnerConnectionService.test.ts create mode 100644 src/services/account/runnerConnectionService.ts diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index f6a143f..2080bd4 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_ISSUES.md b/GITHUB_ISSUES.md index b8011b4..6b045b8 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -35,8 +35,10 @@ 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 and the member interface remain -incomplete. This is +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. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 12f210b..00f3e4c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -12,9 +12,10 @@ This plan turns the target architecture into a dependency-ordered delivery progr 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) from outstanding -canonical membership population, retention/account-deletion approval, member UI and -live-release work. Explicit 18+ self-attestation +(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. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index e929132..ee824d5 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -13,11 +13,20 @@ 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, member UI and a protected pilot remain +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. +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 #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. diff --git a/SECURITY.md b/SECURITY.md index ce195b1..071dc35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,12 @@ 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. Approved membership population, retention/account-deletion -approval, member UI and protected release proof remain required; see the +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). ## 2. Reporting a vulnerability diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 0f3dba6..a76349b 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -23,8 +23,10 @@ The September 27 owner direction adds in-house, opt-in runner connections under [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 interface is still absent and no -service is deployed. Approved membership population remains a release +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 diff --git a/docs/RUNNER_CONNECTIONS.md b/docs/RUNNER_CONNECTIONS.md index 05f0a8b..2ebb417 100644 --- a/docs/RUNNER_CONNECTIONS.md +++ b/docs/RUNNER_CONNECTIONS.md @@ -167,7 +167,7 @@ endpoint. All six handlers stay disabled and absent from the deployment index. 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; the future UI must + 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 @@ -192,6 +192,48 @@ 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, @@ -200,11 +242,10 @@ already sent data. These tests do not prove live middleware or provider settings 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. Independently review the integrated recommendation/privacy interface and - failure cases; backend callback tests alone are not end-to-end acceptance. -5. Add the member form, exact-card preview, explicit consent and recommendation - interface with empty/unavailable/uncertain-save behavior. Keep its capability - disabled until backend-first deployment and privacy/release review pass. +4. Rehearse the browser and real callable transport together in isolated staging; + backend callback tests and a fake-client browser demo are not 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. @@ -223,12 +264,16 @@ flowchart LR Recheck --> Core Service --> Controls["Own scoped exclusion lookup/change and audit"] Controls --> Recheck - Future["NOT IMPLEMENTED: member interface"] -.-> Service + 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"] + 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; the member interface is missing and nothing is live. +checks in local emulators; the disabled member UI has separate fake-client browser +checks, public run information needs no runner profile, and no runner service is live. ## Evidence boundary @@ -245,8 +290,10 @@ 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. -That run is not evidence for later revisions; use the PR's exact-head checks for -the new recommendation change. +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 @@ -258,14 +305,30 @@ 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, missing member UI/retention procedures and unproven hosted enforcement. +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, the member UI or real connections. The issue and draft PR +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. + With Node 20, Java 21 and committed lockfile installs, run the focused storage check with: diff --git a/docs/officers/RUNNER_CONNECTIONS.md b/docs/officers/RUNNER_CONNECTIONS.md index 7535895..92c3e7e 100644 --- a/docs/officers/RUNNER_CONNECTIONS.md +++ b/docs/officers/RUNNER_CONNECTIONS.md @@ -18,13 +18,16 @@ 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. -There is no member form or published card. Nothing in this change is deployed. +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. +Nothing in this change is deployed. The officer lookup setting does not authorize member discovery. -The planned form will ask members to confirm they are 18 or older. It will not -collect a birth date or claim to verify age or identity. The box must start -unchecked. Withdrawal clears the confirmation, so joining again requires a new -confirmation. Club membership is checked separately by the tested server code. +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. @@ -39,11 +42,25 @@ 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 future interface still -needs a clear way to review existing privacy choices. Retention and account +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. + ## Review steps 1. Ask the platform owner for the issue's current source and test record. @@ -53,7 +70,11 @@ been selected by this code. 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. Request separate website and Firebase deployment evidence before announcing +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. **Expected result:** officers can distinguish source development from a usable @@ -66,8 +87,11 @@ as verified age, a minor is included, or anyone claims the code is a live servic **Success proof:** for now, the recorded synthetic calculation, local account, membership, storage, bounded recommendations, privacy-control, request-limit and -browser-denial tests. The member interface, actual hosted authentication checks and a live pilot remain -outstanding. A specialist is still needed for every implementation/release step. +browser-denial tests, plus separate form/client tests and a fake-service browser +demonstration. Connected browser-to-server checks, 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. diff --git a/src/App.jsx b/src/App.jsx index 65f18a6..a9f9f66 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. +
+ )} +