diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index c19473b..b42f333 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -835,6 +835,42 @@ This contract invents no policy and duplicates no sibling. It owns the *member-f The module is imported by no runtime or Functions index and makes nothing officer- or member-observable. It requires only `node:util` and `./membershipAuthority` (itself `node:util`-only), reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, and changes no current profile/role/claim. Source tests and a merge are not Firebase deployment or live behavior proof. +### 8.0i Provider-link lifecycle reconciliation — SOURCE ONLY, UNUSED + +MEMBERS-IDENTITY-001F [#445](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/445) defines one unused, pure, versioned snapshot contract and optimistic-concurrency reducer for the provider-neutral account link in §8.0e. It composes §8.0e's reviewed classifier for consent gating, collision, and desired-versus-observed drift instead of reproducing those decisions. The caller supplies the effective consent disposition already derived by §8.0d: only `active` maps to granted consent, `withdrawn` maps to withdrawn consent, and `reaffirmation_required` or `not_consented` maps to unknown consent. Changing that disposition does not silently request a provider action. + +```mermaid +flowchart LR + I["Current lifecycle snapshot + exact command"] --> V{"Current revision and stable command?"} + V -- "Malformed, stale, or conflicting" --> X["One fixed error; no input echo"] + V -- "Exact latest retry" --> R["Same frozen snapshot"] + V -- "Valid new command" --> C{"Command type?"} + C -- "Consent or desired state" --> D["Update safe local intent"] + C -- "Reconciliation evidence" --> O{"Next ordered outcome?"} + C -- "Replace provider account" --> A{"Desired and observed unlinked?"} + O -- "Success" --> S["Advance the known observation"] + O -- "Definitive failure" --> F["Keep the last known observation"] + O -- "Outcome unknown" --> U["Keep desired state; mark observation unknown"] + A -- "Only confirmed unlinked" --> N["Reset observation for the new opaque account"] + A -- "Linked or unknown" --> X + D --> P["§8.0e classifies consent, collision, and drift"] + S --> P + F --> P + U --> P + N --> P + R --> Z["grantsAuthority: false"] + P --> Z + X --> Z +``` + +Text alternative: a well-formed current command either returns the same frozen snapshot for an exact latest retry or advances one revision; ordered success records a known observation, definitive failure preserves the last known observation, an unknown outcome invalidates the observation without changing intent, and a provider account can change only after confirmed unlink, while every result grants no authority. + +The exact snapshot contains bounded typed membership and provider-account references, one existing provider enum, the effective consent disposition, desired and observed link state, optional collision evidence, a safe-integer revision, the last reconciliation sequence/outcome/typed attempt reference/fixed error code, and the latest stable command ID, payload hash, and expected revision. Creation requests `unlinked` but records provider observation as `unknown`; source never fabricates an external fact. Commands can change consent, request link or unlink, record the exact next reconciliation result, or replace the account after desired and observed state are both unlinked. A reconciliation command pins the current provider-account reference, desired state, record revision, and next per-account sequence, so a delayed link result cannot satisfy a later unlink or relink. Reversing an unresolved link or unlink intent invalidates the prior observation, preventing an in-flight action from making the record look aligned or replaceable. Success must report the requested known state; a foreign bound-membership reference remains collision evidence for §8.0e. Definitive failure keeps the prior observation, while outcome-unknown clears it. Account replacement resets observation and reconciliation to unknown/not-attempted for the new reference. + +Inputs and every nested value are exact plain data objects: missing, extra, symbol, non-enumerable, inherited, accessor, proxy, malformed, out-of-range, stale, skipped, and conflicting shapes fail through one fixed non-echoing error. Purpose-specific prefixes reject raw phone, token, error, URL, and name values from reference fields; a payload hash must be the exact lowercase SHA-256 digest that the reducer recomputes over the canonical command type and safe payload fields. Outputs and nested records are frozen, and records and verdicts hard-code `grantsAuthority: false`. Exact latest retry is read-only. Reusing an ID with a changed type or payload produces another digest and fails; a changed expected revision fails the separate revision comparison. Durable all-history replay protection, cross-record uniqueness, storage transactions, and command authorization remain future trusted-runtime work. + +This contract does not capture consent events or choose a policy-version order, retention rule, deletion service level, provider behavior, relink policy, or officer approval. It makes no provider call and adds no persistence, migration, Firestore schema or Rules, Auth claim, endpoint, route, interface, package, workflow, or deployment. It is imported by no runtime or Functions index, requires only deterministic `node:util`/`node:crypto` operations and the pure §8.0e classifier, and reads no clock, environment, randomness, network, service SDK, or logger. Parent [#81](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/81) remains open for those boundaries. Source changed, tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed, outside provider configured, production data migrated, and production behavior verified are separate states; this source contract proves only the first two before merge. + ### 8.1 Paid race registration PAY-001B1 [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219) adds only the browser projection and first two server validation steps below. The website sends the active field set and omits volunteer tier. The callable preserves the opaque event ID, accepts an exact bounded envelope before Firestore, matches answers against the admitted selected server fields, and encodes callback values. It does not add the target request ID, snapshot, transaction, reservation, idempotent Session saga, safe confirmation capability, deployment, or live proof. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 18ea2ca..a17288a 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -933,6 +933,73 @@ Officer review steps after the source merge: **Escalation:** membership lead plus treasurer and privacy/security owner. Add the platform owner for source/deployment evidence and use the private incident path if real data or unintended access is involved. +## Provider-link lifecycle reconciliation — SOURCE ONLY, UNUSED + +**Status: NOT AVAILABLE YET** + +**Purpose:** define how a future provider-account record can distinguish a requested link or unlink from what the provider has confirmed, without treating email, Google, WhatsApp, Strava, or that record as proof of membership, discounts, payment, a website role, or officer access. + +**Approver:** membership lead plus privacy/security and platform owners. + +**Prerequisites:** issue [#445](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/445), its reviewed pull request, and its exact merge commit are required for source review. The pure classifier from [#367](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/367) and current-consent contract from [#370](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/370) are its source dependencies. Use only made-up references that contain no member or provider information. Approved data purpose and retention under #110, legacy migration under #113, authorization, cross-account uniqueness, storage, provider wiring, protected deployment, readback, and live verification remain future prerequisites. + +```mermaid +flowchart TD + Start["Made-up current snapshot + command"] --> Check{"Exact and current?"} + Check -- "No" --> Stop["Fixed rejection; no private detail"] + Check -- "Exact latest retry" --> Same["Same frozen snapshot"] + Check -- "Valid new command" --> Kind{"Command type?"} + Kind -- "Consent or requested state" --> Local["Update safe local intent"] + Kind -- "Reconciliation evidence" --> Result{"Provider result"} + Kind -- "Change provider account" --> Replace{"Requested and confirmed unlinked?"} + Result -- "Success" --> Known["Record the requested known state"] + Result -- "Definitive failure" --> Keep["Keep the last known state"] + Result -- "Outcome unknown" --> Unknown["Keep the request; mark provider state unknown"] + Replace -- "Confirmed unlinked" --> Reset["Start the new account with unknown provider state"] + Replace -- "Linked or unknown" --> Stop + Same --> NoAuthority["Never grants authority"] + Local --> NoAuthority + Known --> NoAuthority + Keep --> NoAuthority + Unknown --> NoAuthority + Reset --> NoAuthority + Stop --> NoAuthority +``` + +Text alternative: the future source rejects malformed or stale input, treats an exact latest retry as read-only, handles local intent, provider evidence, and account replacement as separate commands, preserves uncertainty instead of guessing success, permits replacement only after confirmed unlink, and never grants membership or any other authority. + +Backup-officer source-review steps: + +1. Keep this procedure marked **NOT AVAILABLE YET**. +2. Obtain the exact #445 issue, reviewed pull request, merge commit, and synthetic test report. +3. Confirm the report uses only made-up references and labels that contain no name, email, phone, provider identifier, URL, error, or token. +4. Confirm the made-up test matrix applies the same provider-neutral contract to email/password, Google, WhatsApp, and Strava values; no live provider uses it yet. +5. Confirm a new snapshot requests unlinked state but marks the provider observation unknown. +6. Confirm the existing classifier blocks link action unless the current consent result is active; ordered provider evidence may still be recorded without granting authority. +7. Confirm a consent change does not silently make a provider call or create an unlink request. +8. Confirm an exact latest command retry returns the same frozen record. The source recomputes a SHA-256 fingerprint, which is a fixed-length one-way summary of the command type and safe payload fields; it checks the expected record version separately. +9. Confirm a changed command with the same ID, stale version, skipped result number, wrong account, reversed in-flight request, or delayed link/unlink result is rejected or marked unknown with one fixed safe boundary. +10. Confirm success records only the requested known state. +11. Confirm a definitive failure keeps the last known state and stores only a fixed error code. +12. Confirm an unknown outcome keeps the requested state but marks provider observation unknown. +13. Confirm a different provider account is accepted only after unlink is both requested and confirmed. +14. Confirm the replacement starts with unknown provider state and cannot accept a delayed result for the old account. +15. Confirm collision, consent, and drift decisions come from the already reviewed #367 classifier. +16. Confirm every record and verdict says it grants no authority. +17. Confirm the module has no runtime import, provider call, database action, claim or role change, route, interface, migration, deployment, log, or production record. + +**Expected result:** the unused synthetic contract can represent ordered link, unlink, relink, failure, uncertainty, collision, and safe account replacement without granting authority. Current signup, membership, discounts, email/password, Google, WhatsApp, Strava, officer screens, provider accounts, and website behavior remain unchanged. + +**Stop conditions:** any real member or provider data; a raw provider error, response, URL, token, email, phone, name, or screenshot; a production inspection; a manual database or provider action; a request to choose consent, retention, relink, or migration policy; missing prerequisite evidence; or a claim that source, tests, a pull request, merge, or green workflow makes this live. + +**Success proof:** exact #445 issue, pull request, reviewed merge commit, focused and full test/lint reports, and the source-isolation scan. Record website publication, `runmprc.com` verification, Firebase deployment, outside-provider configuration, production-data changes, migration, and live behavior separately as **not performed** for this source-only issue. + +**Undo:** before runtime adoption, use one reviewed revert or safe roll-forward for the two unused module/test files and these named documentation sections. No production record repair exists. Any future runtime child must provide its own data-safe rollback. + +**Escalation:** membership lead plus privacy/security and platform owners. Use the private incident route if real data, unintended authority, or a provider action appears. + +No system-topology map changes are required because this unused contract changes no data movement, permissions, account ownership, or deployment topology. + ## My Account membership truth — SOURCE ONLY, NOT LIVE **Status: NOT AVAILABLE YET** diff --git a/functions/membershipProviderLinkLifecycle.js b/functions/membershipProviderLinkLifecycle.js new file mode 100644 index 0000000..141112a --- /dev/null +++ b/functions/membershipProviderLinkLifecycle.js @@ -0,0 +1,650 @@ +'use strict'; + +const { types: { isProxy } } = require('node:util'); +const { createHash } = require('node:crypto'); +const { + providerLinkSchemaVersion, + MEMBERSHIP_PROVIDER_LINK_ENUMS, + classifyProviderLinkReconciliation, +} = require('./membershipProviderLink'); + +const providerLinkLifecycleSchemaVersion = 1; +const PROVIDER_LINK_LIFECYCLE_ERROR_MESSAGE = 'Membership provider link lifecycle input is invalid.'; +const MEMBERSHIP_REFERENCE_PATTERN = /^mbr_[A-Za-z0-9][A-Za-z0-9._:-]{0,123}$/; +const PROVIDER_ACCOUNT_REFERENCE_PATTERN = /^provider\.[A-Za-z0-9][A-Za-z0-9._:-]{0,118}$/; +const COMMAND_ID_PATTERN = /^cmd_[A-Za-z0-9][A-Za-z0-9._:-]{0,123}$/; +const ATTEMPT_REFERENCE_PATTERN = /^attempt\.[A-Za-z0-9][A-Za-z0-9._:-]{0,118}$/; +const PAYLOAD_HASH_PATTERN = /^[a-f0-9]{64}$/; + +const CREATE_FIELDS = Object.freeze([ + 'providerLinkLifecycleSchemaVersion', + 'provider', + 'membershipId', + 'providerAccountRef', + 'effectiveConsentDisposition', + 'commandId', + 'commandPayloadHash', +]); + +const RECORD_FIELDS = Object.freeze([ + 'providerLinkLifecycleSchemaVersion', + 'provider', + 'membershipId', + 'providerAccountRef', + 'effectiveConsentDisposition', + 'desiredState', + 'observedState', + 'boundMembershipId', + 'revision', + 'lastReconciliation', + 'lastCommand', + 'grantsAuthority', +]); + +const RECONCILIATION_FIELDS = Object.freeze([ + 'sequence', + 'outcome', + 'attemptRef', + 'errorCode', +]); + +const LAST_COMMAND_FIELDS = Object.freeze([ + 'commandId', + 'commandPayloadHash', + 'expectedRevision', +]); + +const SET_CONSENT_FIELDS = Object.freeze([ + 'providerLinkLifecycleSchemaVersion', + 'commandType', + 'commandId', + 'commandPayloadHash', + 'expectedRevision', + 'effectiveConsentDisposition', +]); + +const REQUEST_STATE_FIELDS = Object.freeze([ + 'providerLinkLifecycleSchemaVersion', + 'commandType', + 'commandId', + 'commandPayloadHash', + 'expectedRevision', + 'desiredState', +]); + +const REPLACE_ACCOUNT_FIELDS = Object.freeze([ + 'providerLinkLifecycleSchemaVersion', + 'commandType', + 'commandId', + 'commandPayloadHash', + 'expectedRevision', + 'providerAccountRef', +]); + +const RECORD_RECONCILIATION_FIELDS = Object.freeze([ + 'providerLinkLifecycleSchemaVersion', + 'commandType', + 'commandId', + 'commandPayloadHash', + 'expectedRevision', + 'providerAccountRef', + 'reconciledDesiredState', + 'reconciliationSequence', + 'outcome', + 'attemptRef', + 'observedState', + 'boundMembershipId', + 'errorCode', +]); + +const MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS = Object.freeze({ + provider: MEMBERSHIP_PROVIDER_LINK_ENUMS.provider, + effectiveConsentDisposition: Object.freeze([ + 'active', + 'reaffirmation_required', + 'withdrawn', + 'not_consented', + ]), + desiredState: MEMBERSHIP_PROVIDER_LINK_ENUMS.desiredState, + observedState: MEMBERSHIP_PROVIDER_LINK_ENUMS.observedState, + commandType: Object.freeze([ + 'set_consent', + 'request_state', + 'record_reconciliation', + 'replace_provider_account', + ]), + reconciliationOutcome: Object.freeze([ + 'not_attempted', + 'succeeded', + 'definitive_failure', + 'outcome_unknown', + ]), + reconciliationErrorCode: Object.freeze([ + 'none', + 'provider_definitive_failure', + 'provider_outcome_unknown', + ]), +}); + +const PROVIDERS = new Set(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.provider); +const CONSENT_DISPOSITIONS = new Set( + MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.effectiveConsentDisposition, +); +const DESIRED_STATES = new Set(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.desiredState); +const OBSERVED_STATES = new Set(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.observedState); +const RECONCILIATION_OUTCOMES = new Set( + MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.reconciliationOutcome, +); +const RECONCILIATION_ERROR_CODES = new Set( + MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.reconciliationErrorCode, +); + +const CONSENT_FOR_RECONCILIATION = Object.freeze({ + active: 'granted', + reaffirmation_required: 'unknown', + withdrawn: 'withdrawn', + not_consented: 'unknown', +}); + +class MembershipProviderLinkLifecycleError extends Error { + constructor() { + super(PROVIDER_LINK_LIFECYCLE_ERROR_MESSAGE); + Object.defineProperty(this, 'name', { + value: 'MembershipProviderLinkLifecycleError', + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'message', { + value: PROVIDER_LINK_LIFECYCLE_ERROR_MESSAGE, + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'code', { + value: 'invalid_membership_provider_link_lifecycle', + enumerable: false, + writable: false, + configurable: false, + }); + Error.captureStackTrace?.(this, MembershipProviderLinkLifecycleError); + Object.freeze(this); + } +} + +function fail() { + throw new MembershipProviderLinkLifecycleError(); +} + +function readDataObject(value) { + if (value === null || typeof value !== 'object' || isProxy(value)) fail(); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + fail(); + } + if (prototype !== Object.prototype) fail(); + + const data = Object.create(null); + for (const key of keys) { + if (typeof key !== 'string' || Object.prototype.hasOwnProperty.call(data, key)) fail(); + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + fail(); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + fail(); + } + data[key] = descriptor.value; + } + return { data, keys }; +} + +function readExactObject(value, expectedFields) { + const { data, keys } = readDataObject(value); + if (keys.length !== expectedFields.length) fail(); + const keySet = new Set(keys); + if (expectedFields.some((field) => !keySet.has(field))) fail(); + return data; +} + +function requireVersion(value) { + if (value !== providerLinkLifecycleSchemaVersion) fail(); +} + +function requirePattern(value, pattern) { + if (typeof value !== 'string' || !pattern.test(value)) fail(); + return value; +} + +function requireMembershipReference(value) { + return requirePattern(value, MEMBERSHIP_REFERENCE_PATTERN); +} + +function requireProviderAccountReference(value) { + return requirePattern(value, PROVIDER_ACCOUNT_REFERENCE_PATTERN); +} + +function requireCommandId(value) { + return requirePattern(value, COMMAND_ID_PATTERN); +} + +function requireAttemptReference(value) { + return requirePattern(value, ATTEMPT_REFERENCE_PATTERN); +} + +function requirePayloadHash(value) { + return requirePattern(value, PAYLOAD_HASH_PATTERN); +} + +function requireSafeInteger(value, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum) fail(); + return value; +} + +function requireEnum(value, allowed) { + if (!allowed.has(value)) fail(); + return value; +} + +function payloadHash(values) { + return createHash('sha256').update(JSON.stringify(values), 'utf8').digest('hex'); +} + +function createPayloadHash(data) { + return payloadHash([ + providerLinkLifecycleSchemaVersion, + 'create_provider_link', + data.provider, + data.membershipId, + data.providerAccountRef, + data.effectiveConsentDisposition, + ]); +} + +function commandPayloadHash(command) { + if (command.commandType === 'set_consent') { + return payloadHash([ + providerLinkLifecycleSchemaVersion, + command.commandType, + command.effectiveConsentDisposition, + ]); + } + if (command.commandType === 'request_state') { + return payloadHash([ + providerLinkLifecycleSchemaVersion, + command.commandType, + command.desiredState, + ]); + } + if (command.commandType === 'replace_provider_account') { + return payloadHash([ + providerLinkLifecycleSchemaVersion, + command.commandType, + command.providerAccountRef, + ]); + } + return payloadHash([ + providerLinkLifecycleSchemaVersion, + command.commandType, + command.providerAccountRef, + command.reconciledDesiredState, + command.reconciliationSequence, + command.outcome, + command.attemptRef, + command.observedState, + command.boundMembershipId, + command.errorCode, + ]); +} + +function readLastReconciliation(value) { + const reconciliation = readExactObject(value, RECONCILIATION_FIELDS); + requireSafeInteger(reconciliation.sequence); + requireEnum(reconciliation.outcome, RECONCILIATION_OUTCOMES); + requireEnum(reconciliation.errorCode, RECONCILIATION_ERROR_CODES); + + if (reconciliation.outcome === 'not_attempted') { + if (reconciliation.sequence !== 0 + || reconciliation.attemptRef !== null + || reconciliation.errorCode !== 'none') { + fail(); + } + return reconciliation; + } + + if (reconciliation.sequence < 1) fail(); + requireAttemptReference(reconciliation.attemptRef); + if (reconciliation.outcome === 'succeeded') { + if (reconciliation.errorCode !== 'none') fail(); + } else if (reconciliation.outcome === 'definitive_failure') { + if (reconciliation.errorCode !== 'provider_definitive_failure') fail(); + } else if (reconciliation.errorCode !== 'provider_outcome_unknown') { + fail(); + } + return reconciliation; +} + +function readLastCommand(value) { + const command = readExactObject(value, LAST_COMMAND_FIELDS); + requireCommandId(command.commandId); + requirePayloadHash(command.commandPayloadHash); + requireSafeInteger(command.expectedRevision); + return command; +} + +function readRecord(value) { + const record = readExactObject(value, RECORD_FIELDS); + requireVersion(record.providerLinkLifecycleSchemaVersion); + requireEnum(record.provider, PROVIDERS); + requireMembershipReference(record.membershipId); + requireProviderAccountReference(record.providerAccountRef); + requireEnum(record.effectiveConsentDisposition, CONSENT_DISPOSITIONS); + requireEnum(record.desiredState, DESIRED_STATES); + requireEnum(record.observedState, OBSERVED_STATES); + if (record.boundMembershipId !== null) { + requireMembershipReference(record.boundMembershipId); + } + requireSafeInteger(record.revision, 1); + const lastReconciliation = readLastReconciliation(record.lastReconciliation); + const lastCommand = readLastCommand(record.lastCommand); + + if (record.grantsAuthority !== false + || lastCommand.expectedRevision !== record.revision - 1 + || lastReconciliation.sequence > record.revision - 1) { + fail(); + } + if (record.revision === 1 + && (record.desiredState !== 'unlinked' + || record.observedState !== 'unknown' + || record.boundMembershipId !== null + || lastReconciliation.outcome !== 'not_attempted' + || lastCommand.commandPayloadHash !== createPayloadHash(record))) { + fail(); + } + if (record.observedState !== 'linked' && record.boundMembershipId !== null) fail(); + if (lastReconciliation.outcome === 'not_attempted' + && (record.observedState !== 'unknown' || record.boundMembershipId !== null)) { + fail(); + } + if (lastReconciliation.outcome === 'outcome_unknown' + && (record.observedState !== 'unknown' || record.boundMembershipId !== null)) { + fail(); + } + if (lastReconciliation.outcome === 'succeeded' + && record.observedState === 'unknown' + && record.revision < lastReconciliation.sequence + 3) { + fail(); + } + + return { + provider: record.provider, + membershipId: record.membershipId, + providerAccountRef: record.providerAccountRef, + effectiveConsentDisposition: record.effectiveConsentDisposition, + desiredState: record.desiredState, + observedState: record.observedState, + boundMembershipId: record.boundMembershipId, + revision: record.revision, + lastReconciliation, + lastCommand, + }; +} + +function freezeRecord(record) { + const lastReconciliation = Object.freeze({ + sequence: record.lastReconciliation.sequence, + outcome: record.lastReconciliation.outcome, + attemptRef: record.lastReconciliation.attemptRef, + errorCode: record.lastReconciliation.errorCode, + }); + const lastCommand = Object.freeze({ + commandId: record.lastCommand.commandId, + commandPayloadHash: record.lastCommand.commandPayloadHash, + expectedRevision: record.lastCommand.expectedRevision, + }); + return Object.freeze({ + providerLinkLifecycleSchemaVersion, + provider: record.provider, + membershipId: record.membershipId, + providerAccountRef: record.providerAccountRef, + effectiveConsentDisposition: record.effectiveConsentDisposition, + desiredState: record.desiredState, + observedState: record.observedState, + boundMembershipId: record.boundMembershipId, + revision: record.revision, + lastReconciliation, + lastCommand, + grantsAuthority: false, + }); +} + +function canonicalRecord(value, record) { + if (Object.isFrozen(value) + && Object.isFrozen(value.lastReconciliation) + && Object.isFrozen(value.lastCommand)) { + return value; + } + return freezeRecord(record); +} + +function createProviderLinkLifecycle(input) { + const data = readExactObject(input, CREATE_FIELDS); + requireVersion(data.providerLinkLifecycleSchemaVersion); + requireEnum(data.provider, PROVIDERS); + requireMembershipReference(data.membershipId); + requireProviderAccountReference(data.providerAccountRef); + requireEnum(data.effectiveConsentDisposition, CONSENT_DISPOSITIONS); + requireCommandId(data.commandId); + requirePayloadHash(data.commandPayloadHash); + if (data.commandPayloadHash !== createPayloadHash(data)) fail(); + + return freezeRecord({ + provider: data.provider, + membershipId: data.membershipId, + providerAccountRef: data.providerAccountRef, + effectiveConsentDisposition: data.effectiveConsentDisposition, + desiredState: 'unlinked', + observedState: 'unknown', + boundMembershipId: null, + revision: 1, + lastReconciliation: { + sequence: 0, + outcome: 'not_attempted', + attemptRef: null, + errorCode: 'none', + }, + lastCommand: { + commandId: data.commandId, + commandPayloadHash: data.commandPayloadHash, + expectedRevision: 0, + }, + }); +} + +function readCommand(input) { + const inspected = readDataObject(input); + const commandType = inspected.data.commandType; + let expectedFields; + if (commandType === 'set_consent') { + expectedFields = SET_CONSENT_FIELDS; + } else if (commandType === 'request_state') { + expectedFields = REQUEST_STATE_FIELDS; + } else if (commandType === 'record_reconciliation') { + expectedFields = RECORD_RECONCILIATION_FIELDS; + } else if (commandType === 'replace_provider_account') { + expectedFields = REPLACE_ACCOUNT_FIELDS; + } else { + fail(); + } + + if (inspected.keys.length !== expectedFields.length) fail(); + const keySet = new Set(inspected.keys); + if (expectedFields.some((field) => !keySet.has(field))) fail(); + + const command = inspected.data; + requireVersion(command.providerLinkLifecycleSchemaVersion); + requireCommandId(command.commandId); + requirePayloadHash(command.commandPayloadHash); + requireSafeInteger(command.expectedRevision, 1); + + if (commandType === 'set_consent') { + requireEnum(command.effectiveConsentDisposition, CONSENT_DISPOSITIONS); + } else if (commandType === 'request_state') { + requireEnum(command.desiredState, DESIRED_STATES); + } else if (commandType === 'replace_provider_account') { + requireProviderAccountReference(command.providerAccountRef); + } else { + requireProviderAccountReference(command.providerAccountRef); + requireEnum(command.reconciledDesiredState, DESIRED_STATES); + requireSafeInteger(command.reconciliationSequence, 1); + requireEnum(command.outcome, RECONCILIATION_OUTCOMES); + if (command.outcome === 'not_attempted') fail(); + requireAttemptReference(command.attemptRef); + requireEnum(command.errorCode, RECONCILIATION_ERROR_CODES); + + if (command.outcome === 'succeeded') { + requireEnum(command.observedState, OBSERVED_STATES); + if (command.observedState === 'unknown' || command.errorCode !== 'none') fail(); + if (command.observedState === 'unlinked' && command.boundMembershipId !== null) fail(); + if (command.observedState === 'linked' && command.boundMembershipId !== null) { + requireMembershipReference(command.boundMembershipId); + } + } else { + if (command.observedState !== null || command.boundMembershipId !== null) fail(); + const expectedError = command.outcome === 'definitive_failure' + ? 'provider_definitive_failure' + : 'provider_outcome_unknown'; + if (command.errorCode !== expectedError) fail(); + } + } + if (command.commandPayloadHash !== commandPayloadHash(command)) fail(); + return command; +} + +function applyProviderLinkLifecycleCommand(recordInput, commandInput) { + const record = readRecord(recordInput); + const command = readCommand(commandInput); + const current = canonicalRecord(recordInput, record); + + if (command.commandId === record.lastCommand.commandId) { + if (command.commandPayloadHash !== record.lastCommand.commandPayloadHash + || command.expectedRevision !== record.lastCommand.expectedRevision) { + fail(); + } + return current; + } + + if (command.expectedRevision !== record.revision + || record.revision === Number.MAX_SAFE_INTEGER) { + fail(); + } + + const next = { + ...record, + revision: record.revision + 1, + lastCommand: { + commandId: command.commandId, + commandPayloadHash: command.commandPayloadHash, + expectedRevision: command.expectedRevision, + }, + }; + + if (command.commandType === 'set_consent') { + if (command.effectiveConsentDisposition === record.effectiveConsentDisposition) fail(); + next.effectiveConsentDisposition = command.effectiveConsentDisposition; + } else if (command.commandType === 'request_state') { + if (command.desiredState === record.desiredState) fail(); + next.desiredState = command.desiredState; + if (record.observedState !== 'unknown' + && command.desiredState === record.observedState) { + next.observedState = 'unknown'; + next.boundMembershipId = null; + } + } else if (command.commandType === 'replace_provider_account') { + if (command.providerAccountRef === record.providerAccountRef + || record.desiredState !== 'unlinked' + || record.observedState !== 'unlinked' + || record.boundMembershipId !== null) { + fail(); + } + next.providerAccountRef = command.providerAccountRef; + next.observedState = 'unknown'; + next.boundMembershipId = null; + next.lastReconciliation = { + sequence: 0, + outcome: 'not_attempted', + attemptRef: null, + errorCode: 'none', + }; + } else { + if (command.providerAccountRef !== record.providerAccountRef + || command.reconciledDesiredState !== record.desiredState + || record.lastReconciliation.sequence === Number.MAX_SAFE_INTEGER + || command.reconciliationSequence !== record.lastReconciliation.sequence + 1) { + fail(); + } + if (command.outcome === 'succeeded' + && command.observedState !== record.desiredState) { + fail(); + } + + next.lastReconciliation = { + sequence: command.reconciliationSequence, + outcome: command.outcome, + attemptRef: command.attemptRef, + errorCode: command.errorCode, + }; + if (command.outcome === 'succeeded') { + next.observedState = command.observedState; + next.boundMembershipId = command.boundMembershipId; + } else if (command.outcome === 'outcome_unknown') { + next.observedState = 'unknown'; + next.boundMembershipId = null; + } + } + + return freezeRecord(next); +} + +function deriveProviderLinkLifecycleVerdict(recordInput) { + const record = readRecord(recordInput); + const result = classifyProviderLinkReconciliation({ + providerLinkSchemaVersion, + provider: record.provider, + membershipId: record.membershipId, + providerAccountRef: record.providerAccountRef, + consent: CONSENT_FOR_RECONCILIATION[record.effectiveConsentDisposition], + desiredState: record.desiredState, + observedState: record.observedState, + boundMembershipId: record.boundMembershipId, + }); + return Object.freeze({ + providerLinkLifecycleSchemaVersion, + disposition: result.disposition, + reason: result.reason, + grantsAuthority: false, + }); +} + +Object.freeze(MembershipProviderLinkLifecycleError.prototype); +Object.freeze(MembershipProviderLinkLifecycleError); + +module.exports = Object.freeze({ + providerLinkLifecycleSchemaVersion, + MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS, + MembershipProviderLinkLifecycleError, + createProviderLinkLifecycle, + applyProviderLinkLifecycleCommand, + deriveProviderLinkLifecycleVerdict, +}); diff --git a/functions/membershipProviderLinkLifecycle.test.js b/functions/membershipProviderLinkLifecycle.test.js new file mode 100644 index 0000000..685e99a --- /dev/null +++ b/functions/membershipProviderLinkLifecycle.test.js @@ -0,0 +1,1068 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { inspect } = require('node:util'); +const { createHash } = require('node:crypto'); + +const { + providerLinkSchemaVersion, + classifyProviderLinkReconciliation, +} = require('./membershipProviderLink'); +const { + providerLinkLifecycleSchemaVersion, + MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS, + MembershipProviderLinkLifecycleError, + createProviderLinkLifecycle, + applyProviderLinkLifecycleCommand, + deriveProviderLinkLifecycleVerdict, +} = require('./membershipProviderLinkLifecycle'); + +const HOSTILE_CANARY = 'private-member@example.test/+12025550445?token=do-not-copy'; +const MEMBERSHIP_ID = 'mbr_test_001'; +const OTHER_MEMBERSHIP_ID = 'mbr_test_other'; +const ACCOUNT_REF = 'provider.account.001'; +const NEW_ACCOUNT_REF = 'provider.account.002'; + +const RECORD_KEYS = [ + 'providerLinkLifecycleSchemaVersion', + 'provider', + 'membershipId', + 'providerAccountRef', + 'effectiveConsentDisposition', + 'desiredState', + 'observedState', + 'boundMembershipId', + 'revision', + 'lastReconciliation', + 'lastCommand', + 'grantsAuthority', +]; + +function digest(values) { + return createHash('sha256').update(JSON.stringify(values), 'utf8').digest('hex'); +} + +function computedCreateHash(input) { + return digest([ + 1, + 'create_provider_link', + input.provider, + input.membershipId, + input.providerAccountRef, + input.effectiveConsentDisposition, + ]); +} + +function computedCommandHash(command) { + if (command.commandType === 'set_consent') { + return digest([1, command.commandType, command.effectiveConsentDisposition]); + } + if (command.commandType === 'request_state') { + return digest([1, command.commandType, command.desiredState]); + } + if (command.commandType === 'replace_provider_account') { + return digest([1, command.commandType, command.providerAccountRef]); + } + return digest([ + 1, + command.commandType, + command.providerAccountRef, + command.reconciledDesiredState, + command.reconciliationSequence, + command.outcome, + command.attemptRef, + command.observedState, + command.boundMembershipId, + command.errorCode, + ]); +} + +function withComputedHash(input, compute) { + if (typeof input.commandPayloadHash === 'string' + && input.commandPayloadHash.startsWith('sha256.')) { + return { ...input, commandPayloadHash: compute(input) }; + } + return input; +} + +function createInput(overrides = {}) { + return withComputedHash({ + providerLinkLifecycleSchemaVersion: 1, + provider: 'google', + membershipId: MEMBERSHIP_ID, + providerAccountRef: ACCOUNT_REF, + effectiveConsentDisposition: 'active', + commandId: 'cmd_create_001', + commandPayloadHash: 'sha256.create.001', + ...overrides, + }, computedCreateHash); +} + +function setConsent(overrides = {}) { + return withComputedHash({ + providerLinkLifecycleSchemaVersion: 1, + commandType: 'set_consent', + commandId: 'cmd_consent_001', + commandPayloadHash: 'sha256.consent.001', + expectedRevision: 1, + effectiveConsentDisposition: 'withdrawn', + ...overrides, + }, computedCommandHash); +} + +function requestState(overrides = {}) { + return withComputedHash({ + providerLinkLifecycleSchemaVersion: 1, + commandType: 'request_state', + commandId: 'cmd_request_001', + commandPayloadHash: 'sha256.request.001', + expectedRevision: 1, + desiredState: 'linked', + ...overrides, + }, computedCommandHash); +} + +function reconciliation(overrides = {}) { + return withComputedHash({ + providerLinkLifecycleSchemaVersion: 1, + commandType: 'record_reconciliation', + commandId: 'cmd_reconcile_001', + commandPayloadHash: 'sha256.reconcile.001', + expectedRevision: 2, + providerAccountRef: ACCOUNT_REF, + reconciledDesiredState: 'linked', + reconciliationSequence: 1, + outcome: 'succeeded', + attemptRef: 'attempt.001', + observedState: 'linked', + boundMembershipId: MEMBERSHIP_ID, + errorCode: 'none', + ...overrides, + }, computedCommandHash); +} + +function replaceAccount(overrides = {}) { + return withComputedHash({ + providerLinkLifecycleSchemaVersion: 1, + commandType: 'replace_provider_account', + commandId: 'cmd_replace_001', + commandPayloadHash: 'sha256.replace.001', + expectedRevision: 2, + providerAccountRef: NEW_ACCOUNT_REF, + ...overrides, + }, computedCommandHash); +} + +function createRecord(overrides = {}) { + return createProviderLinkLifecycle(createInput(overrides)); +} + +function linkedRecord() { + const requested = applyProviderLinkLifecycleCommand(createRecord(), requestState()); + return applyProviderLinkLifecycleCommand(requested, reconciliation()); +} + +function observedUnlinkedRecord() { + return applyProviderLinkLifecycleCommand(createRecord(), reconciliation({ + commandId: 'cmd_observe_unlinked_001', + commandPayloadHash: 'sha256.observe.unlinked.001', + expectedRevision: 1, + reconciledDesiredState: 'unlinked', + observedState: 'unlinked', + boundMembershipId: null, + })); +} + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to throw'); +} + +function expectSafeError(callback, rawValue = HOSTILE_CANARY) { + const error = captureError(callback); + expect(error).toBeInstanceOf(MembershipProviderLinkLifecycleError); + expect(error).toMatchObject({ + name: 'MembershipProviderLinkLifecycleError', + code: 'invalid_membership_provider_link_lifecycle', + message: 'Membership provider link lifecycle input is invalid.', + }); + expect(Object.isFrozen(error)).toBe(true); + const rendered = [ + error.message, + String(error), + JSON.stringify(error), + inspect(error), + error.stack, + ].join('\n'); + if (rawValue) expect(rendered).not.toContain(rawValue); + expect(rendered).not.toContain(HOSTILE_CANARY); + return error; +} + +function expectDeepFrozen(value) { + expect(Object.isFrozen(value)).toBe(true); + if (value && typeof value === 'object') { + for (const child of Object.values(value)) expectDeepFrozen(child); + } +} + +function matrixRecord({ + provider, + effectiveConsentDisposition, + desiredState, + observedState, + boundMembershipId, +}) { + const known = observedState !== 'unknown'; + const revision = known && desiredState !== observedState ? 3 : 2; + return { + providerLinkLifecycleSchemaVersion: 1, + provider, + membershipId: MEMBERSHIP_ID, + providerAccountRef: ACCOUNT_REF, + effectiveConsentDisposition, + desiredState, + observedState, + boundMembershipId, + revision, + lastReconciliation: { + sequence: 1, + outcome: known ? 'succeeded' : 'outcome_unknown', + attemptRef: 'attempt.matrix.001', + errorCode: known ? 'none' : 'provider_outcome_unknown', + }, + lastCommand: { + commandId: 'cmd_matrix_001', + commandPayloadHash: 'a'.repeat(64), + expectedRevision: revision - 1, + }, + grantsAuthority: false, + }; +} + +describe('versioned provider-link lifecycle record', () => { + test.each(['email_password', 'google', 'whatsapp', 'strava'])( + 'creates a frozen, non-authoritative %s record without inventing provider truth', + (provider) => { + const input = createInput({ provider }); + const before = JSON.stringify(input); + const record = createProviderLinkLifecycle(input); + + expect(Object.keys(record)).toEqual(RECORD_KEYS); + expect(record).toEqual({ + providerLinkLifecycleSchemaVersion: 1, + provider, + membershipId: MEMBERSHIP_ID, + providerAccountRef: ACCOUNT_REF, + effectiveConsentDisposition: 'active', + desiredState: 'unlinked', + observedState: 'unknown', + boundMembershipId: null, + revision: 1, + lastReconciliation: { + sequence: 0, + outcome: 'not_attempted', + attemptRef: null, + errorCode: 'none', + }, + lastCommand: { + commandId: 'cmd_create_001', + commandPayloadHash: createInput({ provider }).commandPayloadHash, + expectedRevision: 0, + }, + grantsAuthority: false, + }); + expectDeepFrozen(record); + expect(JSON.stringify(input)).toBe(before); + expect(deriveProviderLinkLifecycleVerdict(record)).toMatchObject({ + disposition: 'observation_pending', + grantsAuthority: false, + }); + }, + ); + + test('exports one frozen versioned surface and exact closed vocabularies', () => { + const api = require('./membershipProviderLinkLifecycle'); + expect(providerLinkLifecycleSchemaVersion).toBe(1); + expect(Object.isFrozen(api)).toBe(true); + expect(Object.isFrozen(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS)).toBe(true); + for (const values of Object.values(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS)) { + expect(Object.isFrozen(values)).toBe(true); + } + expect(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.provider).toEqual([ + 'email_password', 'google', 'whatsapp', 'strava', + ]); + expect(MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.reconciliationErrorCode).toEqual([ + 'none', 'provider_definitive_failure', 'provider_outcome_unknown', + ]); + expect(Object.isFrozen(MembershipProviderLinkLifecycleError)).toBe(true); + expect(Object.isFrozen(MembershipProviderLinkLifecycleError.prototype)).toBe(true); + }); +}); + +describe('composition with the reviewed reconciliation classifier', () => { + test('matches #367 for every provider, consent, desired, observation, and binding shape', () => { + const consentMap = { + active: 'granted', + reaffirmation_required: 'unknown', + withdrawn: 'withdrawn', + not_consented: 'unknown', + }; + let count = 0; + + for (const provider of MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.provider) { + for (const effectiveConsentDisposition of + MEMBERSHIP_PROVIDER_LINK_LIFECYCLE_ENUMS.effectiveConsentDisposition) { + for (const desiredState of ['linked', 'unlinked']) { + for (const observedState of ['linked', 'unlinked', 'unknown']) { + const bounds = observedState === 'linked' + ? [null, MEMBERSHIP_ID, OTHER_MEMBERSHIP_ID] + : [null]; + for (const boundMembershipId of bounds) { + const record = matrixRecord({ + provider, + effectiveConsentDisposition, + desiredState, + observedState, + boundMembershipId, + }); + const expected = classifyProviderLinkReconciliation({ + providerLinkSchemaVersion, + provider, + membershipId: MEMBERSHIP_ID, + providerAccountRef: ACCOUNT_REF, + consent: consentMap[effectiveConsentDisposition], + desiredState, + observedState, + boundMembershipId, + }); + const actual = deriveProviderLinkLifecycleVerdict(record); + + expect(Object.keys(actual)).toEqual([ + 'providerLinkLifecycleSchemaVersion', + 'disposition', + 'reason', + 'grantsAuthority', + ]); + expect(actual).toEqual({ + providerLinkLifecycleSchemaVersion: 1, + disposition: expected.disposition, + reason: expected.reason, + grantsAuthority: false, + }); + expect(Object.isFrozen(actual)).toBe(true); + count += 1; + } + } + } + } + } + expect(count).toBe(160); + }); + + test.each(['reaffirmation_required', 'withdrawn', 'not_consented'])( + 'blocks a link intent with %s consent without changing provider truth', + (effectiveConsentDisposition) => { + const initial = createRecord({ effectiveConsentDisposition }); + const requested = applyProviderLinkLifecycleCommand(initial, requestState()); + expect(requested.desiredState).toBe('linked'); + expect(requested.observedState).toBe('unknown'); + expect(deriveProviderLinkLifecycleVerdict(requested)).toMatchObject({ + disposition: 'blocked', + reason: 'consent_required', + grantsAuthority: false, + }); + }, + ); + + test('consent withdrawal does not invent an unlink command or authority', () => { + const requested = applyProviderLinkLifecycleCommand(createRecord(), requestState()); + const withdrawn = applyProviderLinkLifecycleCommand(requested, setConsent({ + expectedRevision: 2, + })); + expect(withdrawn.desiredState).toBe('linked'); + expect(withdrawn.observedState).toBe('unknown'); + expect(deriveProviderLinkLifecycleVerdict(withdrawn)).toEqual({ + providerLinkLifecycleSchemaVersion: 1, + disposition: 'blocked', + reason: 'consent_required', + grantsAuthority: false, + }); + }); +}); + +describe('ordered link, unlink, relink, and replacement transitions', () => { + test('links, unlinks, and relinks the same account deterministically', () => { + const initial = createRecord(); + const linkRequested = applyProviderLinkLifecycleCommand(initial, requestState()); + expect(linkRequested.revision).toBe(2); + expect(deriveProviderLinkLifecycleVerdict(linkRequested).disposition) + .toBe('observation_pending'); + + const linked = applyProviderLinkLifecycleCommand(linkRequested, reconciliation()); + expect(linked.revision).toBe(3); + expect(linked.lastReconciliation.sequence).toBe(1); + expect(deriveProviderLinkLifecycleVerdict(linked).disposition).toBe('aligned'); + + const unlinkRequested = applyProviderLinkLifecycleCommand(linked, requestState({ + commandId: 'cmd_unlink_001', + commandPayloadHash: 'sha256.unlink.001', + expectedRevision: 3, + desiredState: 'unlinked', + })); + expect(unlinkRequested.revision).toBe(4); + expect(deriveProviderLinkLifecycleVerdict(unlinkRequested).disposition) + .toBe('reconcile_unlink'); + + const unlinked = applyProviderLinkLifecycleCommand(unlinkRequested, reconciliation({ + commandId: 'cmd_reconcile_unlink_001', + commandPayloadHash: 'sha256.reconcile.unlink.001', + expectedRevision: 4, + reconciledDesiredState: 'unlinked', + reconciliationSequence: 2, + attemptRef: 'attempt.002', + observedState: 'unlinked', + boundMembershipId: null, + })); + expect(unlinked.revision).toBe(5); + expect(deriveProviderLinkLifecycleVerdict(unlinked).disposition).toBe('aligned'); + + const relinkRequested = applyProviderLinkLifecycleCommand(unlinked, requestState({ + commandId: 'cmd_relink_001', + commandPayloadHash: 'sha256.relink.001', + expectedRevision: 5, + })); + expect(relinkRequested.revision).toBe(6); + expect(deriveProviderLinkLifecycleVerdict(relinkRequested).disposition) + .toBe('reconcile_link'); + + const relinked = applyProviderLinkLifecycleCommand(relinkRequested, reconciliation({ + commandId: 'cmd_reconcile_relink_001', + commandPayloadHash: 'sha256.reconcile.relink.001', + expectedRevision: 6, + reconciliationSequence: 3, + attemptRef: 'attempt.003', + })); + expect(relinked.revision).toBe(7); + expect(relinked.lastReconciliation.sequence).toBe(3); + expect(deriveProviderLinkLifecycleVerdict(relinked).disposition).toBe('aligned'); + expectDeepFrozen(relinked); + }); + + test('retains a foreign binding as collision evidence without granting authority', () => { + const requested = applyProviderLinkLifecycleCommand(createRecord(), requestState()); + const collision = applyProviderLinkLifecycleCommand(requested, reconciliation({ + boundMembershipId: OTHER_MEMBERSHIP_ID, + })); + expect(collision.boundMembershipId).toBe(OTHER_MEMBERSHIP_ID); + expect(deriveProviderLinkLifecycleVerdict(collision)).toEqual({ + providerLinkLifecycleSchemaVersion: 1, + disposition: 'collision', + reason: 'provider_account_linked_elsewhere', + grantsAuthority: false, + }); + }); + + test('replaces an account only after confirmed unlink and resets provider observation', () => { + const unlinked = observedUnlinkedRecord(); + const replacement = applyProviderLinkLifecycleCommand(unlinked, replaceAccount()); + + expect(replacement).toMatchObject({ + provider: 'google', + membershipId: MEMBERSHIP_ID, + providerAccountRef: NEW_ACCOUNT_REF, + effectiveConsentDisposition: 'active', + desiredState: 'unlinked', + observedState: 'unknown', + boundMembershipId: null, + revision: 3, + lastReconciliation: { + sequence: 0, + outcome: 'not_attempted', + attemptRef: null, + errorCode: 'none', + }, + grantsAuthority: false, + }); + expectDeepFrozen(replacement); + + expectSafeError(() => applyProviderLinkLifecycleCommand(replacement, reconciliation({ + commandId: 'cmd_old_account_result_001', + commandPayloadHash: 'sha256.old.account.result.001', + expectedRevision: 3, + reconciledDesiredState: 'unlinked', + providerAccountRef: ACCOUNT_REF, + observedState: 'unlinked', + boundMembershipId: null, + }))); + }); + + test('rejects replacement while unknown, linked, desired-linked, or unchanged', () => { + expectSafeError(() => applyProviderLinkLifecycleCommand(createRecord(), replaceAccount({ + expectedRevision: 1, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(linkedRecord(), replaceAccount({ + expectedRevision: 3, + }))); + const unlinked = observedUnlinkedRecord(); + const desiredLinked = applyProviderLinkLifecycleCommand(unlinked, requestState({ + expectedRevision: 2, + })); + expectSafeError(() => applyProviderLinkLifecycleCommand(desiredLinked, replaceAccount({ + expectedRevision: 3, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(unlinked, replaceAccount({ + providerAccountRef: ACCOUNT_REF, + }))); + }); + + test('invalidates a confirmed-unlinked observation when unresolved link intent is reversed', () => { + const unlinked = observedUnlinkedRecord(); + const linkRequested = applyProviderLinkLifecycleCommand(unlinked, requestState({ + expectedRevision: 2, + })); + expect(deriveProviderLinkLifecycleVerdict(linkRequested).disposition).toBe('reconcile_link'); + + const cancelled = applyProviderLinkLifecycleCommand(linkRequested, requestState({ + commandId: 'cmd_cancel_link_001', + expectedRevision: 3, + desiredState: 'unlinked', + })); + expect(cancelled.desiredState).toBe('unlinked'); + expect(cancelled.observedState).toBe('unknown'); + expect(cancelled.boundMembershipId).toBeNull(); + expect(deriveProviderLinkLifecycleVerdict(cancelled).disposition) + .toBe('observation_pending'); + expectSafeError(() => applyProviderLinkLifecycleCommand(cancelled, replaceAccount({ + expectedRevision: 4, + }))); + + const reconfirmed = applyProviderLinkLifecycleCommand(cancelled, reconciliation({ + commandId: 'cmd_reconcile_cancelled_link_001', + expectedRevision: 4, + reconciledDesiredState: 'unlinked', + reconciliationSequence: 2, + attemptRef: 'attempt.cancelled.link.002', + observedState: 'unlinked', + boundMembershipId: null, + })); + expect(applyProviderLinkLifecycleCommand(reconfirmed, replaceAccount({ + expectedRevision: 5, + })).providerAccountRef).toBe(NEW_ACCOUNT_REF); + }); + + test('invalidates a confirmed-linked observation when unresolved unlink intent is reversed', () => { + const unlinkRequested = applyProviderLinkLifecycleCommand(linkedRecord(), requestState({ + commandId: 'cmd_unlink_then_reverse_001', + expectedRevision: 3, + desiredState: 'unlinked', + })); + const relinkRequested = applyProviderLinkLifecycleCommand(unlinkRequested, requestState({ + commandId: 'cmd_reverse_unlink_001', + expectedRevision: 4, + desiredState: 'linked', + })); + + expect(relinkRequested.desiredState).toBe('linked'); + expect(relinkRequested.observedState).toBe('unknown'); + expect(relinkRequested.boundMembershipId).toBeNull(); + expect(deriveProviderLinkLifecycleVerdict(relinkRequested).disposition) + .toBe('observation_pending'); + expectSafeError(() => applyProviderLinkLifecycleCommand(relinkRequested, reconciliation({ + commandId: 'cmd_late_unlink_result_001', + expectedRevision: 5, + reconciledDesiredState: 'unlinked', + reconciliationSequence: 2, + attemptRef: 'attempt.late.unlink.002', + observedState: 'unlinked', + boundMembershipId: null, + }))); + }); +}); + +describe('partial and uncertain reconciliation outcomes', () => { + function unlinkRequestedRecord() { + return applyProviderLinkLifecycleCommand(linkedRecord(), requestState({ + commandId: 'cmd_unlink_partial_001', + commandPayloadHash: 'sha256.unlink.partial.001', + expectedRevision: 3, + desiredState: 'unlinked', + })); + } + + test('a definitive failure records a fixed code and preserves known truth', () => { + const requested = unlinkRequestedRecord(); + const beforeObservation = { + observedState: requested.observedState, + boundMembershipId: requested.boundMembershipId, + }; + const failed = applyProviderLinkLifecycleCommand(requested, reconciliation({ + commandId: 'cmd_failure_001', + commandPayloadHash: 'sha256.failure.001', + expectedRevision: 4, + reconciledDesiredState: 'unlinked', + reconciliationSequence: 2, + outcome: 'definitive_failure', + attemptRef: 'attempt.failure.002', + observedState: null, + boundMembershipId: null, + errorCode: 'provider_definitive_failure', + })); + + expect(failed.desiredState).toBe('unlinked'); + expect({ + observedState: failed.observedState, + boundMembershipId: failed.boundMembershipId, + }).toEqual(beforeObservation); + expect(failed.lastReconciliation).toEqual({ + sequence: 2, + outcome: 'definitive_failure', + attemptRef: 'attempt.failure.002', + errorCode: 'provider_definitive_failure', + }); + expect(deriveProviderLinkLifecycleVerdict(failed).disposition).toBe('reconcile_unlink'); + }); + + test('an unknown outcome preserves intent but invalidates provider observation', () => { + const requested = unlinkRequestedRecord(); + const unknown = applyProviderLinkLifecycleCommand(requested, reconciliation({ + commandId: 'cmd_unknown_001', + commandPayloadHash: 'sha256.unknown.001', + expectedRevision: 4, + reconciledDesiredState: 'unlinked', + reconciliationSequence: 2, + outcome: 'outcome_unknown', + attemptRef: 'attempt.unknown.002', + observedState: null, + boundMembershipId: null, + errorCode: 'provider_outcome_unknown', + })); + + expect(unknown.desiredState).toBe('unlinked'); + expect(unknown.observedState).toBe('unknown'); + expect(unknown.boundMembershipId).toBeNull(); + expect(deriveProviderLinkLifecycleVerdict(unknown).disposition) + .toBe('observation_pending'); + expectSafeError(() => applyProviderLinkLifecycleCommand(unknown, replaceAccount({ + expectedRevision: 5, + }))); + + const confirmed = applyProviderLinkLifecycleCommand(unknown, reconciliation({ + commandId: 'cmd_confirm_after_unknown_001', + commandPayloadHash: 'sha256.confirm.after.unknown.001', + expectedRevision: 5, + reconciledDesiredState: 'unlinked', + reconciliationSequence: 3, + attemptRef: 'attempt.confirm.003', + observedState: 'unlinked', + boundMembershipId: null, + })); + expect(confirmed.observedState).toBe('unlinked'); + expect(applyProviderLinkLifecycleCommand(confirmed, replaceAccount({ + expectedRevision: 6, + })).providerAccountRef).toBe(NEW_ACCOUNT_REF); + }); + + test.each([ + ['success with an error', { errorCode: 'provider_definitive_failure' }], + ['success with unknown observation', { observedState: 'unknown' }], + ['unlinked success with a binding', { + reconciledDesiredState: 'unlinked', observedState: 'unlinked', + boundMembershipId: MEMBERSHIP_ID, + }], + ['failure with observation data', { + outcome: 'definitive_failure', observedState: 'linked', + errorCode: 'provider_definitive_failure', + }], + ['failure with the wrong fixed code', { + outcome: 'definitive_failure', observedState: null, + boundMembershipId: null, errorCode: 'provider_outcome_unknown', + }], + ['unknown with the wrong fixed code', { + outcome: 'outcome_unknown', observedState: null, + boundMembershipId: null, errorCode: 'provider_definitive_failure', + }], + ['not attempted as a command outcome', { + outcome: 'not_attempted', observedState: null, boundMembershipId: null, + }], + ])('rejects %s', (_name, overrides) => { + const requested = applyProviderLinkLifecycleCommand(createRecord(), requestState()); + expectSafeError(() => applyProviderLinkLifecycleCommand( + requested, + reconciliation(overrides), + )); + }); +}); + +describe('idempotency, optimistic revision, and ordering', () => { + test('makes an exact latest retry read-only and canonicalizes a mutable clone', () => { + const initial = createRecord(); + const command = requestState(); + const requested = applyProviderLinkLifecycleCommand(initial, command); + const before = JSON.stringify(requested); + + expect(applyProviderLinkLifecycleCommand(requested, command)).toBe(requested); + expect(requested.revision).toBe(2); + expect(JSON.stringify(requested)).toBe(before); + + const mutableClone = JSON.parse(JSON.stringify(requested)); + const retried = applyProviderLinkLifecycleCommand(mutableClone, command); + expect(retried).not.toBe(mutableClone); + expect(retried).toEqual(requested); + expectDeepFrozen(retried); + }); + + test('rejects changed command reuse, stale/future revisions, and no-op commands', () => { + const initial = createRecord(); + const command = requestState(); + const requested = applyProviderLinkLifecycleCommand(initial, command); + + expectSafeError(() => applyProviderLinkLifecycleCommand(requested, requestState({ + commandPayloadHash: '0'.repeat(64), + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(requested, requestState({ + desiredState: 'unlinked', + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(requested, setConsent({ + commandId: command.commandId, + expectedRevision: command.expectedRevision, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(requested, requestState({ + expectedRevision: 2, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(initial, requestState({ + commandId: 'cmd_stale_001', expectedRevision: 0, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(initial, requestState({ + commandId: 'cmd_future_001', expectedRevision: 2, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(initial, requestState({ + commandId: 'cmd_noop_state_001', desiredState: 'unlinked', + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(initial, setConsent({ + commandId: 'cmd_noop_consent_001', + effectiveConsentDisposition: 'active', + }))); + }); + + test('rejects repeated, skipped, stale, wrong-account, and out-of-order results', () => { + const requested = applyProviderLinkLifecycleCommand(createRecord(), requestState()); + const linked = applyProviderLinkLifecycleCommand(requested, reconciliation()); + + expectSafeError(() => applyProviderLinkLifecycleCommand(linked, reconciliation({ + commandId: 'cmd_repeat_sequence_001', + commandPayloadHash: 'sha256.repeat.sequence.001', + expectedRevision: 3, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(linked, reconciliation({ + commandId: 'cmd_skip_sequence_001', + commandPayloadHash: 'sha256.skip.sequence.001', + expectedRevision: 3, + reconciliationSequence: 3, + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(linked, reconciliation({ + commandId: 'cmd_wrong_account_001', + commandPayloadHash: 'sha256.wrong.account.001', + expectedRevision: 3, + reconciliationSequence: 2, + providerAccountRef: NEW_ACCOUNT_REF, + }))); + + const unlinkRequested = applyProviderLinkLifecycleCommand(linked, requestState({ + commandId: 'cmd_unlink_order_001', + commandPayloadHash: 'sha256.unlink.order.001', + expectedRevision: 3, + desiredState: 'unlinked', + })); + expectSafeError(() => applyProviderLinkLifecycleCommand(unlinkRequested, reconciliation({ + commandId: 'cmd_stale_link_result_001', + commandPayloadHash: 'sha256.stale.link.result.001', + expectedRevision: 4, + reconciliationSequence: 2, + reconciledDesiredState: 'linked', + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(unlinkRequested, reconciliation({ + commandId: 'cmd_opposite_success_001', + commandPayloadHash: 'sha256.opposite.success.001', + expectedRevision: 4, + reconciliationSequence: 2, + reconciledDesiredState: 'unlinked', + observedState: 'linked', + }))); + expectSafeError(() => applyProviderLinkLifecycleCommand(unlinkRequested, reconciliation({ + commandId: 'cmd_old_revision_result_001', + commandPayloadHash: 'sha256.old.revision.result.001', + expectedRevision: 3, + reconciliationSequence: 2, + reconciledDesiredState: 'unlinked', + observedState: 'unlinked', + boundMembershipId: null, + }))); + }); + + test('fails before a safe-integer record revision can overflow', () => { + const maxRecord = { + ...createRecord(), + revision: Number.MAX_SAFE_INTEGER, + lastCommand: { + commandId: 'cmd_before_max_001', + commandPayloadHash: 'b'.repeat(64), + expectedRevision: Number.MAX_SAFE_INTEGER - 1, + }, + }; + expectSafeError(() => applyProviderLinkLifecycleCommand(maxRecord, requestState({ + commandId: 'cmd_at_max_001', + commandPayloadHash: 'sha256.at.max.001', + expectedRevision: Number.MAX_SAFE_INTEGER, + }))); + }); +}); + +describe('strict non-identifying boundary', () => { + test.each([undefined, null, true, 1, 'link', [], new Date(0), new Number(1)])( + 'rejects non-plain creation input case %#', + (input) => { + expectSafeError(() => createProviderLinkLifecycle(input)); + }, + ); + + test('rejects missing, extra, symbol, inherited, non-enumerable, accessor, and proxy fields', () => { + for (const field of Object.keys(createInput())) { + const input = createInput(); + delete input[field]; + expectSafeError(() => createProviderLinkLifecycle(input)); + } + expectSafeError(() => createProviderLinkLifecycle({ + ...createInput(), rawProviderError: HOSTILE_CANARY, + })); + const symbolInput = createInput(); + symbolInput[Symbol(HOSTILE_CANARY)] = true; + expectSafeError(() => createProviderLinkLifecycle(symbolInput)); + expectSafeError(() => createProviderLinkLifecycle(Object.assign( + Object.create({ email: HOSTILE_CANARY }), + createInput(), + ))); + + const nonEnumerable = createInput(); + Object.defineProperty(nonEnumerable, 'providerAccountRef', { + value: ACCOUNT_REF, + enumerable: false, + }); + expectSafeError(() => createProviderLinkLifecycle(nonEnumerable)); + + let invoked = false; + const accessor = createInput(); + Object.defineProperty(accessor, 'providerAccountRef', { + enumerable: true, + get() { + invoked = true; + return HOSTILE_CANARY; + }, + }); + expectSafeError(() => createProviderLinkLifecycle(accessor)); + expect(invoked).toBe(false); + expectSafeError(() => createProviderLinkLifecycle(new Proxy(createInput(), {}))); + expectSafeError(() => applyProviderLinkLifecycleCommand( + createRecord(), + new Proxy(requestState(), {}), + )); + }); + + test.each([ + 'private-member@example.test', + '+12025550445', + '12025550445', + 'ya29.a0AfH6SMBprivateToken', + 'PrivateRunner', + 'provider_definitive_failure', + 'https://example.test/account', + 'account/with/path', + 'account with spaces', + 'éxternal', + '', + '.leading', + 'a'.repeat(129), + ])('rejects non-opaque identifier %p', (value) => { + expectSafeError(() => createProviderLinkLifecycle(createInput({ + providerAccountRef: value, + })), value); + expectSafeError(() => createProviderLinkLifecycle(createInput({ + membershipId: value, + })), value); + expectSafeError(() => createProviderLinkLifecycle(createInput({ + commandId: value, + })), value); + expectSafeError(() => createProviderLinkLifecycle(createInput({ + commandPayloadHash: value, + })), value); + }); + + test('rejects a well-shaped but incorrect caller-supplied payload hash', () => { + expectSafeError(() => createProviderLinkLifecycle(createInput({ + commandPayloadHash: '0'.repeat(64), + }))); + const initial = createRecord(); + expectSafeError(() => applyProviderLinkLifecycleCommand(initial, requestState({ + commandPayloadHash: 'f'.repeat(64), + }))); + }); + + test('rejects raw phone, token, and error values as reconciliation attempt references', () => { + const requested = applyProviderLinkLifecycleCommand(createRecord(), requestState()); + for (const attemptRef of [ + '12025550445', + 'ya29.a0AfH6SMBprivateToken', + 'provider_definitive_failure', + ]) { + expectSafeError(() => applyProviderLinkLifecycleCommand( + requested, + reconciliation({ attemptRef }), + ), attemptRef); + } + }); + + test('rejects malformed enums, numbers, outcome fields, and forged snapshots', () => { + expectSafeError(() => createProviderLinkLifecycle(createInput({ + providerLinkLifecycleSchemaVersion: 2, + }))); + expectSafeError(() => createProviderLinkLifecycle(createInput({ provider: 'facebook' }))); + expectSafeError(() => createProviderLinkLifecycle(createInput({ + effectiveConsentDisposition: 'maybe', + }))); + for (const expectedRevision of [-1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + expectSafeError(() => applyProviderLinkLifecycleCommand(createRecord(), requestState({ + expectedRevision, + }))); + } + + const valid = JSON.parse(JSON.stringify(linkedRecord())); + const forgedAuthority = { ...valid, grantsAuthority: true }; + expectSafeError(() => deriveProviderLinkLifecycleVerdict(forgedAuthority)); + const forgedRevision = { + ...valid, + lastCommand: { ...valid.lastCommand, expectedRevision: valid.revision }, + }; + expectSafeError(() => deriveProviderLinkLifecycleVerdict(forgedRevision)); + const forgedUnknownBinding = { + ...createRecord(), + boundMembershipId: OTHER_MEMBERSHIP_ID, + }; + expectSafeError(() => deriveProviderLinkLifecycleVerdict(forgedUnknownBinding)); + const forgedSequence = { + ...valid, + lastReconciliation: { ...valid.lastReconciliation, sequence: valid.revision }, + }; + expectSafeError(() => deriveProviderLinkLifecycleVerdict(forgedSequence)); + const forgedCreation = { + ...createRecord(), + desiredState: 'linked', + }; + expectSafeError(() => deriveProviderLinkLifecycleVerdict(forgedCreation)); + }); + + test('rejects hostile nested record shapes without invoking or echoing them', () => { + const valid = JSON.parse(JSON.stringify(linkedRecord())); + const extra = { + ...valid, + lastReconciliation: { + ...valid.lastReconciliation, + responseBody: HOSTILE_CANARY, + }, + }; + expectSafeError(() => deriveProviderLinkLifecycleVerdict(extra)); + + let invoked = false; + const accessor = { ...valid.lastCommand }; + Object.defineProperty(accessor, 'commandPayloadHash', { + enumerable: true, + get() { + invoked = true; + return HOSTILE_CANARY; + }, + }); + expectSafeError(() => deriveProviderLinkLifecycleVerdict({ + ...valid, + lastCommand: accessor, + })); + expect(invoked).toBe(false); + expectSafeError(() => deriveProviderLinkLifecycleVerdict({ + ...valid, + lastReconciliation: new Proxy(valid.lastReconciliation, {}), + })); + }); + + test('does not mutate caller records or commands on success or failure', () => { + const record = JSON.parse(JSON.stringify(createRecord())); + const command = requestState(); + const before = JSON.stringify({ record, command }); + applyProviderLinkLifecycleCommand(record, command); + expect(JSON.stringify({ record, command })).toBe(before); + + const bad = requestState({ desiredState: HOSTILE_CANARY }); + const badBefore = JSON.stringify({ record, bad }); + expectSafeError(() => applyProviderLinkLifecycleCommand(record, bad)); + expect(JSON.stringify({ record, bad })).toBe(badBefore); + }); + + test('the fixed frozen error carries no enumerable caller data', () => { + const error = new MembershipProviderLinkLifecycleError(); + expect(Object.keys(error)).toEqual([]); + expect(JSON.stringify(error)).toBe('{}'); + expect(String(error)).not.toContain(HOSTILE_CANARY); + }); +}); + +describe('source isolation', () => { + const filename = path.join(__dirname, 'membershipProviderLinkLifecycle.js'); + const source = fs.readFileSync(filename, 'utf8'); + + test('requires only deterministic built-ins and the approved pure local classifier', () => { + const requires = [...source.matchAll(/require\(([^)]+)\)/g)].map((match) => match[1]); + expect(requires).toEqual([ + "'node:util'", + "'node:crypto'", + "'./membershipProviderLink'", + ]); + expect(source).toContain('classifyProviderLinkReconciliation({'); + expect(source).toContain("createHash('sha256')"); + }); + + test('is imported by no runtime or Functions entrypoint', () => { + const imports = fs.readdirSync(__dirname) + .filter((entry) => entry.endsWith('.js')) + .filter((entry) => !entry.endsWith('.test.js')) + .filter((entry) => entry !== 'membershipProviderLinkLifecycle.js') + .filter((entry) => fs.readFileSync(path.join(__dirname, entry), 'utf8') + .includes("require('./membershipProviderLinkLifecycle')")); + expect(imports).toEqual([]); + }); + + test('reads no clock, environment, randomness, network, service SDK, or logger', () => { + for (const forbidden of [ + 'process.env', 'Date.now', 'new Date', 'Math.random', 'randomBytes', + 'console.', 'logger', 'fetch(', 'https:', 'http:', 'firebase', + 'firestore', 'stripe', 'axios', + ]) { + expect(source.toLowerCase()).not.toContain(forbidden.toLowerCase()); + } + }); + + test('models no personal-data, raw-error, secret, role, price, or payment fields', () => { + expect(source).not.toMatch( + /phoneNumber|emailAddress|streetAddress|dateOfBirth|emergencyContact|rawError|responseBody/i, + ); + expect(source).not.toMatch( + /accessToken|refreshToken|clientSecret|apiKey|bearer|passwordHash|roleName|priceCents|paymentId/i, + ); + }); + + test('hard-codes the no-authority invariant in every public result', () => { + expect(source.match(/grantsAuthority: false/g)).toHaveLength(2); + expect(source).not.toContain('grantsAuthority: true'); + }); +});