diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index b42f333..92c1ae9 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -871,6 +871,40 @@ Inputs and every nested value are exact plain data objects: missing, extra, symb 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.0j Provider-neutral consent-decision receipts — SOURCE ONLY, UNUSED + +MEMBERS-IDENTITY-001G [#447](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/447) defines one unused pure seam between the caller-supplied latest-decision evidence in §8.0d and a future trusted persistence layer. It creates a bounded track head, emits one append-oriented technical grant-or-withdrawal receipt for an exact next command, and projects the current head through the real §8.0d classifier. It does not copy that classifier's active, reaffirmation, withdrawal, or no-decision policy. + +```mermaid +flowchart LR + H["Empty or current bounded track head"] --> V{"Valid exact next step?"} + C["Exact grant or withdrawal command"] --> V + V -- "Malformed, stale, skipped, or conflicting" --> X["One fixed error; no input echo"] + V -- "Exact latest retry" --> R["Same canonical frozen receipt and head"] + V -- "Valid next decision" --> N["Emit one frozen technical receipt; advance head by one"] + N --> H2["Current bounded head"] + R --> H2 + Q["Separate projector call + required policy version"] --> D["§8.0d classification"] + H2 --> D + R --> Z["grantsAuthority: false"] + D --> Z + X --> Z +``` + +Text alternative: from an empty or current bounded track head and one exact grant-or-withdrawal command, the unused source either treats the exact latest retry as read-only, emits one new frozen technical receipt and advances the head by exactly one revision, or fails through one fixed non-echoing error. In a separate call, the projector combines the current head with a caller-supplied required policy version and asks §8.0d to classify it; all results grant no authority. This source captures and stores nothing and does not prove informed consent, legal compliance, notice delivery, actor authorization, or provider acceptance. + +The head is bound to one typed track, subject, and scope reference plus one existing provider enum. It contains a safe-integer revision and either no latest receipt at revision zero or exactly one nested latest receipt at a positive revision. It never holds an unbounded receipt array. Each receipt repeats the exact track binding, names only a typed command and receipt reference, binds the prior receipt reference, carries `granted` or `withdrawn` plus one typed policy-version reference, and hard-codes `grantsAuthority: false`. `none` is the absence of a receipt, not a fabricated event. + +Each command carries the exact track binding, fixed `record_consent_decision` type, stable typed command and receipt references, expected revision, expected latest receipt reference, decision, and policy version. An exact retry of the latest command is checked before freshness or revision exhaustion and returns the same canonical frozen head and receipt. A changed latest-command reuse fails. A new command must match the track, current revision, and current latest receipt exactly; the next receipt advances one safe-integer revision and binds that prior receipt. A fresh repeated grant or withdrawal is allowed because this technical contract invents no provider or product no-op policy. Current receipt-reference reuse is rejected; durable uniqueness across receipts no longer visible in the bounded head remains future storage work. + +The projector validates the full head and passes the exact provider, subject, scope, latest decision or `none`, latest policy version or `null`, and caller-supplied required policy version to `classifyConsentState`. Policy versions are compared for equality only by §8.0d. No timestamp chooses ordering, and this contract defines no version precedence, policy text, effective date, notice wording, scope meaning, withdrawal service level, or provider behavior. + +Every public input and nested receipt is an exact plain data object. A proxy, revoked proxy, accessor, inherited field, symbol, non-enumerable field, extra or missing field, foreign prototype, wrong version, unknown enum, unsafe revision, inconsistent nested binding, or malformed purpose-specific reference fails through one fixed error that never echoes the input. Purpose-specific lowercase-hex reference shapes exclude obvious raw contact, URL, provider-error, token, prose, and human-name values; they are structural bounds, not a semantic privacy or authenticity proof. A trusted server must mint every reference and establish every fact. + +This module can reject malformed or internally inconsistent snapshots, but it cannot authenticate a self-consistent rewritten head. It performs no hashing, signing, storage, create-only write, all-history replay check, actor authentication or authorization, notice delivery, provider call, or legal determination. Purpose, access, provider disclosure, retention, deletion, backup, request handling, and public notice remain with owner-blocked #110; provider-specific capture remains #87; legacy disposition remains #113. Any future runtime must preserve receipts separately with trusted authorization and create-only transactional persistence before treating the head as canonical. + +The module is imported by no runtime or Functions index. It requires only `node:util` and the pure §8.0d module, reads no clock or environment, calls no Firebase/provider service, stores and logs nothing, and changes no profile, membership, role, claim, provider account, or officer workflow. Parent [#81](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/81) remains open. Source changed, tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed, outside provider configured, production data migrated, and production behavior verified remain separate states. + ### 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 a17288a..52c1b02 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1000,6 +1000,70 @@ Backup-officer source-review steps: No system-topology map changes are required because this unused contract changes no data movement, permissions, account ownership, or deployment topology. +## Consent-decision receipts — SOURCE ONLY, UNUSED + +**Status: NOT AVAILABLE YET** + +**Purpose:** let a backup officer review whether unused source preserves the order of made-up technical grant/withdrawal decisions without treating a receipt as membership or legal proof. + +**Approver:** membership lead plus privacy/security and platform owners. + +**Prerequisites:** issue [#447](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/447), its reviewed pull request, and exact merge commit are required for source review. The existing consent-state source under [#370](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/370) must remain the only classifier. Use made-up typed references only. Purpose, notice, access, provider disclosure, retention, deletion, backup, request behavior, and public wording remain owner decisions under #110. Provider-specific capture remains #87; legacy disposition remains #113. Actor authorization, trusted capture facts, create-only persistence, durable all-history uniqueness, Firestore Rules, deployment/readback, and live verification are also future work. + +A “grant” or “withdrawal” here is only a bounded caller-supplied technical decision value. The receipt is not evidence that a person was informed, authorized, or legally consented. + +In this section, a **head** is the small caller-supplied summary of the latest technical decision. A **revision** is its whole-number sequence counter. A **typed reference** is a made-up identifier with a fixed format and no person or provider detail. **Frozen** means the test report shows the returned value cannot be changed in place. The source checks structure relative to the head it receives; it cannot prove that a complete, internally consistent head is genuine or came from the club's canonical records. Trusted authorization and create-only storage are required before any future runtime may rely on it. + +```mermaid +flowchart TD + M["Made-up head + made-up grant or withdrawal command"] --> C{"Exact current or latest retry?"} + C -- "No" --> S["Fixed safe stop"] + C -- "Exact latest retry" --> R["Same made-up receipt and head"] + C -- "Valid next decision" --> A["One new technical receipt; head advances once"] + A --> H["Current made-up head"] + R --> H + Q["Separate projector check + made-up required policy version"] --> P["Existing #370 source classifies"] + H --> P + R --> N["Never grants authority"] + P --> N + S --> N +``` + +Text alternative: using made-up values only, the unused source checks one ordered technical grant or withdrawal decision, treats an exact latest retry as read-only, or advances the bounded head once. In a separate check, a made-up required policy version and the current head go to the existing #370 source for classification. Malformed or conflicting input stops safely. The source does not authenticate the supplied head, ask a person for consent, contact a provider, store a durable history, or prove informed consent, authorization, notice delivery, provider acceptance, legal compliance, or live behavior; it never grants membership or access. + +Backup-officer source-review steps: + +1. Keep this procedure marked **NOT AVAILABLE YET**. +2. Obtain the exact #447 issue, reviewed pull request, merge commit, and synthetic test report. +3. Confirm every example uses made-up typed references with no name, email, phone, account, provider identifier, URL, token, error, policy text, or real decision. +4. Confirm the same source accepts the four already reviewed provider categories without adding a provider-specific rule. +5. Confirm an empty made-up head has revision zero and no latest receipt. +6. Confirm the first made-up grant or withdrawal creates one frozen technical receipt at revision one. +7. Confirm each later technical receipt advances exactly one revision and names the prior receipt. +8. Confirm a grant, withdrawal, and later grant remain separate receipts; the earlier made-up values do not change. +9. Confirm an exact retry of the latest command returns the same frozen head and receipt without adding a decision. +10. Confirm changed reuse of the latest command ID, a wrong track, stale or skipped revision, wrong latest receipt, unsafe revision, or malformed value stops with one fixed safe error. +11. Confirm the head contains only the latest receipt, not an ever-growing list. Separately preserved receipts and durable uniqueness remain future storage work. +12. Confirm a reused older command or receipt ID is not detectable from this bounded head and remains the future create-only storage layer's responsibility. +13. Confirm a complete but rewritten head can pass structural checks; the source alone cannot prove authenticity or canonical history. +14. Confirm a separate projector check sends an empty head, current grant, grant under another required policy version, and withdrawal to the real #370 source and gets not consented, active, reaffirmation required, and withdrawn. +15. Confirm policy versions are compared only for equality; no date, ordering, notice, legal rule, or provider rule is inferred. +16. Confirm every head, receipt, and result says it grants no authority. +17. Confirm the source has no actor field, timestamp, notice or policy text, provider call, database action, role or claim change, route, interface, migration, deployment, log, or production record. +18. End the review without describing the receipt as captured consent, an audit trail, provider acceptance, legal proof, a working member feature, or live behavior. + +**Expected result:** relative to one caller-supplied made-up head, the unused synthetic source can advance one technical decision, return an exact latest retry unchanged, stop visible conflicts safely, and separately ask the existing #370 source for the current technical disposition. It does not establish that the supplied head is genuine or complete. Current signup, membership, discounts, email/password, Google, WhatsApp, Strava, officer screens, data stores, provider accounts, and website behavior remain unchanged. + +**Stop conditions:** any real person, decision, member, or provider data; policy or notice text; a timestamp presented as legal evidence; a production inspection; a database or provider action; a request to choose access, retention, deletion, withdrawal, capture, or migration policy; missing prerequisite evidence; or any claim that source, tests, a pull request, merge, or green workflow makes the feature live or compliant. + +**Success proof:** exact #447 issue, pull request, reviewed merge commit, focused and full test/lint reports, and an automated report showing that no live Function entry point imports the new source. 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 persistence or provider 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/membershipConsentReceipt.js b/functions/membershipConsentReceipt.js new file mode 100644 index 0000000..7b16c13 --- /dev/null +++ b/functions/membershipConsentReceipt.js @@ -0,0 +1,423 @@ +'use strict'; + +const { types: { isProxy } } = require('node:util'); +const { + consentStateSchemaVersion, + MEMBERSHIP_CONSENT_STATE_ENUMS, + classifyConsentState, +} = require('./membershipConsentState'); + +const membershipConsentReceiptSchemaVersion = 1; +const MEMBERSHIP_CONSENT_RECEIPT_ERROR_MESSAGE = + 'Membership consent receipt input is invalid.'; + +// Purpose-specific, server-minted reference shapes keep obvious contact, +// provider-error, URL, prose, and human-name values outside this boundary. +// The shapes are structural only; a trusted server must mint every reference. +const TRACK_ID_PATTERN = /^ctrk_[a-f0-9]{32}$/; +const SUBJECT_REFERENCE_PATTERN = /^subject\.[a-f0-9]{32}$/; +const SCOPE_REFERENCE_PATTERN = /^scope\.[a-f0-9]{32}$/; +const COMMAND_ID_PATTERN = /^cmd_[a-f0-9]{32}$/; +const RECEIPT_ID_PATTERN = /^crpt_[a-f0-9]{32}$/; +const POLICY_VERSION_PATTERN = /^policy\.[a-f0-9]{32}$/; + +const CREATE_FIELDS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'trackId', + 'provider', + 'subjectRef', + 'scopeRef', +]); + +const TRACK_FIELDS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'trackId', + 'provider', + 'subjectRef', + 'scopeRef', + 'receiptRevision', + 'latestReceipt', + 'grantsAuthority', +]); + +const COMMAND_FIELDS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'commandType', + 'trackId', + 'provider', + 'subjectRef', + 'scopeRef', + 'commandId', + 'receiptId', + 'expectedRevision', + 'expectedLatestReceiptId', + 'decision', + 'policyVersion', +]); + +const RECEIPT_FIELDS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'trackId', + 'provider', + 'subjectRef', + 'scopeRef', + 'receiptId', + 'priorReceiptId', + 'receiptRevision', + 'commandId', + 'decision', + 'policyVersion', + 'grantsAuthority', +]); + +const POLICY_FIELDS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'requiredPolicyVersion', +]); + +const MEMBERSHIP_CONSENT_RECEIPT_ENUMS = Object.freeze({ + provider: MEMBERSHIP_CONSENT_STATE_ENUMS.provider, + commandType: Object.freeze(['record_consent_decision']), + decision: Object.freeze(['granted', 'withdrawn']), + disposition: Object.freeze(['appended', 'already_applied']), +}); + +const PROVIDERS = new Set(MEMBERSHIP_CONSENT_RECEIPT_ENUMS.provider); +const DECISIONS = new Set(MEMBERSHIP_CONSENT_RECEIPT_ENUMS.decision); + +class MembershipConsentReceiptError extends Error { + constructor() { + super(MEMBERSHIP_CONSENT_RECEIPT_ERROR_MESSAGE); + Object.defineProperty(this, 'name', { + value: 'MembershipConsentReceiptError', + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'message', { + value: MEMBERSHIP_CONSENT_RECEIPT_ERROR_MESSAGE, + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'code', { + value: 'invalid_membership_consent_receipt', + enumerable: false, + writable: false, + configurable: false, + }); + Error.captureStackTrace?.(this, MembershipConsentReceiptError); + Object.freeze(this); + } +} + +function fail() { + throw new MembershipConsentReceiptError(); +} + +function readDataObject(value) { + if (value === null || typeof value !== 'object') fail(); + + let proxy; + let prototype; + let keys; + try { + proxy = isProxy(value); + if (proxy) fail(); + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch (error) { + if (error instanceof MembershipConsentReceiptError) throw error; + 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, keys }; +} + +function hasCanonicalShape(value, keys, expectedFields) { + return Object.isFrozen(value) + && keys.every((key, index) => key === expectedFields[index]); +} + +function requireVersion(value) { + if (value !== membershipConsentReceiptSchemaVersion) fail(); +} + +function requirePattern(value, pattern) { + if (typeof value !== 'string' || !pattern.test(value)) fail(); + return value; +} + +function requireTrackId(value) { + return requirePattern(value, TRACK_ID_PATTERN); +} + +function requireSubjectReference(value) { + return requirePattern(value, SUBJECT_REFERENCE_PATTERN); +} + +function requireScopeReference(value) { + return requirePattern(value, SCOPE_REFERENCE_PATTERN); +} + +function requireCommandId(value) { + return requirePattern(value, COMMAND_ID_PATTERN); +} + +function requireReceiptId(value) { + return requirePattern(value, RECEIPT_ID_PATTERN); +} + +function requirePolicyVersion(value) { + return requirePattern(value, POLICY_VERSION_PATTERN); +} + +function requireSafeRevision(value) { + if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) fail(); + return value; +} + +function requireProvider(value) { + if (!PROVIDERS.has(value)) fail(); + return value; +} + +function requireDecision(value) { + if (!DECISIONS.has(value)) fail(); + return value; +} + +function readBinding(data) { + requireTrackId(data.trackId); + requireProvider(data.provider); + requireSubjectReference(data.subjectRef); + requireScopeReference(data.scopeRef); +} + +function freezeReceipt(data) { + return Object.freeze({ + membershipConsentReceiptSchemaVersion, + trackId: data.trackId, + provider: data.provider, + subjectRef: data.subjectRef, + scopeRef: data.scopeRef, + receiptId: data.receiptId, + priorReceiptId: data.priorReceiptId, + receiptRevision: data.receiptRevision, + commandId: data.commandId, + decision: data.decision, + policyVersion: data.policyVersion, + grantsAuthority: false, + }); +} + +function readReceipt(input) { + const { data, keys } = readExactObject(input, RECEIPT_FIELDS); + requireVersion(data.membershipConsentReceiptSchemaVersion); + readBinding(data); + requireReceiptId(data.receiptId); + requireSafeRevision(data.receiptRevision); + if (data.receiptRevision === 0) fail(); + requireCommandId(data.commandId); + requireDecision(data.decision); + requirePolicyVersion(data.policyVersion); + if (data.grantsAuthority !== false) fail(); + + if (data.receiptRevision === 1) { + if (data.priorReceiptId !== null) fail(); + } else { + requireReceiptId(data.priorReceiptId); + if (data.priorReceiptId === data.receiptId) fail(); + } + + const canonical = hasCanonicalShape(input, keys, RECEIPT_FIELDS) + ? input + : freezeReceipt(data); + return { data, canonical }; +} + +function freezeTrack(data, latestReceipt) { + return Object.freeze({ + membershipConsentReceiptSchemaVersion, + trackId: data.trackId, + provider: data.provider, + subjectRef: data.subjectRef, + scopeRef: data.scopeRef, + receiptRevision: data.receiptRevision, + latestReceipt, + grantsAuthority: false, + }); +} + +function sameBinding(left, right) { + return left.trackId === right.trackId + && left.provider === right.provider + && left.subjectRef === right.subjectRef + && left.scopeRef === right.scopeRef; +} + +function readTrack(input) { + const { data, keys } = readExactObject(input, TRACK_FIELDS); + requireVersion(data.membershipConsentReceiptSchemaVersion); + readBinding(data); + requireSafeRevision(data.receiptRevision); + if (data.grantsAuthority !== false) fail(); + + let receipt = null; + if (data.receiptRevision === 0) { + if (data.latestReceipt !== null) fail(); + } else { + receipt = readReceipt(data.latestReceipt); + if (receipt.data.receiptRevision !== data.receiptRevision) fail(); + if (!sameBinding(data, receipt.data)) fail(); + } + + const canonicalReceipt = receipt ? receipt.canonical : null; + const canonical = hasCanonicalShape(input, keys, TRACK_FIELDS) + && data.latestReceipt === canonicalReceipt + ? input + : freezeTrack(data, canonicalReceipt); + return { data, receipt: receipt?.data ?? null, canonical }; +} + +function readCreateInput(input) { + const { data } = readExactObject(input, CREATE_FIELDS); + requireVersion(data.membershipConsentReceiptSchemaVersion); + readBinding(data); + return data; +} + +function readCommand(input) { + const { data } = readExactObject(input, COMMAND_FIELDS); + requireVersion(data.membershipConsentReceiptSchemaVersion); + if (data.commandType !== 'record_consent_decision') fail(); + readBinding(data); + requireCommandId(data.commandId); + requireReceiptId(data.receiptId); + requireSafeRevision(data.expectedRevision); + if (data.expectedLatestReceiptId !== null) { + requireReceiptId(data.expectedLatestReceiptId); + } + requireDecision(data.decision); + requirePolicyVersion(data.policyVersion); + return data; +} + +function readPolicyInput(input) { + const { data } = readExactObject(input, POLICY_FIELDS); + requireVersion(data.membershipConsentReceiptSchemaVersion); + requirePolicyVersion(data.requiredPolicyVersion); + return data; +} + +function createConsentReceiptTrack(input) { + const data = readCreateInput(input); + return freezeTrack({ ...data, receiptRevision: 0 }, null); +} + +function isExactLatestRetry(track, receipt, command) { + return sameBinding(track, command) + && receipt.receiptId === command.receiptId + && receipt.receiptRevision - 1 === command.expectedRevision + && receipt.priorReceiptId === command.expectedLatestReceiptId + && receipt.decision === command.decision + && receipt.policyVersion === command.policyVersion; +} + +function freezeResult(disposition, track, receipt) { + return Object.freeze({ + membershipConsentReceiptSchemaVersion, + disposition, + track, + receipt, + grantsAuthority: false, + }); +} + +function appendConsentDecisionReceipt(trackInputValue, commandInput) { + const track = readTrack(trackInputValue); + const commandData = readCommand(commandInput); + const latestReceipt = track.receipt; + + // Exact retry is checked before freshness and exhaustion: the original + // command remains read-only even after its revision became current. + if (latestReceipt && latestReceipt.commandId === commandData.commandId) { + if (!isExactLatestRetry(track.data, latestReceipt, commandData)) fail(); + return freezeResult( + 'already_applied', + track.canonical, + track.canonical.latestReceipt, + ); + } + + if (!sameBinding(track.data, commandData)) fail(); + if (commandData.expectedRevision !== track.data.receiptRevision) fail(); + const expectedLatestReceiptId = latestReceipt ? latestReceipt.receiptId : null; + if (commandData.expectedLatestReceiptId !== expectedLatestReceiptId) fail(); + if (latestReceipt && commandData.receiptId === latestReceipt.receiptId) fail(); + if (track.data.receiptRevision === Number.MAX_SAFE_INTEGER) fail(); + + const receipt = freezeReceipt({ + ...commandData, + priorReceiptId: expectedLatestReceiptId, + receiptRevision: track.data.receiptRevision + 1, + }); + const nextTrack = freezeTrack({ + ...track.data, + receiptRevision: receipt.receiptRevision, + }, receipt); + return freezeResult('appended', nextTrack, receipt); +} + +function deriveConsentStateFromReceiptTrack(trackInputValue, policyInput) { + const track = readTrack(trackInputValue); + const policy = readPolicyInput(policyInput); + return classifyConsentState({ + consentStateSchemaVersion, + provider: track.data.provider, + subjectRef: track.data.subjectRef, + scopeRef: track.data.scopeRef, + latestDecision: track.receipt ? track.receipt.decision : 'none', + latestPolicyVersion: track.receipt ? track.receipt.policyVersion : null, + requiredPolicyVersion: policy.requiredPolicyVersion, + }); +} + +Object.freeze(MembershipConsentReceiptError.prototype); +Object.freeze(MembershipConsentReceiptError); + +module.exports = Object.freeze({ + membershipConsentReceiptSchemaVersion, + MEMBERSHIP_CONSENT_RECEIPT_ENUMS, + MembershipConsentReceiptError, + createConsentReceiptTrack, + appendConsentDecisionReceipt, + deriveConsentStateFromReceiptTrack, +}); diff --git a/functions/membershipConsentReceipt.test.js b/functions/membershipConsentReceipt.test.js new file mode 100644 index 0000000..301dbbf --- /dev/null +++ b/functions/membershipConsentReceipt.test.js @@ -0,0 +1,703 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const { + consentStateSchemaVersion, + MEMBERSHIP_CONSENT_STATE_ENUMS, + classifyConsentState, +} = require('./membershipConsentState'); +const { + membershipConsentReceiptSchemaVersion, + MEMBERSHIP_CONSENT_RECEIPT_ENUMS, + MembershipConsentReceiptError, + createConsentReceiptTrack, + appendConsentDecisionReceipt, + deriveConsentStateFromReceiptTrack, +} = require('./membershipConsentReceipt'); + +const HOSTILE_CANARY = 'private-member@example.test/+12025550170?token=do-not-copy'; + +const IDS = Object.freeze({ + track: 'ctrk_11111111111111111111111111111111', + otherTrack: 'ctrk_12121212121212121212121212121212', + subject: 'subject.22222222222222222222222222222222', + otherSubject: 'subject.23232323232323232323232323232323', + scope: 'scope.33333333333333333333333333333333', + otherScope: 'scope.34343434343434343434343434343434', + command1: 'cmd_44444444444444444444444444444444', + command2: 'cmd_55555555555555555555555555555555', + command3: 'cmd_66666666666666666666666666666666', + receipt1: 'crpt_77777777777777777777777777777777', + receipt2: 'crpt_88888888888888888888888888888888', + receipt3: 'crpt_99999999999999999999999999999999', + policy1: 'policy.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + policy2: 'policy.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', +}); + +const TRACK_KEYS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'trackId', + 'provider', + 'subjectRef', + 'scopeRef', + 'receiptRevision', + 'latestReceipt', + 'grantsAuthority', +]); + +const RECEIPT_KEYS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'trackId', + 'provider', + 'subjectRef', + 'scopeRef', + 'receiptId', + 'priorReceiptId', + 'receiptRevision', + 'commandId', + 'decision', + 'policyVersion', + 'grantsAuthority', +]); + +const RESULT_KEYS = Object.freeze([ + 'membershipConsentReceiptSchemaVersion', + 'disposition', + 'track', + 'receipt', + 'grantsAuthority', +]); + +function trackInput(overrides = {}) { + return { + membershipConsentReceiptSchemaVersion: 1, + trackId: IDS.track, + provider: 'google', + subjectRef: IDS.subject, + scopeRef: IDS.scope, + ...overrides, + }; +} + +function command(overrides = {}) { + return { + membershipConsentReceiptSchemaVersion: 1, + commandType: 'record_consent_decision', + trackId: IDS.track, + provider: 'google', + subjectRef: IDS.subject, + scopeRef: IDS.scope, + commandId: IDS.command1, + receiptId: IDS.receipt1, + expectedRevision: 0, + expectedLatestReceiptId: null, + decision: 'granted', + policyVersion: IDS.policy1, + ...overrides, + }; +} + +function nextCommand(overrides = {}) { + return command({ + commandId: IDS.command2, + receiptId: IDS.receipt2, + expectedRevision: 1, + expectedLatestReceiptId: IDS.receipt1, + decision: 'withdrawn', + ...overrides, + }); +} + +function requiredPolicy(overrides = {}) { + return { + membershipConsentReceiptSchemaVersion: 1, + requiredPolicyVersion: IDS.policy1, + ...overrides, + }; +} + +function captureError(callback) { + try { + callback(); + } catch (err) { + return err; + } + return null; +} + +function expectRejected(callback) { + const err = captureError(callback); + expect(err).toBeInstanceOf(MembershipConsentReceiptError); + expect(err.message).toBe('Membership consent receipt input is invalid.'); + expect(err.code).toBe('invalid_membership_consent_receipt'); + expect(Object.keys(err)).toEqual([]); + expect(JSON.stringify(err)).toBe('{}'); + expect(String(err)).not.toContain(HOSTILE_CANARY); + expect(err.stack).not.toContain(HOSTILE_CANARY); + return err; +} + +function expectFrozenRecord(value, keys) { + expect(Object.isFrozen(value)).toBe(true); + expect(Object.keys(value)).toEqual(keys); + expect(value.grantsAuthority).toBe(false); +} + +function appendSequence(provider = 'google') { + const binding = { provider }; + const empty = createConsentReceiptTrack(trackInput(binding)); + const grant = appendConsentDecisionReceipt(empty, command(binding)); + const withdraw = appendConsentDecisionReceipt(grant.track, nextCommand(binding)); + const regrant = appendConsentDecisionReceipt(withdraw.track, command({ + ...binding, + commandId: IDS.command3, + receiptId: IDS.receipt3, + expectedRevision: 2, + expectedLatestReceiptId: IDS.receipt2, + decision: 'granted', + policyVersion: IDS.policy2, + })); + return { empty, grant, withdraw, regrant }; +} + +describe('append-oriented provider-neutral consent receipts', () => { + test.each(MEMBERSHIP_CONSENT_STATE_ENUMS.provider)( + 'creates an empty %s track and appends the first grant', + (provider) => { + const empty = createConsentReceiptTrack(trackInput({ provider })); + expectFrozenRecord(empty, TRACK_KEYS); + expect(empty).toEqual({ + membershipConsentReceiptSchemaVersion: 1, + trackId: IDS.track, + provider, + subjectRef: IDS.subject, + scopeRef: IDS.scope, + receiptRevision: 0, + latestReceipt: null, + grantsAuthority: false, + }); + + const result = appendConsentDecisionReceipt(empty, command({ provider })); + expectFrozenRecord(result, RESULT_KEYS); + expectFrozenRecord(result.track, TRACK_KEYS); + expectFrozenRecord(result.receipt, RECEIPT_KEYS); + expect(result.disposition).toBe('appended'); + expect(result.track.latestReceipt).toBe(result.receipt); + expect(result.receipt).toEqual({ + membershipConsentReceiptSchemaVersion: 1, + trackId: IDS.track, + provider, + subjectRef: IDS.subject, + scopeRef: IDS.scope, + receiptId: IDS.receipt1, + priorReceiptId: null, + receiptRevision: 1, + commandId: IDS.command1, + decision: 'granted', + policyVersion: IDS.policy1, + grantsAuthority: false, + }); + expect(empty.receiptRevision).toBe(0); + expect(empty.latestReceipt).toBeNull(); + }, + ); + + test.each(MEMBERSHIP_CONSENT_STATE_ENUMS.provider)( + 'creates the first %s withdrawal without fabricating a grant', + (provider) => { + const empty = createConsentReceiptTrack(trackInput({ provider })); + const result = appendConsentDecisionReceipt(empty, command({ provider, decision: 'withdrawn' })); + expect(result.receipt.decision).toBe('withdrawn'); + expect(result.receipt.priorReceiptId).toBeNull(); + expect(result.track.latestReceipt).toBe(result.receipt); + expect(result.grantsAuthority).toBe(false); + }, + ); + + test('appends grant, withdrawal, and re-grant without mutating prior values', () => { + const { empty, grant, withdraw, regrant } = appendSequence(); + const before = JSON.stringify({ empty, grant, withdraw }); + + expect(grant.receipt.receiptRevision).toBe(1); + expect(grant.receipt.priorReceiptId).toBeNull(); + expect(withdraw.receipt.receiptRevision).toBe(2); + expect(withdraw.receipt.priorReceiptId).toBe(IDS.receipt1); + expect(regrant.receipt.receiptRevision).toBe(3); + expect(regrant.receipt.priorReceiptId).toBe(IDS.receipt2); + expect(grant.receipt.decision).toBe('granted'); + expect(withdraw.receipt.decision).toBe('withdrawn'); + expect(regrant.receipt.decision).toBe('granted'); + expect(regrant.receipt.policyVersion).toBe(IDS.policy2); + expect(JSON.stringify({ empty, grant, withdraw })).toBe(before); + + for (const value of [empty, grant, grant.track, grant.receipt, + withdraw, withdraw.track, withdraw.receipt, regrant, regrant.track, regrant.receipt]) { + expect(Object.isFrozen(value)).toBe(true); + expect(value.grantsAuthority).toBe(false); + } + }); + + test('repeated decisions are fresh receipts rather than an invented policy no-op', () => { + const empty = createConsentReceiptTrack(trackInput()); + const first = appendConsentDecisionReceipt(empty, command()); + const second = appendConsentDecisionReceipt(first.track, nextCommand({ decision: 'granted' })); + expect(second.disposition).toBe('appended'); + expect(second.receipt.decision).toBe('granted'); + expect(second.receipt.receiptRevision).toBe(2); + }); +}); + +describe('composition with the real #370 consent-state classifier', () => { + test('derives all four effective dispositions from the bounded latest head', () => { + const empty = createConsentReceiptTrack(trackInput()); + const grant = appendConsentDecisionReceipt(empty, command()); + const withdraw = appendConsentDecisionReceipt(grant.track, nextCommand()); + const cases = [ + [empty, requiredPolicy(), 'not_consented', 'no_decision_recorded'], + [grant.track, requiredPolicy(), 'active', 'consent_current'], + [grant.track, requiredPolicy({ requiredPolicyVersion: IDS.policy2 }), + 'reaffirmation_required', 'policy_version_superseded'], + [withdraw.track, requiredPolicy({ requiredPolicyVersion: IDS.policy2 }), + 'withdrawn', 'consent_withdrawn'], + ]; + + for (const [track, policy, disposition, reason] of cases) { + const derived = deriveConsentStateFromReceiptTrack(track, policy); + expect(Object.isFrozen(derived)).toBe(true); + expect(derived).toEqual(expect.objectContaining({ + consentStateSchemaVersion, + disposition, + reason, + grantsAuthority: false, + })); + + const direct = classifyConsentState({ + consentStateSchemaVersion, + provider: track.provider, + subjectRef: track.subjectRef, + scopeRef: track.scopeRef, + latestDecision: track.latestReceipt ? track.latestReceipt.decision : 'none', + latestPolicyVersion: track.latestReceipt ? track.latestReceipt.policyVersion : null, + requiredPolicyVersion: policy.requiredPolicyVersion, + }); + expect(derived).toBe(direct); + } + }); + + test.each(MEMBERSHIP_CONSENT_STATE_ENUMS.provider)( + 'uses one provider-neutral oracle for %s', + (provider) => { + const { grant, withdraw } = appendSequence(provider); + expect(deriveConsentStateFromReceiptTrack(grant.track, requiredPolicy()).disposition) + .toBe('active'); + expect(deriveConsentStateFromReceiptTrack(withdraw.track, requiredPolicy()).disposition) + .toBe('withdrawn'); + }, + ); + + test('policy versions are equality-only in both lexical directions', () => { + const { grant, regrant } = appendSequence(); + expect(deriveConsentStateFromReceiptTrack( + grant.track, + requiredPolicy({ requiredPolicyVersion: IDS.policy2 }), + ).disposition).toBe('reaffirmation_required'); + expect(deriveConsentStateFromReceiptTrack( + regrant.track, + requiredPolicy({ requiredPolicyVersion: IDS.policy1 }), + ).disposition).toBe('reaffirmation_required'); + expect(deriveConsentStateFromReceiptTrack( + regrant.track, + requiredPolicy({ requiredPolicyVersion: IDS.policy2 }), + ).disposition).toBe('active'); + }); +}); + +describe('latest retry, exact track binding, and monotonic sequencing', () => { + test('an exact latest retry is read-only and returns the original canonical identities', () => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + const retry = appendConsentDecisionReceipt(first.track, command()); + + expect(retry.disposition).toBe('already_applied'); + expect(retry.track).toBe(first.track); + expect(retry.receipt).toBe(first.receipt); + expect(retry.track.receiptRevision).toBe(1); + expectFrozenRecord(retry, RESULT_KEYS); + }); + + test('an unfrozen valid clone is canonicalized before retry identity is retained', () => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + const clone = JSON.parse(JSON.stringify(first.track)); + const retry = appendConsentDecisionReceipt(clone, command()); + + expect(retry.disposition).toBe('already_applied'); + expect(retry.track).not.toBe(clone); + expect(retry.track).toEqual(first.track); + expect(retry.track.latestReceipt).toBe(retry.receipt); + expect(Object.isFrozen(retry.track)).toBe(true); + expect(Object.isFrozen(retry.receipt)).toBe(true); + + const secondRetry = appendConsentDecisionReceipt(retry.track, command()); + expect(secondRetry.track).toBe(retry.track); + expect(secondRetry.receipt).toBe(retry.receipt); + }); + + test.each([ + ['track id', { trackId: IDS.otherTrack }], + ['provider', { provider: 'strava' }], + ['subject', { subjectRef: IDS.otherSubject }], + ['scope', { scopeRef: IDS.otherScope }], + ['receipt id', { receiptId: IDS.receipt2 }], + ['expected revision', { expectedRevision: 1 }], + ['negative-zero expected revision', { expectedRevision: -0 }], + ['expected receipt', { expectedLatestReceiptId: IDS.receipt2 }], + ['decision', { decision: 'withdrawn' }], + ['policy version', { policyVersion: IDS.policy2 }], + ])('rejects changed latest-command reuse: %s', (_label, patch) => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + expectRejected(() => appendConsentDecisionReceipt(first.track, command(patch))); + }); + + test.each([ + ['track id', { trackId: IDS.otherTrack }], + ['provider', { provider: 'strava' }], + ['subject', { subjectRef: IDS.otherSubject }], + ['scope', { scopeRef: IDS.otherScope }], + ])('rejects a new command copied onto another track: %s', (_label, patch) => { + const empty = createConsentReceiptTrack(trackInput()); + expectRejected(() => appendConsentDecisionReceipt(empty, command(patch))); + }); + + test.each([ + ['stale', 0], + ['future/skipped', 2], + ['negative', -1], + ['negative zero', -0], + ['fractional', 1.5], + ['NaN', Number.NaN], + ['infinite', Number.POSITIVE_INFINITY], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ])('rejects a %s revision for a new command', (_label, expectedRevision) => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + expectRejected(() => appendConsentDecisionReceipt(first.track, nextCommand({ expectedRevision }))); + }); + + test('rejects a wrong expected latest receipt and current receipt-id reuse', () => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + expectRejected(() => appendConsentDecisionReceipt(first.track, nextCommand({ + expectedLatestReceiptId: null, + }))); + expectRejected(() => appendConsentDecisionReceipt(first.track, nextCommand({ + receiptId: IDS.receipt1, + }))); + }); + + test('allows an exact latest retry at MAX_SAFE_INTEGER but rejects a new append', () => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + const latestReceipt = Object.freeze({ + ...first.receipt, + priorReceiptId: IDS.receipt2, + receiptRevision: Number.MAX_SAFE_INTEGER, + }); + const exhausted = Object.freeze({ + ...first.track, + receiptRevision: Number.MAX_SAFE_INTEGER, + latestReceipt, + }); + const exactLatest = command({ + expectedRevision: Number.MAX_SAFE_INTEGER - 1, + expectedLatestReceiptId: IDS.receipt2, + }); + expect(appendConsentDecisionReceipt(exhausted, exactLatest).disposition) + .toBe('already_applied'); + expectRejected(() => appendConsentDecisionReceipt(exhausted, nextCommand({ + expectedRevision: Number.MAX_SAFE_INTEGER, + }))); + }); + + test('allows the exact final append from MAX_SAFE_INTEGER minus one', () => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + const latestReceipt = Object.freeze({ + ...first.receipt, + priorReceiptId: IDS.receipt2, + receiptRevision: Number.MAX_SAFE_INTEGER - 1, + }); + const penultimate = Object.freeze({ + ...first.track, + receiptRevision: Number.MAX_SAFE_INTEGER - 1, + latestReceipt, + }); + const result = appendConsentDecisionReceipt(penultimate, nextCommand({ + receiptId: IDS.receipt3, + expectedRevision: Number.MAX_SAFE_INTEGER - 1, + })); + expect(result.disposition).toBe('appended'); + expect(result.track.receiptRevision).toBe(Number.MAX_SAFE_INTEGER); + expect(result.receipt.receiptRevision).toBe(Number.MAX_SAFE_INTEGER); + expect(result.receipt.priorReceiptId).toBe(IDS.receipt1); + }); +}); + +describe('malformed, incoherent, and hostile values fail closed without echo', () => { + test.each([ + ['null', null], ['undefined', undefined], ['string', 'consent'], + ['number', 1], ['array', []], ['date', new Date(0)], + ['boxed string', new String('consent')], ['null-prototype', Object.create(null)], + ])('rejects a %s create input', (_label, input) => { + expectRejected(() => createConsentReceiptTrack(input)); + }); + + test.each([ + ['trackId', HOSTILE_CANARY], ['trackId', 'ctrk_DaveLiu'], + ['subjectRef', 'private-member@example.test'], ['subjectRef', 'subject.DaveLiu'], + ['scopeRef', '+12025550170'], ['scopeRef', 'scope.whatsapp-messaging'], + ['provider', 'facebook'], + ])('rejects a malformed or identifying create field: %s', (field, value) => { + expectRejected(() => createConsentReceiptTrack(trackInput({ [field]: value }))); + }); + + test.each([ + ['commandType', 'capture_legal_consent'], + ['commandId', HOSTILE_CANARY], ['commandId', 'cmd_DaveLiu'], + ['receiptId', 'https://example.test/receipt'], ['receiptId', 'crpt_private_member'], + ['decision', 'none'], ['decision', 'accepted'], + ['policyVersion', 'I agree to receive club messages'], ['policyVersion', 'policy.DaveLiu'], + ])('rejects a malformed or raw command field: %s', (field, value) => { + const empty = createConsentReceiptTrack(trackInput()); + expectRejected(() => appendConsentDecisionReceipt(empty, command({ [field]: value }))); + }); + + test('rejects wrong versions on every public input', () => { + expectRejected(() => createConsentReceiptTrack(trackInput({ + membershipConsentReceiptSchemaVersion: 2, + }))); + const empty = createConsentReceiptTrack(trackInput()); + expectRejected(() => appendConsentDecisionReceipt(empty, command({ + membershipConsentReceiptSchemaVersion: 2, + }))); + expectRejected(() => deriveConsentStateFromReceiptTrack(empty, requiredPolicy({ + membershipConsentReceiptSchemaVersion: 2, + }))); + }); + + test.each([ + 'policy.DaveLiu', + 'policy.short', + 'private-member@example.test', + '+12025550170', + 'https://example.test/policy', + ])('rejects a malformed required policy version: %s', (requiredPolicyVersion) => { + const empty = createConsentReceiptTrack(trackInput()); + expectRejected(() => deriveConsentStateFromReceiptTrack(empty, requiredPolicy({ + requiredPolicyVersion, + }))); + }); + + test.each([ + ['authority flag', (track) => ({ ...track, grantsAuthority: true })], + ['empty negative-zero revision', (track) => ({ ...track, receiptRevision: -0 })], + ['empty nonzero revision', (track) => ({ ...track, receiptRevision: 1 })], + ['empty fabricated latest', (track) => ({ ...track, latestReceipt: {} })], + ['nonempty zero revision', (track) => ({ ...track, receiptRevision: 0 })], + ['nested authority flag', (track) => ({ + ...track, latestReceipt: { ...track.latestReceipt, grantsAuthority: true }, + })], + ['nested revision', (track) => ({ + ...track, latestReceipt: { ...track.latestReceipt, receiptRevision: 2 }, + })], + ['nested provider', (track) => ({ + ...track, latestReceipt: { ...track.latestReceipt, provider: 'strava' }, + })], + ['nested subject', (track) => ({ + ...track, latestReceipt: { ...track.latestReceipt, subjectRef: IDS.otherSubject }, + })], + ['nested scope', (track) => ({ + ...track, latestReceipt: { ...track.latestReceipt, scopeRef: IDS.otherScope }, + })], + ['first prior receipt', (track) => ({ + ...track, latestReceipt: { ...track.latestReceipt, priorReceiptId: IDS.receipt2 }, + })], + ])('rejects an internally incoherent head: %s', (_label, mutate) => { + const first = appendConsentDecisionReceipt( + createConsentReceiptTrack(trackInput()), + command(), + ); + const base = _label.startsWith('empty') ? createConsentReceiptTrack(trackInput()) : first.track; + expectRejected(() => deriveConsentStateFromReceiptTrack(mutate(base), requiredPolicy())); + }); + + test('rejects a later receipt with a missing or self-referential prior id', () => { + const { withdraw } = appendSequence(); + for (const priorReceiptId of [null, IDS.receipt2]) { + expectRejected(() => deriveConsentStateFromReceiptTrack({ + ...withdraw.track, + latestReceipt: { ...withdraw.receipt, priorReceiptId }, + }, requiredPolicy())); + } + }); + + test('rejects missing, extra, inherited, symbol, and non-enumerable fields', () => { + const missing = trackInput(); + delete missing.scopeRef; + expectRejected(() => createConsentReceiptTrack(missing)); + expectRejected(() => createConsentReceiptTrack({ ...trackInput(), extra: HOSTILE_CANARY })); + expectRejected(() => createConsentReceiptTrack(Object.create(trackInput()))); + + const symbol = trackInput(); + symbol[Symbol('private')] = HOSTILE_CANARY; + expectRejected(() => createConsentReceiptTrack(symbol)); + + const hidden = trackInput(); + Object.defineProperty(hidden, 'hidden', { value: HOSTILE_CANARY, enumerable: false }); + expectRejected(() => createConsentReceiptTrack(hidden)); + }); + + test('rejects accessors and proxies without invoking their traps', () => { + let getterCalls = 0; + const accessor = trackInput(); + delete accessor.scopeRef; + Object.defineProperty(accessor, 'scopeRef', { + enumerable: true, + get() { getterCalls += 1; return HOSTILE_CANARY; }, + }); + expectRejected(() => createConsentReceiptTrack(accessor)); + expect(getterCalls).toBe(0); + + let trapCalls = 0; + const proxy = new Proxy(trackInput(), { + get() { trapCalls += 1; return HOSTILE_CANARY; }, + ownKeys() { trapCalls += 1; return []; }, + getPrototypeOf() { trapCalls += 1; return Object.prototype; }, + }); + expectRejected(() => createConsentReceiptTrack(proxy)); + expect(trapCalls).toBe(0); + + const revocable = Proxy.revocable(trackInput(), {}); + revocable.revoke(); + expectRejected(() => createConsentReceiptTrack(revocable.proxy)); + }); + + test('applies strict-object checks to commands, policy inputs, and nested receipts', () => { + const empty = createConsentReceiptTrack(trackInput()); + expectRejected(() => appendConsentDecisionReceipt(empty, new Proxy(command(), {}))); + expectRejected(() => deriveConsentStateFromReceiptTrack(empty, Object.create(requiredPolicy()))); + + const first = appendConsentDecisionReceipt(empty, command()); + expectRejected(() => deriveConsentStateFromReceiptTrack({ + ...first.track, + latestReceipt: new Proxy(first.receipt, {}), + }, requiredPolicy())); + }); + + test('never logs an offending value', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + expectRejected(() => createConsentReceiptTrack(trackInput({ trackId: HOSTILE_CANARY }))); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + }); +}); + +describe('frozen versioned surface and source boundary', () => { + const sourcePath = path.join(__dirname, 'membershipConsentReceipt.js'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + test('exports one frozen provider-neutral versioned vocabulary', () => { + expect(membershipConsentReceiptSchemaVersion).toBe(1); + expect(Object.isFrozen(MEMBERSHIP_CONSENT_RECEIPT_ENUMS)).toBe(true); + for (const values of Object.values(MEMBERSHIP_CONSENT_RECEIPT_ENUMS)) { + expect(Object.isFrozen(values)).toBe(true); + } + expect(MEMBERSHIP_CONSENT_RECEIPT_ENUMS.provider) + .toBe(MEMBERSHIP_CONSENT_STATE_ENUMS.provider); + expect(MEMBERSHIP_CONSENT_RECEIPT_ENUMS.decision).toEqual(['granted', 'withdrawn']); + expect(MEMBERSHIP_CONSENT_RECEIPT_ENUMS.disposition) + .toEqual(['appended', 'already_applied']); + }); + + test('freezes its fixed non-echoing error type', () => { + const err = new MembershipConsentReceiptError(); + expect(Object.isFrozen(err)).toBe(true); + expect(Object.isFrozen(MembershipConsentReceiptError)).toBe(true); + expect(Object.isFrozen(MembershipConsentReceiptError.prototype)).toBe(true); + }); + + test('is imported by no runtime entrypoint', () => { + const visited = new Set(); + const pending = [path.join(__dirname, 'index.js')]; + + while (pending.length > 0) { + const file = pending.pop(); + if (visited.has(file)) continue; + visited.add(file); + const runtimeSource = fs.readFileSync(file, 'utf8'); + for (const match of runtimeSource.matchAll( + /require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g, + )) { + const requested = path.resolve(path.dirname(file), match[1]); + const candidates = [requested, `${requested}.js`, path.join(requested, 'index.js')]; + const candidate = candidates.find((value) => fs.existsSync(value) + && fs.statSync(value).isFile()); + if (candidate) pending.push(candidate); + } + } + + expect([...visited]).not.toContain(sourcePath); + expect(visited.size).toBeGreaterThan(1); + }); + + test('requires only node:util and the shipped #370 classifier', () => { + const requires = [...source.matchAll(/require\(([^)]+)\)/g)].map((match) => match[1]); + expect(requires).toEqual(["'node:util'", "'./membershipConsentState'"]); + expect(source).toContain('classifyConsentState'); + }); + + test('has no clock, environment, randomness, network, Firebase, provider SDK, or logger edge', () => { + for (const forbidden of [ + 'process.env', 'Date.now', 'new Date', 'Math.random', 'randomUUID', + 'console.', 'fetch(', 'https:', 'http:', 'firebase', 'stripe', 'whatsapp', + 'receipts:', 'receipts =', 'receipts.push', + ]) { + expect(source).not.toContain(forbidden); + } + }); + + test('models no personal-data, actor, notice, capture, or secret field', () => { + expect(source).not.toMatch( + /phoneNumber|emailAddress|streetAddress|dateOfBirth|emergencyContact|actorId/i, + ); + expect(source).not.toMatch( + /noticeText|policyText|capturedAt|recordedAt|captureMethod|legalBasis|retention/i, + ); + expect(source).not.toMatch(/passwordHash|accessToken|refreshToken|clientSecret|apiKey|bearer/i); + }); + + test('hard-codes the no-authority invariant', () => { + expect(source).toContain('grantsAuthority: false'); + expect(source).not.toContain('grantsAuthority: true'); + }); +});