From 18d62107fee4136a78ef11e6a96b7f2235c0d173 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 13 Oct 2026 01:12:01 -0700 Subject: [PATCH] test: establish inert fictional race refund and bib transfer boundary --- GITHUB_ISSUES.md | 2 + IMPLEMENTATION_PLAN.md | 2 + OPERATIONS_RUNBOOK.md | 2 + SECURITY.md | 2 + STRIPE_COMMERCE_DESIGN.md | 2 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 2 + docs/officers/STAGING_COMMUNITY.md | 13 ++ functions/fictionalRaceTransitions.js | 115 +++++++++++++ functions/fictionalRaceTransitions.test.js | 181 +++++++++++++++++++++ 10 files changed, 323 insertions(+) create mode 100644 functions/fictionalRaceTransitions.js create mode 100644 functions/fictionalRaceTransitions.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 0599675..41f9a0d 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -168,6 +168,8 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [ ## CI-001 — Repair test gates and secure the deployment pipeline +**RACE-SIM-001A #727 — source-only fictional transition foundation:** Dave approved fictional staging refund/bib-transfer rules after #724 closed. Pure strict synthetic schema/cents/time/revision/ownership/terminal/deadline/waiver transitions, without runtime/client/bundle integration or deployment. Full simulated return releases one place; one recipient-accepted transfer preserves bib/count/original credit/waiver. Actual native authorization, target marking, transactions/immutable command receipts/concurrency/retries and staging UI/deployment/cleanup are separate follow-ons. Real policy/provider money/production and financial parents remain open. + **COMMUNITY-STATUS-001 #724 — staging acceptance verified 2026-10-13:** PR725/`485be670`, exact six PR/main CI38359747304/38360107295 jobs/every step, communityActions version 5/native archive `a4c2eefa`/91,973 bytes and exact Hosting pass. All six availability states, cancellation history/own cancellation, failed-read clearing, exact immutable retry and natural term expiry passed actual UI; read-only invariance and one-account/15-document fenced cleanup/sign-out/tab closure were verified. Additive response-only enum/after-read clock; existing schemas, mutations, policy, authorization, security/resource scope and profiles unchanged. No new service/dependency/IAM/Rules/polling/receiver/permission or production/payment action. Dated no-terminal handoff updated; real financial/production and broad backend completion remain separate. Dave approved fictional race rules for the next staging outcome, not real club policy. **CI-STAGING-VERIFY-001 #722:** one script-only child corrects the final community success output's undefined digest, adds closed redacted failure phases and fixed read-only native verification. Local reproduction proves the reporting bug, not the caught exception from every historical failure. Clean exact green current-main source, native ancestor provenance/exact archive, scoped owner/runtime/config/resource/invoker and unchanged profile inventory remain mandatory. No new deployment, provider/IAM/data/payment mutation, dependency, workflow or production authority. See the [officer review](./docs/officers/STAGING_COMMUNITY.md#check-an-uncertain-staging-release--722). This does not close #105/#133/#136 or full backend readiness; current command/unchanged-marker evidence must be recorded in #722 before claiming hosted verification. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3a94976..7fc1a2f 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,7 @@ # MPRC Secure Commerce Implementation Plan +**#727 fictional race foundation — SOURCE ONLY:** first test a closed pure transition model without provider calls or deployed integration. Then implement native Auth/App Check/disposable-target/permission admission and atomic event/entry/command receipts; test actual concurrency and immutable retries. Only afterward add minimal staging screens and perform exact backend-first Function/Hosting deployment, genuine disposable UI/security/lifecycle checks and owned cleanup. No real rules/payments/production authority is implied; #724 core stays unchanged. + **#719 focused continuation — STAGING VERIFIED 2026-10-12:** verified #716 dependency is followed by deliberate fictional receipt linking/unchanged term activation in the same staging service/UI. Separate capability/marker, authoritative Auth, pending revision, atomic uniqueness, permission-first retry and cancellation-through-term guards passed local full checks and 44 hosted cases, including real screens/offline/races/natural expiry. PR720/b6a8fd5 exact PR/main CI passed; Function v4/archive and staging Hosting match; six users/47 documents cleaned. Close only the scoped child after final native handoff; #115/#81, real rules/payments and production launch remain separate. **Status:** Approved technical sequence and assessment snapshot; GitHub issues are the live execution/status source diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 200eca6..fdf51ec 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**#727 fictional race foundation — NOT AVAILABLE YET:** pure helper/tests only; it is intentionally excluded from callable/client imports and the community runtime archive. Do not deploy unchanged Functions or use legacy real-payment/runner-substitution screens for this test. Future operation needs current native identity/authority, expiring owner-marked disposable targets, strict fictional configuration, atomic event/entry/immutable receipt persistence, bounded retry/rate/audit and actual backend-first staging/UI/cleanup proof. Source simulation has no provider/account state to undo. Current #724 staging remains unchanged; real club rules, financial/provider work and production are separate. + **#724 current-status correction — STAGING VERIFIED 2026-10-13:** same scoped staging service/runtime, response-only availability and no migration. PR725/`485be670` passed six exact PR/main jobs/every step before backend-first publication. communityActions version 5/native 91,973-byte archive `a4c2eefa` and staging Hosting match; deployment and read-only verification returned success. Profiles, resource/security/IAM scope remain unchanged. All six availability states, cancelled-event confirmed history/own cancellation, failed refresh clearing, immutable saved retry and natural synthetic term expiry passed on the actual member page. Failed-read proof used invalid owned configuration, not offline simulation. One account/15 owned documents were removed with identity/update-time fences and absence checks, then sign-out/tab closure. Follow the [no-terminal review](./docs/officers/STAGING_COMMUNITY.md#current-member-status--724-staging-verified). No extra service/polling, receiver, browser permission, real policy/payment or production action. Fictional race rules are approved only for the next staging outcome; those workflows are not yet deployed. ## Read-only community release verification — #722 diff --git a/SECURITY.md b/SECURITY.md index 95cf91f..7898f1b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # MPRC Security, Privacy, and Risk Plan +**#727 fictional race foundation — SOURCE ONLY:** strict closed synthetic record/request shapes, integer bounds, expected revisions, fixed safe errors and original evidence preservation apply only to an unconnected pure helper. Fake identifier prefixes or simulation markers are not identity, disposable-target, permission, App Check, atomic persistence/retry or provider proof. No callable, client, release-bundle import, IAM/Rule/service change or deployment; existing #724 staging and production boundaries unchanged. A later server boundary must independently supply native verified identities, current scoped authority, target markers, time, transactional command receipts/counts and genuine staging denial/race/retry/cleanup evidence before the screen is available. + **#719 staging linking — STAGING VERIFIED 2026-10-12:** live expiring exact v3 association permission remains separate. Native recent verified TOTP/App Check, exact caller/input, owner-marked disposable UID, authoritative enabled/verified Auth and pending null-authority revision are required. Atomic receipt/account uniqueness and permission-first replay prevent duplicate activation/reassignment/renewal; original evidence/term and profiles/claims remain unchanged. Minimal linked receipt projection exposes no target/contact data. Both new collections remain default-denied even to browser admins; runtime keeps the same six project-scoped permissions. Native source/spec/security and 32 denial cases plus hosted races/retries/UI/revocation/cancel/expiry and exact cleanup passed. This is fictional staging proof, not production security qualification. **Status:** Pre-production security assessment diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index c755c47..2b58eb6 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,5 +1,7 @@ # Stripe, Race Registration, and Merchandise Design +**#727 fictional race foundation — SOURCE ONLY:** a separate unconnected pure model uses explicit fictional cents/returned-credit fields and closed simulation markers. It neither reads nor creates a Checkout, PaymentIntent, Refund, provider object, webhook or real payment status. Test-only full credit return/recipient-accepted bib transfer rules do not approve actual club policy or close PAY-004/PAY-005/RACE-001. Real refunds still require verified provider state, finance permission/recent MFA, durable idempotent operations and reconciliation below. No runtime/client/release-bundle import or deployment. + **#719 fictional activation — STAGING VERIFIED 2026-10-12:** separately authorized disposable accounts may receive only an unchanged fictional receipt term. This is not payment verification, Stripe reconciliation, a real dues decision, renewal, refund or Auth-role grant. Receipt evidence stays immutable; association is separate and server-only. Native Function v4/Hosting b6a8fd5 and actual cancellation/expiry tests passed; six users/47 documents were cleaned. Production and real payments remain untouched and unavailable. **Status:** Required production design; current code is a test-mode prototype diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index e064171..3bd3c13 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**#727 fictional race foundation — SOURCE ONLY / NOT AVAILABLE YET:** `fictionalRaceTransitions.js` is a pure, strictly bounded model, not imported by an entrypoint, client or runtime release bundle. Dave approved fictional test rules only: full simulated credit return before race start; one recipient-accepted bib transfer with a short offer deadline, original credit/waiver/bib preservation and no capacity increase. Expected event/entry revisions and terminal guards prevent an admitted transition from using stale supplied records; this is not native authorization, atomic persistence, immutable retry/journal or concurrency proof. No payment/provider evidence, real rules, new service/dependency/IAM/Rules, migration or deployment. Follow-on trusted Auth/App Check/disposable-target/capability boundary, transactional command receipts/capacity, minimal staging UI and backend-first hosted tests/cleanup remain required. Current #724 Function/Hosting and production remain unchanged. + **#724 member-status correction — STAGING VERIFIED 2026-10-13:** the existing staging status read adds a response-only closed `signupStatus` enum (open/not_open_yet/closed/ended/full/cancelled), derived from validated event/window/capacity with server time sampled after reads. No stored schema, mutation authorization, count, registration/waiver/audit, membership policy, IAM/Rules/service/dependency or production change. UI distinguishes cancelled events from stored signups, fails closed on missing/unknown availability and hides old membership/event status/actions on uncertain reads while preserving exact saved retry and account/provider fences. No polling. PR725 merged `485be670244c7ecf59cc7ab32ac465f5b3372a1f`; exact PR/main CI38359747304/38360107295 passed all six jobs/every step. Native Function version 5 and staging Hosting match that source; the 91,973-byte archive matches SHA-256 `a4c2eefaa3e7c3a6b40580ef7dcd7d668c80dedf4563a13565cb5034a449ba1c`. Deployment/read-only verification returned success; profiles/security/resource scope are unchanged. Actual UI proved all six states, cancelled-event history/own cancellation, failed-read clearing, immutable saved retry and natural synthetic term expiry. Nine initial documents/update times stayed unchanged by reads; one disposable account/15 owned documents were removed with fences and absence checks, followed by sign-out/tab closure. Backend failure used an invalid owned manifest, not offline simulation. No new service, receiver or browser permission was needed. Production and real payments remain separate; fictional race rules were approved only for subsequent staging tests. **#719 fictional account linking — STAGING VERIFIED 2026-10-12:** `admin_dues_link` deliberately connects one immutable fictional receipt to one owner-marked verified/enabled disposable pending account. Exact v3 association permission is separate from recording/approval/event permissions; native recent TOTP/App Check, exact caller/input/revision and <=24-hour disposable marker are required. One transaction creates a separate link and derived authority preserving the original current term, preventing duplicate receipt/account activation, reassignment and renewal. Saved retries recheck permission first; cancellation keeps the original term and server-clock expiry removes test membership. Receipt review adds only a linked boolean, no UID/contact data. No Auth claim/role, real policy/data/payment, dependency/service/schedule/IAM expansion/migration or production change. Default-deny browser Rules cover both new collections. PR720 merged b6a8fd57d9add466da73df1d33b69e131e3fecbd after six exact PR/main jobs and every step passed. Native communityActions version 4 and staging Hosting match that source; the downloaded 91,793-byte archive matches SHA-256 47f11b8bec7f5ae58281c9029a015e9cc751a04e5ad3eafe3234441fa3a963db. Forty-four hosted cases (32 denial cases), atomic races, immutable retries, actual officer/member UI, offline retry, phone-size DOM layout, cancellation and natural expiry passed. Six disposable users and 47 exact owned documents were removed; native absence and late profiles were verified. Both capabilities were disabled first; browser sign-out, Apps-on-device OFF, normal networking/viewport, closed tab/receiver and memory disposal complete the test handoff. Profiles v2/v1/2843388, App Check/MFA and the six-permission min0/max2 runtime remain unchanged. The release command reported its existing generic post-deploy failure; independent native checks qualified the result without repeating deployment or bypassing a guard. Reporting cause remains unqualified. Production/runmprc.com and real payments were untouched. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 526f681..194564c 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -3,6 +3,8 @@ **#719 fictional linking — STAGING VERIFIED 2026-10-12:** the [supervised screen procedure](./STAGING_COMMUNITY.md#link-a-fictional-receipt--719) deliberately connects an immutable fictional receipt to one specialist-marked disposable pending account. Real screens verified activation, cancellation through the unchanged original term and inactive status after natural expiry. Old test fixtures are removed; ask the specialist for fresh private setup. No real policy/payment/claim/renewal/refund/production launch is supplied. Stop on an uncertain ID/revision or changed term and contact Dave privately. **Current status:** live commerce is **NOT AVAILABLE YET**. + +**Fictional race follow-on, #727:** Dave approved fictional refund and bib-transfer rules for staging tests, not real club policy. The [source-only handoff](./STAGING_COMMUNITY.md#fictional-race-refunds-and-bib-transfers--727-source-foundation-not-available-yet) models credit return and recipient-accepted transfer with made-up records. No usable screen, native authorization, transaction/retry proof, provider refund or deployment is supplied by that foundation. Do not use legacy refund/substitute controls or edit a real entry as a workaround. **Use this guide when:** a request touches registrations, members-only access, products, payments, refunds, waivers, or private data. **Prerequisites:** a claimed issue, named business owner and backup, approved policy/value, isolated test data, and a platform specialist. diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 5c88ac5..19399c0 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -27,6 +27,19 @@ ## What the actions mean +## Fictional race refunds and bib transfers — #727 source foundation, NOT AVAILABLE YET + +**Purpose:** prepare a safe demonstration without moving money. **Approver/escalation:** Dave, sole staging owner; no backup is appointed. **Prerequisites:** the later reviewed server transaction, verified disposable accounts/markers, narrow permissions, genuine Auth/App Check and actual staging screen/deployment/cleanup proof. None is supplied by this source foundation. There is no usable race refund or transfer button from #727. + +Dave approved fictional test rules, not real club rules. The source models a full return of fictional credit before race start, releasing a seat once. A transfer keeps the same bib and booked count, requires the named recipient's fictional-waiver acceptance before a short deadline, and preserves the original fictional credit owner and waiver. An outstanding offer must be revoked before cancellation. Only one transfer is permitted in this test model. After transfer, only the current runner may cancel; simulated credit returns to its original owner. No card, bank, Stripe object, real refund or paid membership is involved. + +1. Ask Dave for the next issue's dated staging demonstration evidence. +2. Until the server and screen are verified, treat these actions as **NOT AVAILABLE YET**. +3. Do not use an existing legacy refund or substitute screen as a shortcut. +4. Stop on a real account, payment detail, production URL or request to edit a record. + +**Expected result/proof:** #727 supplies pure synthetic transition tests only. Fake identifiers do not prove native identity, permission, disposable ownership, atomic capacity or retry safety. Those must be independently checked in the follow-on server/UI work; a simulated credit is not provider-confirmed money. **Undo:** no provider or account state changes in this foundation; stop the review and request a small reviewed source revert if needed. **Specialist-only work:** integration, fixtures, provider readback, exact owned cleanup and rollback. Officers need no terminal, secret, payment detail or record edit. + ## Link a fictional receipt — #719 **Status:** STAGING VERIFIED on 2026-10-12 under #719. This supplements #716 recording without changing its immutable receipt or approving real club policy. All old disposable fixtures are removed; fresh specialist-supplied private setup is required. Production, ordinary officer access and real dues remain **NOT AVAILABLE YET**. diff --git a/functions/fictionalRaceTransitions.js b/functions/fictionalRaceTransitions.js new file mode 100644 index 0000000..e0aae64 --- /dev/null +++ b/functions/fictionalRaceTransitions.js @@ -0,0 +1,115 @@ +'use strict'; + +// Inert pure foundation. Not imported by any callable, client or release bundle. +// This models fictional credits, never payment/refund/provider evidence. +// A future trusted boundary must supply native identity/time, disposable markers, +// capabilities and atomic record + immutable command-receipt persistence. +const { parseStrictObject } = require('./requestValidation'); +const POLICY = 'fictional-race-v1-not-money'; +const WAIVER = 'fictional-race-waiver-v1'; +const ID = /^stage_race_[A-Za-z0-9_-]{1,64}$/; +const MESSAGE = 'Fictional race transition unavailable.'; +const check = value => { if (!value) throw new Error(MESSAGE); }; +const integer = (value, max) => Number.isSafeInteger(value) && value >= 0 && value <= max; +const time = value => integer(value, 8640000000000000); +const id = value => typeof value === 'string' && ID.test(value); +const strict = (value, keys) => parseStrictObject(value, { requiredKeys: keys, optionalKeys: [] }); + +function waiver(value, uid, now) { + const parsed = strict(value, ['uid', 'version', 'acceptedAtMs']); + check(parsed.uid === uid && parsed.version === WAIVER && time(parsed.acceptedAtMs) && parsed.acceptedAtMs <= now); + return parsed; +} + +function records(eventInput, entryInput, now) { + check(time(now)); + const event = strict(eventInput, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', + 'revision', 'startAtMs', 'status', 'capacity', 'confirmedCount']); + const entry = strict(entryInput, ['schemaVersion', 'simulationOnly', 'policyVersion', 'eventId', + 'entryId', 'revision', 'bibId', 'runnerUid', 'creditOwnerUid', 'fictionalAmountCents', + 'fictionalReturnedCents', 'currency', 'status', 'transferCount', 'originalWaiver', 'recipientWaiver', 'offer']); + for (const record of [event, entry]) { + check(record.schemaVersion === 1 && record.simulationOnly === true && record.policyVersion === POLICY + && id(record.eventId) && integer(record.revision, 999999) && record.revision > 0); + } + check(entry.eventId === event.eventId && time(event.startAtMs) + && ['open', 'cancelled'].includes(event.status) + && integer(event.capacity, 10000) && event.capacity > 0 + && integer(event.confirmedCount, event.capacity)); + check(id(entry.entryId) && id(entry.bibId) && id(entry.runnerUid) && id(entry.creditOwnerUid) + && integer(entry.fictionalAmountCents, 10000) && entry.fictionalAmountCents > 0 + && entry.currency === 'USD' && integer(entry.transferCount, 1) + && ['confirmed', 'cancelled'].includes(entry.status) + && entry.fictionalReturnedCents === (entry.status === 'cancelled' ? entry.fictionalAmountCents : 0)); + waiver(entry.originalWaiver, entry.creditOwnerUid, now); + if (entry.transferCount === 0) check(entry.runnerUid === entry.creditOwnerUid && entry.recipientWaiver === null); + else { + check(entry.runnerUid !== entry.creditOwnerUid && entry.offer === null); + const accepted = waiver(entry.recipientWaiver, entry.runnerUid, now); + check(accepted.acceptedAtMs >= entry.originalWaiver.acceptedAtMs); + } + if (entry.offer !== null) { + const pending = strict(entry.offer, ['recipientUid', 'expiresAtMs']); + check(entry.status === 'confirmed' && entry.transferCount === 0 && id(pending.recipientUid) + && pending.recipientUid !== entry.runnerUid && time(pending.expiresAtMs) + && pending.expiresAtMs <= event.startAtMs); + } + return { event, entry }; +} + +function request(input) { + // First safely clone the discriminant; never read getters/proxies directly. + const parsed = parseStrictObject(input, { requiredKeys: ['action', 'eventId', 'entryId', + 'expectedEventRevision', 'expectedEntryRevision'], optionalKeys: ['recipientUid', 'expiresAtMs', 'waiverVersion'] }); + const additions = parsed.action === 'offer_transfer' ? ['recipientUid', 'expiresAtMs'] + : parsed.action === 'accept_transfer' ? ['waiverVersion'] : []; + strict(parsed, ['action', 'eventId', 'entryId', 'expectedEventRevision', 'expectedEntryRevision', ...additions]); + check(['simulate_refund', 'offer_transfer', 'accept_transfer', 'revoke_transfer'].includes(parsed.action) + && id(parsed.eventId) && id(parsed.entryId) + && integer(parsed.expectedEventRevision, 999999) && parsed.expectedEventRevision > 0 + && integer(parsed.expectedEntryRevision, 999999) && parsed.expectedEntryRevision > 0); + return parsed; +} + +function reduceFictionalRace(eventInput, entryInput, commandInput, callerUid, now) { + try { + const { event, entry } = records(eventInput, entryInput, now); + const command = request(commandInput); + check(id(callerUid) && command.eventId === event.eventId && command.entryId === entry.entryId + && command.expectedEventRevision === event.revision && command.expectedEntryRevision === entry.revision + && entry.status === 'confirmed' && event.confirmedCount > 0 && now < event.startAtMs); + const nextEntry = { ...entry, revision: entry.revision + 1 }; + let nextEvent = event; + if (command.action === 'accept_transfer') { + check(event.status === 'open' && entry.offer !== null && callerUid === entry.offer.recipientUid + && now < entry.offer.expiresAtMs && command.waiverVersion === WAIVER); + nextEntry.runnerUid = callerUid; + nextEntry.transferCount = 1; + nextEntry.recipientWaiver = Object.freeze({ uid: callerUid, version: WAIVER, acceptedAtMs: now }); + nextEntry.offer = null; + } else { + check(callerUid === entry.runnerUid); + if (command.action === 'simulate_refund') { + check(entry.offer === null); + nextEntry.status = 'cancelled'; + nextEntry.fictionalReturnedCents = entry.fictionalAmountCents; + nextEvent = { ...event, revision: event.revision + 1, confirmedCount: event.confirmedCount - 1 }; + } else if (command.action === 'offer_transfer') { + check(event.status === 'open' && entry.transferCount === 0 && entry.offer === null + && id(command.recipientUid) && command.recipientUid !== callerUid + && time(command.expiresAtMs) && now < command.expiresAtMs + && command.expiresAtMs <= event.startAtMs && command.expiresAtMs - now <= 300000); + nextEntry.offer = Object.freeze({ recipientUid: command.recipientUid, expiresAtMs: command.expiresAtMs }); + } else { + check(entry.offer !== null); + nextEntry.offer = null; + } + } + // Keep next records representable by this same schema, including revision + // ceilings. No transition may produce a record that a subsequent read rejects. + records(nextEvent, nextEntry, now); + return Object.freeze({ simulationOnly: true, event: Object.freeze(nextEvent), entry: Object.freeze(nextEntry) }); + } catch { throw new Error(MESSAGE); } +} + +module.exports = { POLICY, WAIVER, reduceFictionalRace }; diff --git a/functions/fictionalRaceTransitions.test.js b/functions/fictionalRaceTransitions.test.js new file mode 100644 index 0000000..f291039 --- /dev/null +++ b/functions/fictionalRaceTransitions.test.js @@ -0,0 +1,181 @@ +const { reduceFictionalRace, POLICY, WAIVER } = require('./fictionalRaceTransitions'); +const now = 1700000000000; +const owner = 'stage_race_owner'; +const recipient = 'stage_race_recipient'; +function fixture() { + return { + event: { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, eventId: 'stage_race_event', + revision: 1, startAtMs: now + 60000, status: 'open', capacity: 1, confirmedCount: 1 }, + entry: { schemaVersion: 1, simulationOnly: true, policyVersion: POLICY, + eventId: 'stage_race_event', entryId: 'stage_race_entry', revision: 1, bibId: 'stage_race_bib', + runnerUid: owner, creditOwnerUid: owner, fictionalAmountCents: 1000, fictionalReturnedCents: 0, + currency: 'USD', status: 'confirmed', transferCount: 0, + originalWaiver: { uid: owner, version: WAIVER, acceptedAtMs: now - 1000 }, recipientWaiver: null, offer: null }, + }; +} +function command(action, extra = {}) { + return { action, eventId: 'stage_race_event', entryId: 'stage_race_entry', + expectedEventRevision: 1, expectedEntryRevision: 1, ...extra }; +} +const offer = () => command('offer_transfer', { recipientUid: recipient, expiresAtMs: now + 30000 }); +function apply(state, cmd, uid = owner, at = now) { + return reduceFictionalRace(state.event, state.entry, cmd, uid, at); +} +function rejects(state, cmd, uid = owner, at = now) { + const before = JSON.stringify(state); + expect(() => apply(state, cmd, uid, at)).toThrow('Fictional race transition unavailable.'); + expect(JSON.stringify(state)).toBe(before); +} + +describe('inert fictional race transition foundation', () => { + test('full fictional return cancels one entry and releases one seat without changing original evidence', () => { + const state = fixture(); const original = JSON.stringify(state); + const next = apply(state, command('simulate_refund')); + expect(next.event).toEqual({ ...state.event, revision: 2, confirmedCount: 0 }); + expect(next.entry).toEqual({ ...state.entry, revision: 2, status: 'cancelled', fictionalReturnedCents: 1000 }); + expect(next.entry.creditOwnerUid).toBe(owner); + expect(JSON.stringify(state)).toBe(original); + expect(next.simulationOnly).toBe(true); + expect(next).not.toHaveProperty('paymentStatus'); + rejects(next, command('simulate_refund')); + rejects(next, command('simulate_refund', { expectedEventRevision: 2, expectedEntryRevision: 2 })); + }); + + test('cancelled organizer event permits a fictional return, not a transfer', () => { + const state = fixture(); state.event.status = 'cancelled'; + expect(apply(state, command('simulate_refund')).event.confirmedCount).toBe(0); + rejects(state, offer()); + }); + + test('offer does not change runner, original credit, evidence, bib or capacity', () => { + const state = fixture(); const next = apply(state, offer()); + expect(next.event).toEqual(state.event); + expect(next.entry).toEqual({ ...state.entry, revision: 2, offer: { recipientUid: recipient, expiresAtMs: now + 30000 } }); + rejects(next, command('simulate_refund', { expectedEntryRevision: 2 })); + rejects(next, { ...offer(), expectedEntryRevision: 2 }); + }); + + test('only named recipient accepting the exact fictional waiver transfers one bib, with no extra seat', () => { + const state = apply(fixture(), offer()); + const cmd = command('accept_transfer', { expectedEntryRevision: 2, waiverVersion: WAIVER }); + const next = apply(state, cmd, recipient); + expect(next.event).toEqual(state.event); + expect(next.entry).toEqual({ ...state.entry, revision: 3, runnerUid: recipient, transferCount: 1, + recipientWaiver: { uid: recipient, version: WAIVER, acceptedAtMs: now }, offer: null }); + rejects(state, cmd, owner); + rejects(state, cmd, 'stage_race_stranger'); + rejects(state, { ...cmd, waiverVersion: 'real-waiver' }, recipient); + rejects(next, cmd, recipient); + rejects(next, command('offer_transfer', { expectedEntryRevision: 3, recipientUid: owner, expiresAtMs: now + 30000 }), recipient); + rejects(next, command('simulate_refund', { expectedEntryRevision: 3 }), owner); + const cancelled = apply(next, command('simulate_refund', { expectedEntryRevision: 3 }), recipient); + expect(cancelled.entry.creditOwnerUid).toBe(owner); + expect(cancelled.entry.originalWaiver).toEqual(fixture().entry.originalWaiver); + expect(cancelled.entry.recipientWaiver).toEqual(next.entry.recipientWaiver); + expect(cancelled.entry.fictionalReturnedCents).toBe(1000); + expect(cancelled.event.confirmedCount).toBe(0); + }); + + test('revocation is owner-only, preserves evidence and makes old acceptance stale', () => { + const state = apply(fixture(), offer()); + const revoked = apply(state, command('revoke_transfer', { expectedEntryRevision: 2 })); + expect(revoked.entry.offer).toBeNull(); + expect(revoked.event).toEqual(state.event); + rejects(state, command('revoke_transfer', { expectedEntryRevision: 2 }), recipient); + rejects(revoked, command('accept_transfer', { expectedEntryRevision: 2, waiverVersion: WAIVER }), recipient); + }); + + test.each([0, 29999])('accept before exclusive offer end %i', offset => { + const state = apply(fixture(), offer()); + expect(apply(state, command('accept_transfer', { expectedEntryRevision: 2, waiverVersion: WAIVER }), recipient, now + offset).entry.runnerUid).toBe(recipient); + }); + test.each([30000, 60000])('expired offer rejects acceptance at %i', offset => { + const state = apply(fixture(), offer()); + rejects(state, command('accept_transfer', { expectedEntryRevision: 2, waiverVersion: WAIVER }), recipient, now + offset); + }); + test('expired offer may be explicitly revoked then refunded before race start', () => { + const state = apply(fixture(), offer()); + const revoked = apply(state, command('revoke_transfer', { expectedEntryRevision: 2 }), owner, now + 30000); + expect(apply(revoked, command('simulate_refund', { expectedEntryRevision: 3 }), owner, now + 30000).entry.status).toBe('cancelled'); + }); + + test.each(['simulate_refund', 'offer_transfer', 'accept_transfer', 'revoke_transfer'])('race start denies %s', action => { + const state = ['accept_transfer', 'revoke_transfer'].includes(action) ? apply(fixture(), offer()) : fixture(); + const cmd = action === 'offer_transfer' ? offer() : command(action, { expectedEntryRevision: state.entry.revision, + ...(action === 'accept_transfer' ? { waiverVersion: WAIVER } : {}) }); + rejects(state, cmd, action === 'accept_transfer' ? recipient : owner, now + 60000); + }); + + test.each([-1, 0, 1.5, NaN, Infinity, 10001, Number.MAX_SAFE_INTEGER])('invalid fictional cents %s', amount => { + const state = fixture(); state.entry.fictionalAmountCents = amount; + rejects(state, command('simulate_refund')); + }); + test.each([1, 10000])('inclusive fictional cents %i', amount => { + const state = fixture(); state.entry.fictionalAmountCents = amount; + expect(apply(state, command('simulate_refund')).entry.fictionalReturnedCents).toBe(amount); + }); + test.each([ + ['simulationOnly', false], ['schemaVersion', 2], ['policyVersion', 'real'], ['currency', 'usd'], + ['runnerUid', 'real-member'], ['creditOwnerUid', recipient], ['status', 'paid'], ['fictionalReturnedCents', 1], + ['transferCount', 2], ['revision', 1000000], ['paymentStatus', 'paid'], ['stripePaymentIntentId', 'pi_fake'], + ])('rejects foreign or inconsistent entry %s', (key, value) => { + const state = fixture(); state.entry[key] = value; rejects(state, command('simulate_refund')); + }); + test.each([ + ['simulationOnly', false], ['confirmedCount', 0], ['capacity', 0], ['status', 'closed'], + ['eventId', 'stage_race_wrong'], ['revision', 1000000], ['confirmedCount', 2], ['startAtMs', -1], + ])('rejects invalid event %s', (key, value) => { + const state = fixture(); state.event[key] = value; rejects(state, command('simulate_refund')); + }); + test.each(['amountCents', 'currency', 'admin', 'paid', 'newRunner', 'reason', 'requestId'])('request injection %s', key => { + rejects(fixture(), { ...command('simulate_refund'), [key]: 'untrusted' }); + }); + test.each([undefined, null, [], 'refund', Object.create({ action: 'simulate_refund' })])('malformed command %s', cmd => { + rejects(fixture(), cmd); + }); + test('hostile getters and proxies are never evaluated or echoed', () => { + const getter = jest.fn(() => { throw Error('private synthetic value'); }); + const cmd = command('simulate_refund'); Object.defineProperty(cmd, 'action', { get: getter, enumerable: true }); + rejects(fixture(), cmd); expect(getter).not.toHaveBeenCalled(); + rejects(fixture(), new Proxy(command('simulate_refund'), { get: getter })); + expect(getter).not.toHaveBeenCalled(); + }); + test.each([now - 1, now + 60001, now + 300000, now + 1.5])('invalid offer deadline %s', expiresAtMs => { + rejects(fixture(), { ...offer(), expiresAtMs }); + }); + test('wrong caller, self transfer, stale revisions and mismatched target deny unchanged', () => { + rejects(fixture(), command('simulate_refund'), recipient); + rejects(fixture(), { ...offer(), recipientUid: owner }); + rejects(fixture(), command('simulate_refund', { expectedEntryRevision: 0 })); + rejects(fixture(), command('simulate_refund', { expectedEventRevision: 0 })); + rejects(fixture(), command('simulate_refund', { entryId: 'stage_race_wrong' })); + rejects(fixture(), command('unknown')); + }); + test.each([-1, 1.5, NaN, Infinity, 8640000000000001])('unusable server time %s', at => { + rejects(fixture(), command('simulate_refund'), owner, at); + }); + test('revision ceiling cannot produce an unreadable next record', () => { + const state = fixture(); state.entry.revision = 999999; + rejects(state, command('simulate_refund', { expectedEntryRevision: 999999 })); + state.entry.revision = 999998; + expect(apply(state, command('simulate_refund', { expectedEntryRevision: 999998 })).entry.revision).toBe(999999); + }); + test('changed event revision or organizer cancellation blocks pending recipient acceptance', () => { + const offered = apply(fixture(), offer()); + const state = { event: { ...offered.event, revision: 2 }, entry: offered.entry }; + const cmd = command('accept_transfer', { expectedEntryRevision: 2, waiverVersion: WAIVER }); + rejects(state, cmd, recipient); + state.event.status = 'cancelled'; + rejects(state, { ...cmd, expectedEventRevision: 2 }, recipient); + }); + test.each([ + ['originalWaiver', { uid: recipient, version: WAIVER, acceptedAtMs: now }], + ['originalWaiver', { uid: owner, version: WAIVER, acceptedAtMs: now + 1 }], + ['recipientWaiver', { uid: owner, version: WAIVER, acceptedAtMs: now }], + ['offer', { recipientUid: owner, expiresAtMs: now + 30000 }], + ['offer', { recipientUid: recipient, expiresAtMs: now + 60001 }], + ['offer', { recipientUid: recipient, expiresAtMs: now + 30000, email: 'fictional@example.invalid' }], + ])('rejects malformed nested evidence %s', (key, value) => { + const state = fixture(); state.entry[key] = value; rejects(state, command('simulate_refund')); + }); +});