From aee505cacd0d8f155ed14c9bb0942b02a158aa2d Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 20:36:56 -0700 Subject: [PATCH] MEMBERS-IDENTITY-001A define membership authority contract --- SYSTEM_DESIGN.md | 24 ++ docs/officers/EVENTS_SHOP_MEMBERS.md | 49 +++ functions/membershipAuthority.js | 509 ++++++++++++++++++++++++ functions/membershipAuthority.test.js | 532 ++++++++++++++++++++++++++ 4 files changed, 1114 insertions(+) create mode 100644 functions/membershipAuthority.js create mode 100644 functions/membershipAuthority.test.js diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 8490a20f..d2cb8c9d 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -377,6 +377,30 @@ DATA-001C1 [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) pau The server chooses initial timestamps. The current self-edit path sends a Firestore server timestamp, but the Rules source type-checks rather than independently proves that edit timestamp. Do not describe arbitrary profile edit timestamps as server-authoritative until a coordinated Rules/API issue closes that residual. +### 8.0a Provider-neutral membership authority and entitlement — SOURCE ONLY, UNUSED + +MEMBERS-IDENTITY-001A [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208) defines one unused pure contract that keeps a stable MPRC membership separate from the Firebase account used to sign in. A membership record can exist without a UID. Such a record grants no website entitlement. Google, WhatsApp, Strava, email equality, a profile role, and any browser field remain projections or inputs to future reviewed workflows; none is membership authority. + +```mermaid +flowchart LR + M["Stable opaque MPRC membership ID"] --> L{"Explicit UID association recorded?"} + A["Firebase account"] --> L + L -- "No or conflicting" --> N["Not entitled"] + L -- "Yes" --> T{"Versioned term decision complete?"} + T -- "Missing or decision pending" --> P["Policy decision pending"] + T -- "Suspended, ended, future, or expired" --> N + T -- "Approved and inside explicit start/end" --> E["Fixed current-member result"] + X["Google / WhatsApp / Strava / email / role"] -. "Never grants membership" .-> N +``` + +Text alternative: a stable membership receives the fixed current-member result only after an explicit UID association and a complete approved term whose explicit half-open time range contains the evaluation time. Missing, conflicting, suspended, ended, future, expired, or undecided state fails closed. External identities, channels, matching email, and roles never grant membership. + +The CommonJS module creates an account-independent revision-1 snapshot, records one explicit account association, records already-decided term references monotonically, and derives one of three frozen non-identifying results: current member, not entitled, or decision pending. It accepts exact plain objects, bounded server-minted opaque identifiers, safe-integer time values, one current term rather than an unbounded mutable history, and a last-command marker for immediate idempotent retry. An exact last-command retry is read-only. A changed last-command retry, a second/different UID association within the snapshot, a stale record revision, an exhausted safe-integer revision, a skipped or repeated term revision, a reversed time range, an unsupported version/state, an extra field, an accessor, or a proxy fails through one fixed error. + +This contract does not verify a person, payment, plan, evidence item, refund, dispute, or policy decision. It does not choose calendar-year versus anniversary terms, grace, prices, plan eligibility, retention, or legacy disposition. Its identifier grammar is not a semantic privacy classifier; a future trusted server must mint opaque values and establish every referenced fact. The last-command marker prevents only an immediate changed retry; command IDs are not a durable global replay registry. Durable cross-record UID uniqueness, full command replay history, append-only audit, Firestore schema/Rules, custom claims, token refresh/revocation, runtime authorization, migration, and deployment are later children behind #110, #113, #114, AUTH-003/ADMIN, and the protected release work. + +The module is imported by no runtime or Functions index. It reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, changes no current profile/role/claim, and cannot make #81, annual renewal, discounts, roster export, or officer membership tools available. Source tests and a merge are not Firebase deployment or live behavior proof. + ### 8.1 Paid race registration ```mermaid diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 9862d11f..9c33ad9a 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -241,6 +241,55 @@ Officer steps after every prerequisite has proof: **Escalation:** membership lead plus privacy/platform owner; use the private incident path under #112 if exposure is suspected. +## Provider-neutral membership authority — SOURCE ONLY, UNUSED + +**Status: NOT AVAILABLE YET** + +**Purpose:** keep a club membership separate from the account or outside service a person uses, so email, Google, WhatsApp, Strava, and a website role cannot accidentally grant membership, discounts, or officer access. + +**Approver:** membership lead plus treasurer and privacy/security owner. + +**Prerequisites:** issue [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208) must be merged for source review. Before any officer or member can use this model, #110 must approve data purposes and retention, a focused #114 child must approve term/payment rules, the identity/admin work must approve who may link or remove a website account, and reviewed Firebase schema, Rules, Functions, deployment, readback, and made-up staged behavior must all have proof. #113 separately owns legacy-source disposition. None of those runtime prerequisites is completed by #208. + +```mermaid +flowchart TD + Record["Stable club membership"] --> Link{"Explicitly linked to this website account?"} + Link -- "No or conflict" --> No["No member access"] + Link -- "Yes" --> Term{"Approved term is current?"} + Term -- "Decision pending" --> Wait["No access decision yet"] + Term -- "Future, expired, suspended, or ended" --> No + Term -- "Yes" --> Current["Current-member result"] + Outside["Email / Google / WhatsApp / Strava / website role"] -. "Cannot grant membership" .-> No +``` + +In words: the future system starts with a stable club membership, links it deliberately to one website account, and grants a current-member result only for a complete approved term. Missing, conflicting, undecided, future, expired, suspended, or ended state does not grant access. Email, sign-in method, community channels, and website roles are never proof of membership. + +Officer review steps after the source merge: + +1. Keep every membership activation, renewal, discount, roster, and outside-channel action marked **NOT AVAILABLE YET**. +2. Do not grant a website role as a workaround. +3. Do not edit a database record as a workaround. +4. Do not match an account by email as a workaround. +5. Do not create a second account as a workaround. +6. Ask the platform owner to show the fixed #208 report using only made-up, non-identifying reference values. +7. Confirm a membership with no account link returns no website entitlement. +8. Confirm an explicit made-up account link still returns `decision pending` until a complete term decision is supplied. +9. Confirm only an approved term inside its explicit start/end range returns the fixed current-member result. +10. Confirm a different account, missing decision, future or expired range, suspension, ending, out-of-date or conflicting update, or changed immediate retry fails closed without exposing an identifier. +11. Confirm a second attempt to link the same or another website account to one membership fails, even when the update is otherwise current. +12. Confirm the report contains no provider call, database write, claim/role change, migration, log, website route, or production record. +13. End the source review without describing the contract as a working membership system or choosing calendar, grace, price, plan, refund, dispute, or retention policy. + +**Expected result:** officers can explain the future authority boundary in plain language. The unused source accepts only the narrow synthetic contract, preserves an account-independent membership, and returns a fixed non-identifying result. Current website accounts, roles, dues forms, discounts, and external channels behave exactly as before. + +**Stop conditions:** any real member/account/payment/provider data; a request to infer membership from email or role; an unresolved policy choice; a direct Auth, Firestore, claim, or production edit; missing dependency/deployment proof; or a statement that green tests mean member access is live. + +**Success proof:** exact #208 issue, pull request, reviewed commit, 46-case focused synthetic report, full repository checks, two independent exact-diff reviews, and a source scan showing the module is not connected to any live Function entry point. Future availability additionally requires separately approved policy, schema, authorization, cross-record account-link uniqueness, durable command replay protection, migration decision, protected Firebase deployment/readback, made-up staging test, website publication, `runmprc.com` verification, and backup-officer walkthrough. + +**Undo:** before runtime adoption, revert or safely roll forward only the two unused module/test files and these named documentation sections through a reviewed pull request. There is no production record to repair. After any future adoption, use that child's documented rollback; never undo membership by changing a claim or database record by hand. + +**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. + ## Refund amount and returned-result guards — SOURCE ONLY, NOT LIVE **Purpose:** make an invalid partial amount stop, and record a refund complete only when Stripe returns a matching final success. diff --git a/functions/membershipAuthority.js b/functions/membershipAuthority.js new file mode 100644 index 00000000..29dbb57f --- /dev/null +++ b/functions/membershipAuthority.js @@ -0,0 +1,509 @@ +'use strict'; + +const { types: { isProxy } } = require('node:util'); + +const membershipAuthoritySchemaVersion = 1; +const MEMBERSHIP_AUTHORITY_ERROR_MESSAGE = 'Membership authority input is invalid.'; +const MAX_TIME_MS = 8_640_000_000_000_000; +const OPAQUE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +const CREATE_FIELDS = Object.freeze([ + 'membershipAuthoritySchemaVersion', + 'membershipId', + 'commandId', +]); + +const ASSOCIATE_COMMAND_FIELDS = Object.freeze([ + 'membershipAuthoritySchemaVersion', + 'commandType', + 'commandId', + 'expectedRevision', + 'uid', +]); + +const TERM_COMMAND_FIELDS = Object.freeze([ + 'membershipAuthoritySchemaVersion', + 'commandType', + 'commandId', + 'expectedRevision', + 'termRevision', + 'termState', + 'termId', + 'startsAtMs', + 'endsAtMs', + 'planRef', + 'evidenceRef', + 'policyVersion', +]); + +const RECORD_FIELDS = Object.freeze([ + 'membershipAuthoritySchemaVersion', + 'membershipId', + 'revision', + 'association', + 'term', + 'lastCommand', +]); + +const ASSOCIATION_FIELDS = Object.freeze([ + 'state', + 'uid', + 'revision', +]); + +const TERM_FIELDS = Object.freeze([ + 'state', + 'termId', + 'startsAtMs', + 'endsAtMs', + 'planRef', + 'evidenceRef', + 'policyVersion', + 'revision', +]); + +const LAST_COMMAND_FIELDS = Object.freeze([ + 'commandType', + 'commandId', + 'expectedRevision', +]); + +const ENTITLEMENT_INPUT_FIELDS = Object.freeze([ + 'membershipAuthoritySchemaVersion', + 'record', + 'uid', + 'asOfMs', +]); + +const MEMBERSHIP_AUTHORITY_ENUMS = Object.freeze({ + associationState: Object.freeze(['unlinked', 'linked']), + commandType: Object.freeze([ + 'create_membership', + 'associate_account', + 'record_term_decision', + ]), + termState: Object.freeze([ + 'decision_pending', + 'approved', + 'suspended', + 'ended', + ]), +}); + +const ASSOCIATION_STATES = new Set(MEMBERSHIP_AUTHORITY_ENUMS.associationState); +const COMMAND_TYPES = new Set(MEMBERSHIP_AUTHORITY_ENUMS.commandType); +const TERM_STATES = new Set(MEMBERSHIP_AUTHORITY_ENUMS.termState); + +const RESULTS = Object.freeze({ + active: Object.freeze({ + membershipAuthoritySchemaVersion, + entitlement: 'current_member', + state: 'active', + }), + inactive: Object.freeze({ + membershipAuthoritySchemaVersion, + entitlement: 'not_entitled', + state: 'inactive', + }), + pending: Object.freeze({ + membershipAuthoritySchemaVersion, + entitlement: 'decision_pending', + state: 'requires_policy_decision', + }), +}); + +class MembershipAuthorityError extends Error { + constructor() { + super(MEMBERSHIP_AUTHORITY_ERROR_MESSAGE); + Object.defineProperty(this, 'name', { + value: 'MembershipAuthorityError', + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'message', { + value: MEMBERSHIP_AUTHORITY_ERROR_MESSAGE, + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'code', { + value: 'invalid_membership_authority', + enumerable: false, + writable: false, + configurable: false, + }); + Error.captureStackTrace?.(this, MembershipAuthorityError); + Object.freeze(this); + } +} + +function fail() { + throw new MembershipAuthorityError(); +} + +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 !== membershipAuthoritySchemaVersion) fail(); +} + +function requireOpaqueIdentifier(value) { + if (typeof value !== 'string' || !OPAQUE_IDENTIFIER_PATTERN.test(value)) fail(); + return value; +} + +function requireRevision(value, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum) fail(); + return value; +} + +function requireTime(value) { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_TIME_MS) fail(); + return value; +} + +function freezeRecord(record) { + const association = Object.freeze({ + state: record.association.state, + uid: record.association.uid, + revision: record.association.revision, + }); + const term = Object.freeze({ + state: record.term.state, + termId: record.term.termId, + startsAtMs: record.term.startsAtMs, + endsAtMs: record.term.endsAtMs, + planRef: record.term.planRef, + evidenceRef: record.term.evidenceRef, + policyVersion: record.term.policyVersion, + revision: record.term.revision, + }); + const lastCommand = Object.freeze({ + commandType: record.lastCommand.commandType, + commandId: record.lastCommand.commandId, + expectedRevision: record.lastCommand.expectedRevision, + }); + return Object.freeze({ + membershipAuthoritySchemaVersion, + membershipId: record.membershipId, + revision: record.revision, + association, + term, + lastCommand, + }); +} + +function readAssociation(value) { + const association = readExactObject(value, ASSOCIATION_FIELDS); + if (!ASSOCIATION_STATES.has(association.state)) fail(); + requireRevision(association.revision); + + if (association.state === 'unlinked') { + if (association.uid !== null || association.revision !== 0) fail(); + } else { + requireOpaqueIdentifier(association.uid); + if (association.revision !== 1) fail(); + } + return association; +} + +function readTerm(value) { + const term = readExactObject(value, TERM_FIELDS); + if (!TERM_STATES.has(term.state)) fail(); + requireRevision(term.revision); + + if (term.revision === 0) { + if (term.state !== 'decision_pending' + || term.termId !== null + || term.startsAtMs !== null + || term.endsAtMs !== null + || term.planRef !== null + || term.evidenceRef !== null + || term.policyVersion !== null) { + fail(); + } + return term; + } + + requireOpaqueIdentifier(term.termId); + requireTime(term.startsAtMs); + requireTime(term.endsAtMs); + if (term.startsAtMs >= term.endsAtMs) fail(); + requireOpaqueIdentifier(term.planRef); + requireOpaqueIdentifier(term.evidenceRef); + requireOpaqueIdentifier(term.policyVersion); + return term; +} + +function readLastCommand(value) { + const command = readExactObject(value, LAST_COMMAND_FIELDS); + if (!COMMAND_TYPES.has(command.commandType)) fail(); + requireOpaqueIdentifier(command.commandId); + requireRevision(command.expectedRevision); + return command; +} + +function readRecord(value) { + const record = readExactObject(value, RECORD_FIELDS); + requireVersion(record.membershipAuthoritySchemaVersion); + requireOpaqueIdentifier(record.membershipId); + requireRevision(record.revision, 1); + const association = readAssociation(record.association); + const term = readTerm(record.term); + const lastCommand = readLastCommand(record.lastCommand); + + if (record.revision !== 1 + association.revision + term.revision + || lastCommand.expectedRevision !== record.revision - 1) { + fail(); + } + + if (lastCommand.commandType === 'create_membership') { + if (record.revision !== 1 || association.revision !== 0 || term.revision !== 0) fail(); + } else if (lastCommand.commandType === 'associate_account') { + if (record.revision !== 2 || association.revision !== 1 || term.revision !== 0) fail(); + } else if (association.revision !== 1 || term.revision < 1) { + fail(); + } + + return { + membershipId: record.membershipId, + revision: record.revision, + association, + term, + lastCommand, + }; +} + +function canonicalRecord(value, record) { + if (Object.isFrozen(value) + && Object.isFrozen(value.association) + && Object.isFrozen(value.term) + && Object.isFrozen(value.lastCommand)) { + return value; + } + return freezeRecord(record); +} + +function createMembershipAuthority(input) { + const data = readExactObject(input, CREATE_FIELDS); + requireVersion(data.membershipAuthoritySchemaVersion); + requireOpaqueIdentifier(data.membershipId); + requireOpaqueIdentifier(data.commandId); + + return freezeRecord({ + membershipId: data.membershipId, + revision: 1, + association: { + state: 'unlinked', + uid: null, + revision: 0, + }, + term: { + state: 'decision_pending', + termId: null, + startsAtMs: null, + endsAtMs: null, + planRef: null, + evidenceRef: null, + policyVersion: null, + revision: 0, + }, + lastCommand: { + commandType: 'create_membership', + commandId: data.commandId, + expectedRevision: 0, + }, + }); +} + +function readCommand(input) { + const inspected = readDataObject(input); + const commandType = inspected.data.commandType; + let expectedFields; + if (commandType === 'associate_account') { + expectedFields = ASSOCIATE_COMMAND_FIELDS; + } else if (commandType === 'record_term_decision') { + expectedFields = TERM_COMMAND_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.membershipAuthoritySchemaVersion); + requireOpaqueIdentifier(command.commandId); + requireRevision(command.expectedRevision, 1); + + if (commandType === 'associate_account') { + requireOpaqueIdentifier(command.uid); + return command; + } + + requireRevision(command.termRevision, 1); + if (!TERM_STATES.has(command.termState)) fail(); + requireOpaqueIdentifier(command.termId); + requireTime(command.startsAtMs); + requireTime(command.endsAtMs); + if (command.startsAtMs >= command.endsAtMs) fail(); + requireOpaqueIdentifier(command.planRef); + requireOpaqueIdentifier(command.evidenceRef); + requireOpaqueIdentifier(command.policyVersion); + return command; +} + +function isExactAssociationRetry(record, command) { + return record.lastCommand.commandType === 'associate_account' + && record.lastCommand.commandId === command.commandId + && record.lastCommand.expectedRevision === command.expectedRevision + && record.association.state === 'linked' + && record.association.uid === command.uid; +} + +function isExactTermRetry(record, command) { + return record.lastCommand.commandType === 'record_term_decision' + && record.lastCommand.commandId === command.commandId + && record.lastCommand.expectedRevision === command.expectedRevision + && record.term.revision === command.termRevision + && record.term.state === command.termState + && record.term.termId === command.termId + && record.term.startsAtMs === command.startsAtMs + && record.term.endsAtMs === command.endsAtMs + && record.term.planRef === command.planRef + && record.term.evidenceRef === command.evidenceRef + && record.term.policyVersion === command.policyVersion; +} + +function applyMembershipAuthorityCommand(recordInput, commandInput) { + const record = readRecord(recordInput); + const command = readCommand(commandInput); + const current = canonicalRecord(recordInput, record); + + if (command.commandId === record.lastCommand.commandId) { + const exact = command.commandType === 'associate_account' + ? isExactAssociationRetry(record, command) + : isExactTermRetry(record, command); + if (!exact) fail(); + return current; + } + + if (command.expectedRevision !== record.revision) fail(); + if (record.revision === Number.MAX_SAFE_INTEGER) fail(); + + const nextRevision = record.revision + 1; + + if (command.commandType === 'associate_account') { + if (record.association.state !== 'unlinked' || record.term.revision !== 0) fail(); + return freezeRecord({ + ...record, + revision: nextRevision, + association: { + state: 'linked', + uid: command.uid, + revision: 1, + }, + lastCommand: { + commandType: command.commandType, + commandId: command.commandId, + expectedRevision: command.expectedRevision, + }, + }); + } + + if (record.association.state !== 'linked' + || command.termRevision !== record.term.revision + 1) { + fail(); + } + return freezeRecord({ + ...record, + revision: nextRevision, + term: { + state: command.termState, + termId: command.termId, + startsAtMs: command.startsAtMs, + endsAtMs: command.endsAtMs, + planRef: command.planRef, + evidenceRef: command.evidenceRef, + policyVersion: command.policyVersion, + revision: command.termRevision, + }, + lastCommand: { + commandType: command.commandType, + commandId: command.commandId, + expectedRevision: command.expectedRevision, + }, + }); +} + +function deriveMembershipEntitlement(input) { + const data = readExactObject(input, ENTITLEMENT_INPUT_FIELDS); + requireVersion(data.membershipAuthoritySchemaVersion); + requireOpaqueIdentifier(data.uid); + requireTime(data.asOfMs); + const record = readRecord(data.record); + + if (record.association.state !== 'linked' || record.association.uid !== data.uid) { + return RESULTS.inactive; + } + if (record.term.state === 'decision_pending') return RESULTS.pending; + if (record.term.state !== 'approved') return RESULTS.inactive; + if (data.asOfMs < record.term.startsAtMs || data.asOfMs >= record.term.endsAtMs) { + return RESULTS.inactive; + } + return RESULTS.active; +} + +Object.freeze(MembershipAuthorityError.prototype); +Object.freeze(MembershipAuthorityError); + +module.exports = Object.freeze({ + membershipAuthoritySchemaVersion, + MEMBERSHIP_AUTHORITY_ENUMS, + MembershipAuthorityError, + createMembershipAuthority, + applyMembershipAuthorityCommand, + deriveMembershipEntitlement, +}); diff --git a/functions/membershipAuthority.test.js b/functions/membershipAuthority.test.js new file mode 100644 index 00000000..81dee6dc --- /dev/null +++ b/functions/membershipAuthority.test.js @@ -0,0 +1,532 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { inspect } = require('node:util'); + +const { + membershipAuthoritySchemaVersion, + MEMBERSHIP_AUTHORITY_ENUMS, + MembershipAuthorityError, + createMembershipAuthority, + applyMembershipAuthorityCommand, + deriveMembershipEntitlement, +} = require('./membershipAuthority'); + +const HOSTILE_CANARY = 'private-member@example.test/+12025550208?token=do-not-copy'; +const TERM_START_MS = 1_000_000; +const TERM_END_MS = 2_000_000; + +function createInput(overrides = {}) { + return { + membershipAuthoritySchemaVersion: 1, + membershipId: 'mbr_test_001', + commandId: 'cmd_create_001', + ...overrides, + }; +} + +function associateCommand(overrides = {}) { + return { + membershipAuthoritySchemaVersion: 1, + commandType: 'associate_account', + commandId: 'cmd_link_001', + expectedRevision: 1, + uid: 'uid_test_001', + ...overrides, + }; +} + +function termCommand(overrides = {}) { + return { + membershipAuthoritySchemaVersion: 1, + commandType: 'record_term_decision', + commandId: 'cmd_term_001', + expectedRevision: 2, + termRevision: 1, + termState: 'approved', + termId: 'term_test_2026', + startsAtMs: TERM_START_MS, + endsAtMs: TERM_END_MS, + planRef: 'plan_test_001', + evidenceRef: 'evidence_test_001', + policyVersion: 'policy_test_001', + ...overrides, + }; +} + +function entitlementInput(record, overrides = {}) { + return { + membershipAuthoritySchemaVersion: 1, + record, + uid: 'uid_test_001', + asOfMs: TERM_START_MS, + ...overrides, + }; +} + +function createLinkedRecord() { + return applyMembershipAuthorityCommand( + createMembershipAuthority(createInput()), + associateCommand(), + ); +} + +function createApprovedRecord(overrides = {}) { + return applyMembershipAuthorityCommand( + createLinkedRecord(), + termCommand(overrides), + ); +} + +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(MembershipAuthorityError); + expect(error).toMatchObject({ + name: 'MembershipAuthorityError', + code: 'invalid_membership_authority', + message: 'Membership authority 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); + } +} + +describe('provider-neutral membership authority contract', () => { + test('exports one frozen versioned API and enum catalog', () => { + const api = require('./membershipAuthority'); + expect(membershipAuthoritySchemaVersion).toBe(1); + expect(Object.isFrozen(api)).toBe(true); + expect(Object.isFrozen(MEMBERSHIP_AUTHORITY_ENUMS)).toBe(true); + for (const values of Object.values(MEMBERSHIP_AUTHORITY_ENUMS)) { + expect(Object.isFrozen(values)).toBe(true); + } + expect(Object.isFrozen(MembershipAuthorityError)).toBe(true); + expect(Object.isFrozen(MembershipAuthorityError.prototype)).toBe(true); + }); + + test('creates an account-independent record that grants no website entitlement', () => { + const input = createInput(); + const before = JSON.stringify(input); + const record = createMembershipAuthority(input); + + expect(record).toEqual({ + membershipAuthoritySchemaVersion: 1, + membershipId: 'mbr_test_001', + revision: 1, + association: { + state: 'unlinked', + uid: null, + revision: 0, + }, + term: { + state: 'decision_pending', + termId: null, + startsAtMs: null, + endsAtMs: null, + planRef: null, + evidenceRef: null, + policyVersion: null, + revision: 0, + }, + lastCommand: { + commandType: 'create_membership', + commandId: 'cmd_create_001', + expectedRevision: 0, + }, + }); + expectDeepFrozen(record); + expect(JSON.stringify(input)).toBe(before); + expect(deriveMembershipEntitlement(entitlementInput(record))).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'not_entitled', + state: 'inactive', + }); + }); + + test('associates one UID explicitly without treating account identity as membership', () => { + const initial = createMembershipAuthority(createInput()); + const command = associateCommand(); + const before = JSON.stringify({ initial, command }); + const linked = applyMembershipAuthorityCommand(initial, command); + + expect(linked.revision).toBe(2); + expect(linked.association).toEqual({ + state: 'linked', + uid: 'uid_test_001', + revision: 1, + }); + expect(linked.term.state).toBe('decision_pending'); + expect(linked.lastCommand).toEqual({ + commandType: 'associate_account', + commandId: 'cmd_link_001', + expectedRevision: 1, + }); + expectDeepFrozen(linked); + expect(JSON.stringify({ initial, command })).toBe(before); + expect(deriveMembershipEntitlement(entitlementInput(linked))).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'decision_pending', + state: 'requires_policy_decision', + }); + }); + + test('makes an exact command retry read-only and rejects a changed reuse', () => { + const initial = createMembershipAuthority(createInput()); + const linked = applyMembershipAuthorityCommand(initial, associateCommand()); + expect(applyMembershipAuthorityCommand(linked, associateCommand())).toBe(linked); + + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + associateCommand({ uid: 'uid_other_001' }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + associateCommand({ commandType: 'record_term_decision' }), + )); + }); + + test('records an already-decided term without choosing policy and grants only the fixed result', () => { + const linked = createLinkedRecord(); + const command = termCommand(); + const record = applyMembershipAuthorityCommand(linked, command); + + expect(record.revision).toBe(3); + expect(record.term).toEqual({ + state: 'approved', + termId: 'term_test_2026', + startsAtMs: TERM_START_MS, + endsAtMs: TERM_END_MS, + planRef: 'plan_test_001', + evidenceRef: 'evidence_test_001', + policyVersion: 'policy_test_001', + revision: 1, + }); + expectDeepFrozen(record); + const result = deriveMembershipEntitlement(entitlementInput(record)); + expect(result).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'current_member', + state: 'active', + }); + expect(Object.keys(result)).toEqual([ + 'membershipAuthoritySchemaVersion', + 'entitlement', + 'state', + ]); + expect(JSON.stringify(result)).not.toContain('mbr_test_001'); + expect(JSON.stringify(result)).not.toContain('uid_test_001'); + expect(applyMembershipAuthorityCommand(record, command)).toBe(record); + }); + + test('uses explicit half-open term boundaries without choosing a calendar policy', () => { + const record = createApprovedRecord(); + const notEntitled = { + membershipAuthoritySchemaVersion: 1, + entitlement: 'not_entitled', + state: 'inactive', + }; + + expect(deriveMembershipEntitlement(entitlementInput(record, { + asOfMs: TERM_START_MS - 1, + }))).toEqual(notEntitled); + expect(deriveMembershipEntitlement(entitlementInput(record, { + asOfMs: TERM_START_MS, + }))).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'current_member', + state: 'active', + }); + expect(deriveMembershipEntitlement(entitlementInput(record, { + asOfMs: TERM_END_MS - 1, + }))).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'current_member', + state: 'active', + }); + expect(deriveMembershipEntitlement(entitlementInput(record, { + asOfMs: TERM_END_MS, + }))).toEqual(notEntitled); + }); + + test.each([ + ['decision_pending', 'decision_pending', 'requires_policy_decision'], + ['suspended', 'not_entitled', 'inactive'], + ['ended', 'not_entitled', 'inactive'], + ])('fails closed for a %s term decision', (termState, entitlement, state) => { + const record = createApprovedRecord({ termState }); + expect(deriveMembershipEntitlement(entitlementInput(record))).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement, + state, + }); + }); + + test('a different UID never receives the linked membership entitlement', () => { + const result = deriveMembershipEntitlement(entitlementInput(createApprovedRecord(), { + uid: 'uid_other_001', + })); + expect(result).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'not_entitled', + state: 'inactive', + }); + }); + + test('rejects stale, skipped, reordered, and conflicting transitions without mutation', () => { + const initial = createMembershipAuthority(createInput()); + const linked = applyMembershipAuthorityCommand(initial, associateCommand()); + const before = JSON.stringify(linked); + + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + associateCommand({ commandId: 'cmd_link_002', expectedRevision: 1 }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + associateCommand({ commandId: 'cmd_link_002', uid: 'uid_other_001' }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + associateCommand({ commandId: 'cmd_link_002', expectedRevision: 2 }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + associateCommand({ + commandId: 'cmd_link_002', + expectedRevision: 2, + uid: 'uid_other_001', + }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + linked, + termCommand({ termRevision: 2 }), + )); + + const approved = applyMembershipAuthorityCommand(linked, termCommand()); + expectSafeError(() => applyMembershipAuthorityCommand( + approved, + termCommand({ commandId: 'cmd_term_002', expectedRevision: 2, termRevision: 2 }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + approved, + termCommand({ commandId: 'cmd_term_002', expectedRevision: 3, termRevision: 1 }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + approved, + termCommand({ evidenceRef: 'evidence_changed_001' }), + )); + expect(JSON.stringify(linked)).toBe(before); + }); + + test('fails closed before a record revision would exceed the safe-integer boundary', () => { + const approved = createApprovedRecord(); + const maxRecord = { + ...approved, + revision: Number.MAX_SAFE_INTEGER, + term: { + ...approved.term, + revision: Number.MAX_SAFE_INTEGER - 2, + }, + lastCommand: { + commandType: 'record_term_decision', + commandId: 'cmd_term_max_001', + expectedRevision: Number.MAX_SAFE_INTEGER - 1, + }, + }; + const before = JSON.stringify(maxRecord); + + expectSafeError(() => applyMembershipAuthorityCommand(maxRecord, termCommand({ + commandId: 'cmd_term_max_002', + expectedRevision: Number.MAX_SAFE_INTEGER, + termRevision: Number.MAX_SAFE_INTEGER - 1, + }))); + expect(JSON.stringify(maxRecord)).toBe(before); + }); + + test('accepts a monotonic later term decision while keeping policy facts opaque', () => { + const approved = createApprovedRecord(); + const suspended = applyMembershipAuthorityCommand(approved, termCommand({ + commandId: 'cmd_term_002', + expectedRevision: 3, + termRevision: 2, + termState: 'suspended', + evidenceRef: 'evidence_test_002', + policyVersion: 'policy_test_002', + })); + expectSafeError(() => applyMembershipAuthorityCommand(suspended, termCommand())); + expect(suspended.term.state).toBe('suspended'); + expect(suspended.term.revision).toBe(2); + expect(deriveMembershipEntitlement(entitlementInput(suspended))).toEqual({ + membershipAuthoritySchemaVersion: 1, + entitlement: 'not_entitled', + state: 'inactive', + }); + }); +}); + +describe('strict and non-identifying input boundary', () => { + test.each([ + undefined, + null, + true, + 1, + 'membership', + [], + new Date(0), + new Number(1), + ])('rejects non-plain root input case %#', (input) => { + expectSafeError(() => createMembershipAuthority(input)); + }); + + test('rejects missing, extra, symbol, inherited, accessor, and proxy fields', () => { + for (const field of Object.keys(createInput())) { + const input = createInput(); + delete input[field]; + expectSafeError(() => createMembershipAuthority(input)); + } + expectSafeError(() => createMembershipAuthority({ + ...createInput(), + email: HOSTILE_CANARY, + })); + const symbolInput = createInput(); + symbolInput[Symbol(HOSTILE_CANARY)] = true; + expectSafeError(() => createMembershipAuthority(symbolInput)); + expectSafeError(() => createMembershipAuthority(Object.assign( + Object.create({ email: HOSTILE_CANARY }), + createInput(), + ))); + const getterInput = createInput(); + Object.defineProperty(getterInput, 'membershipId', { + get() { + throw new Error(HOSTILE_CANARY); + }, + enumerable: true, + }); + expectSafeError(() => createMembershipAuthority(getterInput)); + expectSafeError(() => createMembershipAuthority(new Proxy(createInput(), {}))); + expectSafeError(() => applyMembershipAuthorityCommand( + createMembershipAuthority(createInput()), + new Proxy(associateCommand(), {}), + )); + }); + + test.each([ + 'private-member@example.test', + '+12025550208', + 'https://example.test/member', + 'member with spaces', + 'member/with/path', + 'éxternal', + '', + 'a'.repeat(129), + ])('rejects non-opaque identifier %p', (value) => { + expectSafeError(() => createMembershipAuthority(createInput({ membershipId: value })), value); + expectSafeError(() => createMembershipAuthority(createInput({ commandId: value })), value); + }); + + test.each([ + ['email', HOSTILE_CANARY], + ['phoneNumber', '+12025550208'], + ['provider', 'google.com'], + ['role', 'admin_canary_208'], + ['stripePaymentId', 'pi_private'], + ['priceCents', 2500], + ['memberName', 'Private Runner'], + ])('rejects forbidden caller field %s', (field, value) => { + expectSafeError(() => applyMembershipAuthorityCommand( + createMembershipAuthority(createInput()), + { ...associateCommand(), [field]: value }, + ), String(value)); + }); + + test.each([ + NaN, + Infinity, + -1, + 1.5, + Number.MAX_SAFE_INTEGER, + ])('rejects invalid term time %p', (startsAtMs) => { + expectSafeError(() => applyMembershipAuthorityCommand( + createLinkedRecord(), + termCommand({ startsAtMs }), + )); + }); + + test('rejects equal/reversed bounds and invalid entitlement clocks', () => { + expectSafeError(() => applyMembershipAuthorityCommand( + createLinkedRecord(), + termCommand({ startsAtMs: TERM_END_MS }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + createLinkedRecord(), + termCommand({ startsAtMs: TERM_END_MS + 1 }), + )); + const record = createApprovedRecord(); + for (const asOfMs of [NaN, Infinity, -1, 1.5, Number.MAX_SAFE_INTEGER]) { + expectSafeError(() => deriveMembershipEntitlement(entitlementInput(record, { asOfMs }))); + } + }); + + test('rejects every unsupported schema, command, and term enum', () => { + expectSafeError(() => createMembershipAuthority(createInput({ + membershipAuthoritySchemaVersion: 2, + }))); + expectSafeError(() => applyMembershipAuthorityCommand( + createMembershipAuthority(createInput()), + associateCommand({ commandType: 'grant_membership' }), + )); + expectSafeError(() => applyMembershipAuthorityCommand( + createLinkedRecord(), + termCommand({ termState: 'paid' }), + )); + }); + + test('validates records again instead of trusting caller-created snapshots', () => { + const valid = createApprovedRecord(); + const forged = JSON.parse(JSON.stringify(valid)); + forged.association.uid = HOSTILE_CANARY; + expectSafeError(() => deriveMembershipEntitlement(entitlementInput(forged))); + + const extra = JSON.parse(JSON.stringify(valid)); + extra.term.priceCents = 2500; + expectSafeError(() => deriveMembershipEntitlement(entitlementInput(extra))); + }); +}); + +describe('static non-adoption and dependency boundary', () => { + test('has no runtime edge, external SDK, clock, random, logger, environment, or provider field', () => { + const source = fs.readFileSync(path.join(__dirname, 'membershipAuthority.js'), 'utf8'); + const index = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + expect(index).not.toContain('membershipAuthority'); + expect(source).not.toMatch(/firebase|firestore|stripe|google|whatsapp|strava/i); + expect(source).not.toMatch(/process\.env|Date\.now|new Date|Math\.random|console\.|fetch\s*\(|https?:/); + expect(source).not.toMatch(/email|phone|address|dob|emergency|price|payment/i); + const requires = [...source.matchAll(/require\(([^)]+)\)/g)].map((match) => match[1]); + expect(requires).toEqual(["'node:util'"]); + }); +});