diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index bc7bcd2..afd2c8b 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -751,6 +751,40 @@ This contract invents no revocation SLA, grace period, expiry date, timing, or c The module is imported by no runtime or Functions index and updates no officer procedure because it makes nothing officer-observable. It requires only `node:util`, reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, and changes no current profile/role/claim. Source tests and a merge are not Firebase deployment or live behavior proof. +### 8.0h Member/account status projection and renewal affordance — SOURCE ONLY, UNUSED + +MEMBERS-DUES-001F [#437](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/437) defines one unused pure contract for **item 6** of parent [#114](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/114) — "Minimum member/account status projection and renewal UX after the server contract is proven", whose acceptance line requires member pricing and protected content to consume "the server-derived active-membership projection rather than Auth provider or raw profile fields". §8.0a `deriveMembershipEntitlement` is the authorization gate: it returns one of three frozen results — `current_member` / `not_entitled` / `decision_pending` — **collapsing every reason** (a term an hour from expiry, an elapsed term, a refund-suspended term, an offboarded ended term, a not-yet-started future term, an unlinked record) into the same `not_entitled`, and it applies no renewal-window threshold and offers no renewal affordance. A member's own account page and the member-pricing surface need those reasons **disaggregated** plus a renewal affordance; that disaggregation is the ONLY thing this contract adds. §8.17 / §8.18 / §8.19 each forward-declare it unbuilt ("no member/account status projection or renewal UX (item 6) … remain with #114"). + +It is deliberately a projection OF the shipped authority §8.0a, which it **COMPOSES rather than re-implements** — the one source-only core that imports a sibling, a documented departure from the `node:util`-only norm the other cores follow. The safety property that matters here is **display never over-states authorization**: any status that reads as entitled (`active` / `expiring_soon`) must correspond to a `current_member` authorization, always. Composing the real authority makes that invariant TRUE BY CONSTRUCTION and robust to any future change in §8.0a's window logic, and it re-implements NONE of §8.0a's record validation (revision math, enum / time / opaque-id checks) — re-implementing that in a second module would be the salami failure mode this project forbids. The imported sibling is itself pure and `node:util`-only, so purity / determinism / no-I/O hold transitively; a source-boundary test locks this module's require set to exactly `{ node:util, ./membershipAuthority }` and locks `membershipAuthority`'s own require set to `{ node:util }`. + +```mermaid +flowchart LR + V["§8.0a entitlement + record view + asOfMs + policy.renewalWindowMs"] --> G{"Well-formed input & policy?"} + G -- "No" --> X["denied: malformed_input | malformed_policy (input never echoed)"] + G -- "Yes" --> E{"§8.0a entitlement?"} + E -- "decision_pending" --> P["pending — no renewal"] + E -- "current_member" --> W{"endsAtMs - asOfMs <= renewalWindowMs?"} + W -- "No" --> A["active — no renewal"] + W -- "Yes" --> ES["expiring_soon — renewal offered"] + E -- "not_entitled" --> L{"linked & uid matches?"} + L -- "No" --> N["none — no renewal (a join, not a renewal)"] + L -- "Yes" --> T{"term state / window?"} + T -- "suspended" --> S["suspended — no renewal (clawback)"] + T -- "ended" --> EN["ended — no renewal (owner re-admits)"] + T -- "approved, asOfMs < startsAtMs" --> U["upcoming — no renewal (already renewed)"] + T -- "approved, asOfMs >= endsAtMs" --> EX["expired — renewal offered"] +``` + +Text alternative: the authoritative entitlement §8.0a derives is taken verbatim and disaggregated within its bucket. A `decision_pending` entitlement displays `pending`. A `current_member` entitlement — §8.0a's only entitled branch, a linked and matched association with an approved term whose window contains `asOfMs` — displays `expiring_soon` when the owner-configured `renewalWindowMs` covers the remaining time (`endsAtMs - asOfMs <= renewalWindowMs`, boundary inclusive) and `active` otherwise. A `not_entitled` entitlement is graduated in §8.0a's own order: an unlinked or uid-mismatched record is `none` (a non-member — a join, not a renewal); else a `suspended` term is `suspended` and an `ended` term is `ended`; else the term is approved but outside its window, so `asOfMs < startsAtMs` is `upcoming` (already renewed ahead) and `asOfMs >= endsAtMs` is `expired`. Renewal is offered for exactly `expiring_soon` and `expired`; the term-end instant is displayed for `active`/`expiring_soon`/`upcoming`/`expired` and is `null` otherwise. A malformed input or policy shape — proxy, revoked proxy, foreign prototype, array, symbol key, extra or missing field, accessor, bad version, or out-of-range time — denies with a fixed reason and never echoes the input; a malformed record makes the composed authority throw, which is caught and mapped to `malformed_input`. + +The marquee property is that display never over-states authorization, proven across the seam: the projection's `entitlement` is `deriveMembershipEntitlement`'s output verbatim, so a status reads as entitled (`active`/`expiring_soon`) **if and only if** §8.0a returns `current_member`. The test drives the REAL authority over every fixture and a swept range of instants, asserting the projected entitlement equals the authority's and that no not-current record ever displays as entitled — including an approved, in-window term linked to a **different** uid (→ `none`, never `active`) and an in-window **suspended** term (→ `suspended`, never `active`). The second invariant is that renewal is offered only where a safe self-serve re-purchase is correct: `renewalOffered` is `true` for exactly `expiring_soon` and `expired`, and never for a `suspended` term (a refund/dispute clawback — re-purchase could re-grant disputed access or double-charge), an `ended` term (offboarding — the owner re-admits), a `pending` decision, an already-renewed `upcoming` term, a comfortably-`active` term, or a non-member. + +The CommonJS module reads `input` `{ membershipAccountStatusSchemaVersion, record, uid, asOfMs }` and `policy` `{ membershipAccountStatusSchemaVersion, renewalWindowMs }` through the same descriptor-based closed-object read the membership-dues siblings use (getters never invoked; proxy / foreign prototype / array / symbol / extra / missing all deny), composes the shipped authority for the entitlement, and returns one frozen verdict; it never throws for any value in either argument position. The `record` is validated once by the composed authority and never re-validated here, so the two contracts share a seam but no duplicated validation. The verdict carries a display status, a boolean, the authoritative entitlement, and one term-end instant — no code, token, role, price, amount, account reference, PII, or command; a source-boundary test enforces the absence of that vocabulary. It is a read-model, so access itself stays gated by §8.0a at the access point. + +This contract invents no policy and duplicates no sibling. It owns the *member-facing status disaggregation and renewal affordance* — which of eight display states this member is in and whether a self-serve renewal is correct — and it is deliberately not any neighbour. §8.0a `deriveMembershipEntitlement` is the authorization gate it composes; this adds no authority and only disaggregates the reason §8.0a collapses. §8.13 `projectMemberDiscounts` is a *consumer* of membership status at a content/pricing surface; this is the *producer* of the display projection such a surface reads, and grants no discount. §8.0g (MEMBERS-DUES-001E) is item 4's token-refresh/revocation disposition — a session-lifecycle decision, not a display read-model. §8.17 / §8.18 / §8.19 reconcile verified payment / reversal / checkout into term *commands*; this issues no command and reconciles no payment. §8.0c `membershipClaimReconciliation` derives the desired *claim value*; this writes and reads no claim. It performs no Auth-claim read or write, no token action, no renewal Checkout creation, no persistence, and no route or UI — the "renewal UX" wiring around this read-model, the actual member-pricing and protected-content enforcement that would consume it, and the owner-configured `renewalWindowMs` all remain with #114 and its dependencies. + +The module is imported by no runtime or Functions index and makes nothing officer- or member-observable. It requires only `node:util` and `./membershipAuthority` (itself `node:util`-only), reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, and changes no current profile/role/claim. Source tests and a merge are not Firebase deployment or live behavior proof. + ### 8.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/functions/membershipAccountStatusProjection.js b/functions/membershipAccountStatusProjection.js new file mode 100644 index 0000000..5f863cd --- /dev/null +++ b/functions/membershipAccountStatusProjection.js @@ -0,0 +1,316 @@ +'use strict'; + +// ============================================================================ +// §8.0h — MEMBERS-DUES-001F — Member/account status projection (item 6 of #114) +// ============================================================================ +// SOURCE ONLY, UNUSED. Imported by nothing (no index, route, callable, Rules, +// or worker) -> zero runtime behavior; nothing here awaits the owner-gated +// deploy. Merging it deploys nothing and changes no live behavior. +// +// One pure, deterministic, NON-THROWING projection for MEMBERS-DUES-001 [#114] +// item 6 -- "Minimum member/account status projection and renewal UX after the +// server contract is proven" -- the member-facing DISPLAY read-model that +// acceptance line 66 requires member pricing and protected content to consume +// ("the server-derived active-membership projection rather than Auth provider or +// raw profile fields"). §8.17 and §8.18 both forward-declare it as unbuilt: "no +// member/account status projection or renewal UX (item 6) ... remain with #114." +// +// It is DELIBERATELY a projection OF the shipped authority §8.0a +// `deriveMembershipEntitlement` (functions/membershipAuthority.js), which it +// COMPOSES rather than re-implements. §8.0a is a clock-consuming but +// reason-COLLAPSING authorization gate: given the same reference instant and +// membership record it returns one of three frozen results -- current_member / +// not_entitled / decision_pending -- collapsing every "why" (a term an hour from +// expiry, an elapsed term, a refund-suspended term, an offboarded ended term, a +// not-yet-started future term, an unlinked record) into the same not_entitled, +// and it applies no renewal-window threshold and offers no renewal affordance. A +// member's own account page and the member-pricing surface need those reasons +// DISAGGREGATED plus a renewal affordance; that disaggregation is the ONLY thing +// this adds. +// +// WHY IT COMPOSES §8.0a (the one source-only core that imports a sibling -- a +// deliberate, documented departure from the node:util-only norm the other cores +// follow): the safety property that matters here is DISPLAY NEVER OVER-STATES +// AUTHORIZATION -- any status that reads as entitled (active / expiring_soon) +// must correspond to a current_member authorization, always. Composing the real +// authority makes that invariant TRUE BY CONSTRUCTION and robust to any future +// change in §8.0a's window logic, and it re-implements NONE of §8.0a's record +// validation (revision math, enum / time / opaque-id checks). Re-implementing +// that validation in a second module would be a divergence risk -- the salami +// failure mode this project's discipline forbids ("a duplicated slice is a new +// failure mode, not redundancy"). The imported sibling is itself pure and +// node:util-only, so purity / determinism / no-I/O hold transitively; a +// source-boundary test locks this module's require set to exactly +// { node:util, ./membershipAuthority } and locks membershipAuthority's own +// require set to { node:util }. +// +// `projectMemberAccountStatus(input, policy)` returns ONE frozen verdict and +// NEVER throws: +// * { decision: 'projected', status, entitlement, renewalOffered, activeThroughMs } +// * { decision: 'denied', reason } reason in { malformed_input | malformed_policy } +// +// input : { membershipAccountStatusSchemaVersion, record, uid, asOfMs } +// `record` is a §8.0a authority record (validated by the composed +// authority, never re-validated here); `uid` is the signed-in member +// the projection is FOR; `asOfMs` is the caller-supplied reference +// instant (this reducer reads no clock). +// policy : { membershipAccountStatusSchemaVersion, renewalWindowMs } +// `renewalWindowMs` is the owner-configured "expiring soon" lead time +// -- the SOLE source of the threshold; this reducer invents none. +// +// status in { active | expiring_soon | upcoming | expired | suspended | ended +// | pending | none } (member-facing display state) +// entitlement in { current_member | not_entitled | decision_pending } +// -- taken VERBATIM from §8.0a (authoritative; the seam) +// renewalOffered in { true | false } -- true ONLY for { expiring_soon | expired } +// activeThroughMs -- the term-end instant to display (integer) for +// { active | expiring_soon | upcoming | expired }; null otherwise. +// +// Safety invariants (all test-locked; invariant 1 proven by composing the REAL +// §8.0a over every fixture): +// 1. Display never over-states authorization: +// status in { active, expiring_soon } <=> entitlement current_member +// status in { upcoming, expired, suspended, +// ended, none } ==> entitlement not_entitled +// status === pending <=> entitlement decision_pending +// 2. Renewal is offered ONLY where a safe self-serve re-purchase is correct: +// renewalOffered <=> status in { expiring_soon, expired }. NEVER for a +// suspended term (refund/dispute clawback -- re-purchase could re-grant +// disputed access or double-charge), an ended term (offboarding -- owner +// re-admits), a pending decision, an already-renewed upcoming term, a +// comfortably-active term, or a non-member ('none' is a join, not a renewal). +// 3. Invents no policy: the "expiring soon" lead time is policy.renewalWindowMs +// (owner configuration -- the sole source); the reducer only compares +// endsAtMs - asOfMs <= renewalWindowMs. No hardcoded window. (cf. the #114 +// owner-decision rule -- invent no grace / terms / prices.) +// 4. Clockless: consumes asOfMs; constructs no Date. Same instant -> same +// projection. +// 5. Confers nothing / mints nothing: the verdict carries a display status, a +// boolean, the authoritative entitlement, and one term-end instant -- no +// code, token, role, price, amount, account reference, PII, or command. +// It is a READ-MODEL; access stays gated by §8.0a at the access point. +// 6. Hostile-input-safe & non-throwing: input and policy are read through a +// descriptor-based closed-object read (getters never invoked; proxy / +// revoked proxy / foreign prototype / array / symbol key / extra / missing +// -> malformed); the record is validated by the composed §8.0a, whose +// throw on any malformed record is caught and mapped to malformed_input; +// never throws for any value in either argument position. +// ============================================================================ + +const { types: { isProxy } } = require('node:util'); +const { + deriveMembershipEntitlement, + membershipAuthoritySchemaVersion, +} = require('./membershipAuthority'); + +const membershipAccountStatusSchemaVersion = 1; + +const MAX_TIME_MS = 8_640_000_000_000_000; + +// The same opaque-identifier grammar §8.0a enforces on a linked association's +// uid. Kept as a local literal (the pattern is not an exported symbol) purely to +// validate the `uid` the projection is FOR; the record's own identifiers are +// validated by the composed authority. +const OPAQUE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +const INPUT_FIELDS = Object.freeze([ + 'membershipAccountStatusSchemaVersion', + 'record', + 'uid', + 'asOfMs', +]); + +const POLICY_FIELDS = Object.freeze([ + 'membershipAccountStatusSchemaVersion', + 'renewalWindowMs', +]); + +// Member-facing display states. +const STATUS = Object.freeze({ + active: 'active', + expiringSoon: 'expiring_soon', + upcoming: 'upcoming', + expired: 'expired', + suspended: 'suspended', + ended: 'ended', + pending: 'pending', + none: 'none', +}); + +const DENIAL_REASONS = Object.freeze(['malformed_input', 'malformed_policy']); + +// ---- hostile-input-safe closed-object read --------------------------------- +// Read an exact, closed record: an ordinary object whose own string-keyed +// properties are precisely `expectedFields`, each an enumerable data property. +// Returns a null-prototype copy read with no getter ever invoked, or null on any +// deviation (proxy, array, foreign prototype, symbol key, wrong key count, +// missing, extra -- enumerable OR non-enumerable -- inherited, accessor, or +// non-enumerable field). Same idiom as the membership dues siblings. +function readExact(value, expectedFields) { + if (value === null || typeof value !== 'object') return null; + // isProxy before Array.isArray: Array.isArray throws on a revoked proxy, while + // isProxy safely reports it as a proxy (which this rejects). Order matters for + // total, never-throwing behavior. + if (isProxy(value)) return null; + if (Array.isArray(value)) return null; + if (Object.getPrototypeOf(value) !== Object.prototype) return null; + if (Object.getOwnPropertySymbols(value).length !== 0) return null; + // Own property NAMES, not just enumerable keys: a non-enumerable extra own + // property must also deny -- it is invisible to Object.keys but would still + // make the record something other than the exact closed shape. + if (Object.getOwnPropertyNames(value).length !== expectedFields.length) return null; + const keys = Object.keys(value); + if (keys.length !== expectedFields.length) return null; + for (const key of keys) { + if (!expectedFields.includes(key)) return null; + } + const out = Object.create(null); + for (const field of expectedFields) { + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (!descriptor) return null; + if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) return null; + if (!descriptor.enumerable) return null; + out[field] = descriptor.value; + } + return out; +} + +function isOpaqueIdentifier(value) { + return typeof value === 'string' && OPAQUE_IDENTIFIER_PATTERN.test(value); +} + +function isTimeMs(value) { + return Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIME_MS; +} + +// ---- verdict constructors -------------------------------------------------- + +const DENIALS = Object.freeze(Object.fromEntries( + DENIAL_REASONS.map((reason) => [reason, Object.freeze({ decision: 'denied', reason })]), +)); + +function deny(reason) { + return DENIALS[reason]; +} + +function project(status, entitlement, renewalOffered, activeThroughMs) { + return Object.freeze({ + decision: 'projected', + status, + entitlement, + renewalOffered, + activeThroughMs, + }); +} + +// Read exactly the record fields the display graduation consumes. Only ever +// called AFTER `deriveMembershipEntitlement` has SUCCEEDED on this record, which +// proves the record (and its nested association / term) are plain inert data -- +// the authority rejects any proxy or accessor at every level it reads -- so +// these direct reads are TOCTOU-free. Type-guarded defensively regardless; any +// surprise (impossible post-success) resolves to null -> malformed_input. +function readGraduationView(record) { + if (record === null || typeof record !== 'object') return null; + const association = record.association; + const term = record.term; + if (association === null || typeof association !== 'object') return null; + if (term === null || typeof term !== 'object') return null; + const associationState = association.state; + const associationUid = association.uid; + const termState = term.state; + const startsAtMs = term.startsAtMs; + const endsAtMs = term.endsAtMs; + if (typeof associationState !== 'string') return null; + if (associationUid !== null && !isOpaqueIdentifier(associationUid)) return null; + if (typeof termState !== 'string') return null; + return { associationState, associationUid, termState, startsAtMs, endsAtMs }; +} + +// ---- the projection -------------------------------------------------------- +function projectMemberAccountStatus(input, policy) { + const inp = readExact(input, INPUT_FIELDS); + if (inp === null) return deny('malformed_input'); + if (inp.membershipAccountStatusSchemaVersion !== membershipAccountStatusSchemaVersion) { + return deny('malformed_input'); + } + if (!isOpaqueIdentifier(inp.uid)) return deny('malformed_input'); + if (!isTimeMs(inp.asOfMs)) return deny('malformed_input'); + + const pol = readExact(policy, POLICY_FIELDS); + if (pol === null) return deny('malformed_policy'); + if (pol.membershipAccountStatusSchemaVersion !== membershipAccountStatusSchemaVersion) { + return deny('malformed_policy'); + } + if (!isTimeMs(pol.renewalWindowMs)) return deny('malformed_policy'); + + // Compose the shipped authority. It fully validates the record (revision math, + // enums, times, opaque ids) and returns the authoritative entitlement; any + // malformed record makes it throw MembershipAuthorityError, caught here and + // mapped to malformed_input so this projection never throws. + let authority; + try { + authority = deriveMembershipEntitlement({ + membershipAuthoritySchemaVersion, + record: inp.record, + uid: inp.uid, + asOfMs: inp.asOfMs, + }); + } catch { + return deny('malformed_input'); + } + const entitlement = authority.entitlement; + + const view = readGraduationView(inp.record); + if (view === null) return deny('malformed_input'); // impossible post-success + const { associationState, associationUid, termState, startsAtMs, endsAtMs } = view; + const asOfMs = inp.asOfMs; + const renewalWindowMs = pol.renewalWindowMs; + + // Disaggregate WITHIN each authoritative entitlement bucket. Every branch takes + // its entitlement verbatim from the authority, so the display status can never + // contradict authorization (invariant 1). + + if (entitlement === 'decision_pending') { + // §8.0a returns decision_pending only for a linked, matched association whose + // term is decision_pending -- no term window to display. + return project(STATUS.pending, entitlement, false, null); + } + + if (entitlement === 'current_member') { + // §8.0a's ONLY current_member branch: linked + matched association, approved + // term, asOfMs in [startsAtMs, endsAtMs). The term therefore has valid dates; + // guard defensively. + if (!isTimeMs(startsAtMs) || !isTimeMs(endsAtMs)) return deny('malformed_input'); + const nearing = endsAtMs - asOfMs <= renewalWindowMs; + const status = nearing ? STATUS.expiringSoon : STATUS.active; + return project(status, entitlement, nearing, endsAtMs); + } + + // entitlement === 'not_entitled'. §8.0a returns it, in order, for: association + // not linked or uid mismatch (checked FIRST, before any term state); else a + // suspended term; else an ended term; else an approved term outside its window. + if (associationState !== 'linked' || associationUid !== inp.uid) { + return project(STATUS.none, entitlement, false, null); + } + if (termState === 'suspended') { + return project(STATUS.suspended, entitlement, false, null); + } + if (termState === 'ended') { + return project(STATUS.ended, entitlement, false, null); + } + // Guaranteed here: an APPROVED term outside its window (the authority returned + // not_entitled for a linked, matched, non-suspended, non-ended term, and a + // decision_pending term would have been the decision_pending bucket). So + // exactly one of asOfMs < startsAtMs (not yet started) or asOfMs >= endsAtMs + // (elapsed) holds. Guard the dates defensively. + if (!isTimeMs(startsAtMs) || !isTimeMs(endsAtMs)) return deny('malformed_input'); + if (asOfMs < startsAtMs) { + return project(STATUS.upcoming, entitlement, false, endsAtMs); + } + return project(STATUS.expired, entitlement, true, endsAtMs); +} + +module.exports = Object.freeze({ + membershipAccountStatusSchemaVersion, + projectMemberAccountStatus, +}); diff --git a/functions/membershipAccountStatusProjection.test.js b/functions/membershipAccountStatusProjection.test.js new file mode 100644 index 0000000..253d162 --- /dev/null +++ b/functions/membershipAccountStatusProjection.test.js @@ -0,0 +1,632 @@ +'use strict'; + +// Tests for §8.0h MEMBERS-DUES-001F `projectMemberAccountStatus` — the +// member-facing account/status DISPLAY projection (item 6 of #114). The module +// is SOURCE ONLY and imported by nothing; these tests exercise it directly. +// +// The centerpiece is the CONSISTENCY SEAM: every fixture's projected entitlement +// is proven equal to what the REAL shipped authority §8.0a +// `deriveMembershipEntitlement` derives from the same record — the by-construction +// proof that display never over-states authorization. + +const fs = require('fs'); +const path = require('path'); + +const { + projectMemberAccountStatus, + membershipAccountStatusSchemaVersion, +} = require('./membershipAccountStatusProjection'); +// The real authority — composed here to prove the cross-module invariant, exactly +// as the membership-dues siblings feed their emitted command to the real reducer. +const { + deriveMembershipEntitlement, + membershipAuthoritySchemaVersion, +} = require('./membershipAuthority'); + +const V = membershipAccountStatusSchemaVersion; // 1 +const AUTH_V = membershipAuthoritySchemaVersion; // 1 + +const UID = 'uid_member_001'; +const OTHER_UID = 'uid_member_999'; + +const DAY = 86_400_000; +const STARTS = 1_700_000_000_000; +const ENDS = STARTS + 365 * DAY; +const WINDOW = 30 * DAY; // owner-configured "expiring soon" lead time + +// ---- §8.0a record fixtures (revision-consistent so the real authority accepts) -- +const linkedAssoc = Object.freeze({ state: 'linked', uid: UID, revision: 1 }); +const otherAssoc = Object.freeze({ state: 'linked', uid: OTHER_UID, revision: 1 }); +const unlinkedAssoc = Object.freeze({ state: 'unlinked', uid: null, revision: 0 }); + +const pendingTerm = Object.freeze({ + state: 'decision_pending', + termId: null, + startsAtMs: null, + endsAtMs: null, + planRef: null, + evidenceRef: null, + policyVersion: null, + revision: 0, +}); + +function decidedTerm(state, startsAtMs, endsAtMs) { + return { + state, + termId: 'term_0001', + startsAtMs, + endsAtMs, + planRef: 'plan_annual', + evidenceRef: 'ev_0001', + policyVersion: 'policy_v1', + revision: 1, + }; +} + +function makeRecord({ association, term, revision, commandType, commandId }) { + return { + membershipAuthoritySchemaVersion: AUTH_V, + membershipId: 'mem_0001', + revision, + association, + term, + lastCommand: { commandType, commandId, expectedRevision: revision - 1 }, + }; +} + +// linked + approved/suspended/ended term: revision 3, record_term_decision. +function decidedRecord(state, startsAtMs, endsAtMs, association = linkedAssoc) { + return makeRecord({ + association, + term: decidedTerm(state, startsAtMs, endsAtMs), + revision: 3, + commandType: 'record_term_decision', + commandId: 'cmd_term', + }); +} +// linked + decision_pending term: revision 2, associate_account. +const pendingRecord = makeRecord({ + association: linkedAssoc, + term: pendingTerm, + revision: 2, + commandType: 'associate_account', + commandId: 'cmd_assoc', +}); +// unlinked + decision_pending term: revision 1, create_membership. +const unlinkedRecord = makeRecord({ + association: unlinkedAssoc, + term: pendingTerm, + revision: 1, + commandType: 'create_membership', + commandId: 'cmd_create', +}); + +function input(record, asOfMs, uid = UID) { + return { membershipAccountStatusSchemaVersion: V, record, uid, asOfMs }; +} +function policy(renewalWindowMs) { + return { membershipAccountStatusSchemaVersion: V, renewalWindowMs }; +} + +// ---- the decision matrix: named rows with the full expected verdict --------- +const ROWS = [ + { + name: 'active (comfortably in window)', + record: decidedRecord('approved', STARTS, ENDS), + asOfMs: STARTS + 100 * DAY, + window: WINDOW, + status: 'active', entitlement: 'current_member', + renewalOffered: false, activeThroughMs: ENDS, + }, + { + name: 'expiring_soon (within renewal window)', + record: decidedRecord('approved', STARTS, ENDS), + asOfMs: ENDS - 10 * DAY, + window: WINDOW, + status: 'expiring_soon', entitlement: 'current_member', + renewalOffered: true, activeThroughMs: ENDS, + }, + { + name: 'upcoming (approved but not yet started — early renewal)', + record: decidedRecord('approved', STARTS, ENDS), + asOfMs: STARTS - 5 * DAY, + window: WINDOW, + status: 'upcoming', entitlement: 'not_entitled', + renewalOffered: false, activeThroughMs: ENDS, + }, + { + name: 'expired (approved but window elapsed)', + record: decidedRecord('approved', STARTS, ENDS), + asOfMs: ENDS + 5 * DAY, + window: WINDOW, + status: 'expired', entitlement: 'not_entitled', + renewalOffered: true, activeThroughMs: ENDS, + }, + { + name: 'suspended (refund/dispute clawback — even while in window)', + record: decidedRecord('suspended', STARTS, ENDS), + asOfMs: STARTS + 100 * DAY, + window: WINDOW, + status: 'suspended', entitlement: 'not_entitled', + renewalOffered: false, activeThroughMs: null, + }, + { + name: 'ended (offboarded — even while in window)', + record: decidedRecord('ended', STARTS, ENDS), + asOfMs: STARTS + 100 * DAY, + window: WINDOW, + status: 'ended', entitlement: 'not_entitled', + renewalOffered: false, activeThroughMs: null, + }, + { + name: 'pending (decision_pending term)', + record: pendingRecord, + asOfMs: STARTS + 100 * DAY, + window: WINDOW, + status: 'pending', entitlement: 'decision_pending', + renewalOffered: false, activeThroughMs: null, + }, + { + name: 'none (unlinked association)', + record: unlinkedRecord, + asOfMs: STARTS + 100 * DAY, + window: WINDOW, + status: 'none', entitlement: 'not_entitled', + renewalOffered: false, activeThroughMs: null, + }, + { + name: 'none (linked to a DIFFERENT uid — approved in-window, must NOT be active)', + record: decidedRecord('approved', STARTS, ENDS, otherAssoc), + asOfMs: STARTS + 100 * DAY, + window: WINDOW, + status: 'none', entitlement: 'not_entitled', + renewalOffered: false, activeThroughMs: null, + }, +]; + +function verdictOf(row) { + return projectMemberAccountStatus(input(row.record, row.asOfMs), policy(row.window)); +} + +describe('projectMemberAccountStatus — decision matrix', () => { + test.each(ROWS)('$name', (row) => { + expect(verdictOf(row)).toEqual({ + decision: 'projected', + status: row.status, + entitlement: row.entitlement, + renewalOffered: row.renewalOffered, + activeThroughMs: row.activeThroughMs, + }); + }); + + test('every display status in the matrix is covered', () => { + const seen = new Set(ROWS.map((r) => r.status)); + for (const s of ['active', 'expiring_soon', 'upcoming', 'expired', + 'suspended', 'ended', 'pending', 'none']) { + expect(seen.has(s)).toBe(true); + } + }); +}); + +describe('the consistency seam — display never over-states authorization', () => { + // Invariant 1, proven against the REAL §8.0a for every matrix row. + test.each(ROWS)('projection.entitlement === deriveMembershipEntitlement for: $name', (row) => { + const authority = deriveMembershipEntitlement({ + membershipAuthoritySchemaVersion: AUTH_V, + record: row.record, + uid: UID, + asOfMs: row.asOfMs, + }); + const verdict = verdictOf(row); + // the authority accepts every fixture (never throws) and the projection + // echoes its entitlement verbatim + expect(verdict.decision).toBe('projected'); + expect(verdict.entitlement).toBe(authority.entitlement); + expect(verdict.entitlement).toBe(row.entitlement); + }); + + test('entitled-reading statuses map to current_member; the rest never do', () => { + for (const row of ROWS) { + const v = verdictOf(row); + if (v.status === 'active' || v.status === 'expiring_soon') { + expect(v.entitlement).toBe('current_member'); + } else { + expect(v.entitlement).not.toBe('current_member'); + } + if (v.status === 'pending') { + expect(v.entitlement).toBe('decision_pending'); + } + if (['upcoming', 'expired', 'suspended', 'ended', 'none'].includes(v.status)) { + expect(v.entitlement).toBe('not_entitled'); + } + } + }); + + test('a fuzzed sweep of instants never lets a not-current display read as entitled', () => { + // Sweep asOf across the whole timeline for the approved/suspended/other-uid + // records; for each, the projection's "entitled" reading must agree with the + // real authority, never over-stating. + const records = [ + decidedRecord('approved', STARTS, ENDS), + decidedRecord('suspended', STARTS, ENDS), + decidedRecord('ended', STARTS, ENDS), + decidedRecord('approved', STARTS, ENDS, otherAssoc), + pendingRecord, + unlinkedRecord, + ]; + for (const record of records) { + for (let k = -3; k <= 370; k += 7) { + const asOfMs = STARTS + k * DAY; + if (asOfMs < 0) continue; + const authority = deriveMembershipEntitlement({ + membershipAuthoritySchemaVersion: AUTH_V, record, uid: UID, asOfMs, + }); + const v = projectMemberAccountStatus(input(record, asOfMs), policy(WINDOW)); + expect(v.decision).toBe('projected'); + expect(v.entitlement).toBe(authority.entitlement); + const readsEntitled = v.status === 'active' || v.status === 'expiring_soon'; + expect(readsEntitled).toBe(authority.entitlement === 'current_member'); + } + } + }); +}); + +describe('invariant 2 — renewal offered only for a safe self-serve re-purchase', () => { + test.each(ROWS)('renewalOffered iff status in {expiring_soon, expired}: $name', (row) => { + const v = verdictOf(row); + expect(v.renewalOffered).toBe(v.status === 'expiring_soon' || v.status === 'expired'); + }); + + test('a suspended term never offers renewal at any instant (clawback safety)', () => { + const record = decidedRecord('suspended', STARTS, ENDS); + for (let k = -3; k <= 370; k += 11) { + const asOfMs = STARTS + k * DAY; + if (asOfMs < 0) continue; + const v = projectMemberAccountStatus(input(record, asOfMs), policy(WINDOW)); + expect(v.status).toBe('suspended'); + expect(v.renewalOffered).toBe(false); + } + }); + + test('an ended term never offers renewal at any instant (owner re-admits)', () => { + const record = decidedRecord('ended', STARTS, ENDS); + for (let k = -3; k <= 370; k += 11) { + const asOfMs = STARTS + k * DAY; + if (asOfMs < 0) continue; + const v = projectMemberAccountStatus(input(record, asOfMs), policy(WINDOW)); + expect(v.status).toBe('ended'); + expect(v.renewalOffered).toBe(false); + } + }); +}); + +describe('invariant 3 — the expiring-soon threshold is owner policy, never invented', () => { + const record = decidedRecord('approved', STARTS, ENDS); + + test('window boundary is inclusive: endsAtMs - asOfMs === window is expiring_soon', () => { + const asOfMs = ENDS - WINDOW; // exactly WINDOW remaining + expect(verdictOf({ record, asOfMs, window: WINDOW }).status).toBe('expiring_soon'); + }); + + test('one ms more than the window remaining is still active', () => { + const asOfMs = ENDS - WINDOW - 1; // WINDOW + 1 remaining + expect(verdictOf({ record, asOfMs, window: WINDOW }).status).toBe('active'); + }); + + test('window 0 never yields expiring_soon (active flips straight to expired)', () => { + expect(verdictOf({ record, asOfMs: ENDS - 1, window: 0 }).status).toBe('active'); + expect(verdictOf({ record, asOfMs: ENDS, window: 0 }).status).toBe('expired'); + }); + + test('a window wider than the term makes an in-window term perpetually expiring_soon', () => { + const asOfMs = STARTS + 1; + expect(verdictOf({ record, asOfMs, window: 400 * DAY }).status).toBe('expiring_soon'); + }); + + test('the same instant with different windows moves only the active/expiring_soon line', () => { + const asOfMs = ENDS - 20 * DAY; // 20 days remaining + expect(verdictOf({ record, asOfMs, window: 10 * DAY }).status).toBe('active'); + expect(verdictOf({ record, asOfMs, window: 30 * DAY }).status).toBe('expiring_soon'); + }); +}); + +describe('window/term boundaries', () => { + const record = decidedRecord('approved', STARTS, ENDS); + + test('asOfMs === startsAtMs is in window (not upcoming)', () => { + // with a wide-enough window this is active; the point is it is NOT upcoming + const v = verdictOf({ record, asOfMs: STARTS, window: 0 }); + expect(v.status).toBe('active'); + expect(v.entitlement).toBe('current_member'); + }); + + test('asOfMs === startsAtMs - 1 is upcoming', () => { + const v = verdictOf({ record, asOfMs: STARTS - 1, window: WINDOW }); + expect(v.status).toBe('upcoming'); + expect(v.entitlement).toBe('not_entitled'); + }); + + test('asOfMs === endsAtMs is expired (never expiring_soon or active)', () => { + const v = verdictOf({ record, asOfMs: ENDS, window: 400 * DAY }); + expect(v.status).toBe('expired'); + expect(v.entitlement).toBe('not_entitled'); + expect(v.renewalOffered).toBe(true); + }); + + test('asOfMs === endsAtMs - 1 is in window', () => { + const v = verdictOf({ record, asOfMs: ENDS - 1, window: 0 }); + expect(v.status).toBe('active'); + expect(v.entitlement).toBe('current_member'); + }); +}); + +// ---- malformed input battery ------------------------------------------------ +describe('malformed input → denied malformed_input', () => { + const good = () => input(decidedRecord('approved', STARTS, ENDS), STARTS + DAY); + const D = { decision: 'denied', reason: 'malformed_input' }; + + test('null / undefined / primitive / array', () => { + for (const bad of [null, undefined, 0, 1, '', 'x', true, Symbol('s'), 42n, [], [1, 2]]) { + expect(projectMemberAccountStatus(bad, policy(WINDOW))).toEqual(D); + } + }); + + test('proxy input (incl. revoked) is rejected, not trapped', () => { + const p = new Proxy(good(), { get() { throw new Error('trap'); } }); + expect(projectMemberAccountStatus(p, policy(WINDOW))).toEqual(D); + const { proxy, revoke } = Proxy.revocable(good(), {}); + revoke(); + expect(projectMemberAccountStatus(proxy, policy(WINDOW))).toEqual(D); + }); + + test('foreign prototype / null prototype', () => { + const nullProto = Object.assign(Object.create(null), good()); + expect(projectMemberAccountStatus(nullProto, policy(WINDOW))).toEqual(D); + class Bag {} + const foreign = Object.assign(new Bag(), good()); + expect(projectMemberAccountStatus(foreign, policy(WINDOW))).toEqual(D); + }); + + test('missing / extra / inherited / symbol / accessor keys', () => { + const base = good(); + const missing = { membershipAccountStatusSchemaVersion: V, record: base.record, uid: UID }; + expect(projectMemberAccountStatus(missing, policy(WINDOW))).toEqual(D); + const extra = { ...base, sneaky: 1 }; + expect(projectMemberAccountStatus(extra, policy(WINDOW))).toEqual(D); + const inherited = Object.create({ asOfMs: STARTS + DAY }); + Object.assign(inherited, { + membershipAccountStatusSchemaVersion: V, record: base.record, uid: UID, + }); + expect(projectMemberAccountStatus(inherited, policy(WINDOW))).toEqual(D); + const sym = { ...base, [Symbol('x')]: 1 }; + expect(projectMemberAccountStatus(sym, policy(WINDOW))).toEqual(D); + const accessor = { ...base }; + let touched = false; + Object.defineProperty(accessor, 'asOfMs', { enumerable: true, get() { touched = true; return STARTS; } }); + // redefine as accessor over an existing key: need to delete first + delete accessor.asOfMs; + Object.defineProperty(accessor, 'asOfMs', { enumerable: true, get() { touched = true; return STARTS; } }); + expect(projectMemberAccountStatus(accessor, policy(WINDOW))).toEqual(D); + expect(touched).toBe(false); + const nonEnum = { ...base }; + delete nonEnum.uid; + Object.defineProperty(nonEnum, 'uid', { enumerable: false, value: UID, writable: true, configurable: true }); + expect(projectMemberAccountStatus(nonEnum, policy(WINDOW))).toEqual(D); + }); + + test('bad schema version', () => { + for (const bad of [0, 2, '1', 1.5, null, undefined, true]) { + const i = { ...good(), membershipAccountStatusSchemaVersion: bad }; + expect(projectMemberAccountStatus(i, policy(WINDOW))).toEqual(D); + } + }); + + test('bad uid (non-opaque, empty, phone-shape allowed as opaque, wrong type)', () => { + for (const bad of [null, undefined, 0, 42, '', ' ', 'has space', 'x'.repeat(129), + 'bad/slash', 'trailing\n', {}, []]) { + const i = { ...good(), uid: bad }; + expect(projectMemberAccountStatus(i, policy(WINDOW))).toEqual(D); + } + }); + + test('bad asOfMs (non-integer, negative, float, out of range, wrong type)', () => { + for (const bad of [null, undefined, -1, 1.5, NaN, Infinity, '0', 8_640_000_000_000_001, {}]) { + const i = { ...good(), asOfMs: bad }; + expect(projectMemberAccountStatus(i, policy(WINDOW))).toEqual(D); + } + }); +}); + +describe('malformed record (via the composed authority) → denied malformed_input', () => { + const D = { decision: 'denied', reason: 'malformed_input' }; + const bads = { + 'null record': null, + 'array record': [], + 'missing lastCommand': (() => { + const r = decidedRecord('approved', STARTS, ENDS); delete r.lastCommand; return r; + })(), + 'extra field': { ...decidedRecord('approved', STARTS, ENDS), extra: 1 }, + 'bad revision math': { ...decidedRecord('approved', STARTS, ENDS), revision: 99 }, + 'bad term.state enum': (() => { + const r = decidedRecord('approved', STARTS, ENDS); r.term = { ...r.term, state: 'frozen' }; return r; + })(), + 'ill-ordered term window': (() => { + const r = decidedRecord('approved', ENDS, STARTS); return r; // startsAtMs >= endsAtMs + })(), + 'wrong authority schema version': { ...decidedRecord('approved', STARTS, ENDS), membershipAuthoritySchemaVersion: 2 }, + 'non-opaque membershipId': { ...decidedRecord('approved', STARTS, ENDS), membershipId: 'bad id' }, + 'proxy record': new Proxy(decidedRecord('approved', STARTS, ENDS), {}), + 'getter on association': (() => { + const r = decidedRecord('approved', STARTS, ENDS); + const assoc = { ...linkedAssoc }; + delete r.association; + Object.defineProperty(r, 'association', { enumerable: true, get() { return assoc; } }); + return r; + })(), + }; + + test.each(Object.entries(bads))('%s → denied AND the real authority also rejects it', (_name, badRecord) => { + // the projection denies + expect(projectMemberAccountStatus(input(badRecord, STARTS + DAY), policy(WINDOW))).toEqual(D); + // and the seam is honest: the composed authority rejects the same record + expect(() => deriveMembershipEntitlement({ + membershipAuthoritySchemaVersion: AUTH_V, record: badRecord, uid: UID, asOfMs: STARTS + DAY, + })).toThrow(); + }); +}); + +describe('malformed policy → denied malformed_policy', () => { + const good = () => input(decidedRecord('approved', STARTS, ENDS), STARTS + DAY); + const D = { decision: 'denied', reason: 'malformed_policy' }; + + test('non-object / proxy / array', () => { + for (const bad of [null, undefined, 0, 'x', true, [], new Proxy(policy(WINDOW), {})]) { + expect(projectMemberAccountStatus(good(), bad)).toEqual(D); + } + }); + + test('bad schema version / missing / extra keys', () => { + expect(projectMemberAccountStatus(good(), { membershipAccountStatusSchemaVersion: 2, renewalWindowMs: WINDOW })).toEqual(D); + expect(projectMemberAccountStatus(good(), { membershipAccountStatusSchemaVersion: V })).toEqual(D); + expect(projectMemberAccountStatus(good(), { ...policy(WINDOW), extra: 1 })).toEqual(D); + }); + + test('bad renewalWindowMs (negative, float, NaN, huge, wrong type)', () => { + for (const bad of [null, undefined, -1, 1.5, NaN, Infinity, '0', 8_640_000_000_000_001, {}]) { + expect(projectMemberAccountStatus(good(), { membershipAccountStatusSchemaVersion: V, renewalWindowMs: bad })).toEqual(D); + } + }); + + test('input malformity is reported before policy malformity', () => { + // both args malformed → the input denial wins (input is validated first) + expect(projectMemberAccountStatus(null, null)).toEqual({ decision: 'denied', reason: 'malformed_input' }); + }); +}); + +// ---- total, never-throwing behavior ---------------------------------------- +describe('never throws for any value in either argument position', () => { + const hostile = [ + null, undefined, 0, NaN, '', 'x', true, Symbol('s'), 42n, [], {}, + new Proxy({}, { get() { throw new Error('x'); }, ownKeys() { throw new Error('x'); } }), + (() => { const { proxy, revoke } = Proxy.revocable({}, {}); revoke(); return proxy; })(), + (() => { const o = {}; o.self = o; return o; })(), + Object.create(null), + ]; + test('input position', () => { + for (const h of hostile) { + expect(() => projectMemberAccountStatus(h, policy(WINDOW))).not.toThrow(); + const v = projectMemberAccountStatus(h, policy(WINDOW)); + expect(v.decision).toBe('denied'); + } + }); + test('policy position (with a valid input)', () => { + const good = input(decidedRecord('approved', STARTS, ENDS), STARTS + DAY); + for (const h of hostile) { + expect(() => projectMemberAccountStatus(good, h)).not.toThrow(); + } + }); + test('record position (nested hostile record)', () => { + for (const h of hostile) { + const i = { membershipAccountStatusSchemaVersion: V, record: h, uid: UID, asOfMs: STARTS + DAY }; + expect(() => projectMemberAccountStatus(i, policy(WINDOW))).not.toThrow(); + expect(projectMemberAccountStatus(i, policy(WINDOW)).decision).toBe('denied'); + } + }); +}); + +// ---- determinism & immutability -------------------------------------------- +describe('determinism, immutability, and mint-nothing surface', () => { + test('the projected verdict is deeply frozen', () => { + const v = verdictOf(ROWS[0]); + expect(Object.isFrozen(v)).toBe(true); + expect(() => { 'use strict'; v.status = 'x'; }).toThrow(TypeError); + }); + + test('denial verdicts are frozen singletons (context-free)', () => { + const a = projectMemberAccountStatus(null, policy(WINDOW)); + const b = projectMemberAccountStatus(42, policy(WINDOW)); + expect(a).toBe(b); + expect(Object.isFrozen(a)).toBe(true); + }); + + test('same inputs → identical verdict (clockless determinism)', () => { + const a = verdictOf(ROWS[1]); + const b = verdictOf(ROWS[1]); + expect(a).toEqual(b); + }); + + test('mutating the input object after the call does not change the returned verdict', () => { + const record = decidedRecord('approved', STARTS, ENDS); + const i = input(record, STARTS + 100 * DAY); + const v = projectMemberAccountStatus(i, policy(WINDOW)); + i.asOfMs = ENDS + DAY; + i.uid = OTHER_UID; + record.term.state = 'suspended'; + expect(v).toEqual({ + decision: 'projected', status: 'active', entitlement: 'current_member', + renewalOffered: false, activeThroughMs: ENDS, + }); + }); + + test('the verdict carries no code/token/role/price/PII vocabulary', () => { + for (const row of ROWS) { + const s = JSON.stringify(verdictOf(row)); + expect(s).not.toMatch(/\b(code|token|secret|password|hash|ssn|dob|email|address|amount|price|role|claim)\b/i); + } + }); + + test('the verdict shape is exactly the documented keys', () => { + const v = verdictOf(ROWS[0]); + expect(Object.keys(v).sort()).toEqual( + ['activeThroughMs', 'decision', 'entitlement', 'renewalOffered', 'status'], + ); + }); +}); + +// ---- source-boundary checks ------------------------------------------------- +describe('source-boundary — pure, composes only the authority, imported by nothing', () => { + const moduleFile = path.join(__dirname, 'membershipAccountStatusProjection.js'); + const authorityFile = path.join(__dirname, 'membershipAuthority.js'); + const raw = fs.readFileSync(moduleFile, 'utf8'); + // strip block then line comments so vocabulary/require checks see code only + const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); + + function requiresOf(src) { + const out = new Set(); + const re = /require\(\s*(['"])([^'"]+)\1\s*\)/g; + let m; + while ((m = re.exec(src)) !== null) out.add(m[2]); + return out; + } + + test('this module requires exactly node:util and ./membershipAuthority', () => { + expect([...requiresOf(code)].sort()).toEqual(['./membershipAuthority', 'node:util']); + }); + + test('the composed authority is itself pure (requires only node:util) — transitive purity', () => { + const authRaw = fs.readFileSync(authorityFile, 'utf8'); + const authCode = authRaw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); + expect([...requiresOf(authCode)]).toEqual(['node:util']); + }); + + test('the header pins §8.0h / MEMBERS-DUES-001F and the SOURCE ONLY status', () => { + expect(raw).toContain('§8.0h'); + expect(raw).toContain('MEMBERS-DUES-001F'); + expect(raw).toContain('SOURCE ONLY, UNUSED'); + }); + + test('code (comment-stripped) holds no credential/PII/mint vocabulary', () => { + expect(code).not.toMatch(/\b(secret|password|hash|ssn|dob|apikey|api_key|bearer|privatekey)\b/i); + }); + + test('no other functions/ module imports this one (imported by nothing)', () => { + const dir = __dirname; + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js') + && f !== 'membershipAccountStatusProjection.js' + && f !== 'membershipAccountStatusProjection.test.js'); + for (const f of files) { + const src = fs.readFileSync(path.join(dir, f), 'utf8'); + expect(src).not.toMatch(/membershipAccountStatusProjection/); + } + }); +});