From 6eb57048cedc72d05771798f9a6f2d0b9b74e762 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 21 Jul 2026 10:59:45 -0700 Subject: [PATCH 1/2] WHATSAPP-002B: pure one-time verification-code issuance decision (source only) --- SYSTEM_DESIGN.md | 33 + functions/whatsappVerificationIssuance.js | 371 ++++++++++ .../whatsappVerificationIssuance.test.js | 688 ++++++++++++++++++ 3 files changed, 1092 insertions(+) create mode 100644 functions/whatsappVerificationIssuance.js create mode 100644 functions/whatsappVerificationIssuance.test.js diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 43f061f..bc7bcd2 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1337,6 +1337,39 @@ This contract invents no policy and duplicates no sibling. It owns the *reminder The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; holds no event content and no secret; mints only the deterministic in-memory `draftId` it returns; performs or persists nothing beyond the returned verdict; and can cause no draft creation, approval, or publish. It defines no Firestore schema or Rules, runs no reconciler or scheduler, and wires into no route or Instagram/provider adapter. Source change, tests, merge, Firebase deployment, provider configuration, production data, website publication, and live behavior remain separate states; #433 generates no real draft and proves none of the external or live states. +### 8.23 WhatsApp one-time verification-code issuance decision — SOURCE ONLY, UNUSED + +WHATSAPP-002B [#435](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/435) builds the **issuance core** of parent WHATSAPP-002 [#87](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/87) (*Link a verified WhatsApp number and versioned consent to a member UID*): one unused pure contract for the scope line *"generate a one-time server code … make it short-lived, one-use, rate/attempt limited, and App Check protected"* — specifically the half the redemption reducer §8.16 leaves with #87: *whether the server may issue a new one-time code for one (member, phone) pair now, and whether doing so supersedes a still-live prior challenge.* It is a **non-throwing frozen-verdict** reducer in the idiom of the access-gate siblings (§8.15, §8.16, §8.22), here an issuance/anti-abuse gate that composes with — and is strictly distinct from — the §8.16 redemption reducer: §8.16 bounds how many *guesses* a single challenge permits, while this bounds how many *challenges* exist and how *fast* they may be minted for a pair. It **mints nothing and holds no code**: it carries no code, hash, or new challenge id — the code, its hash, the new challenge reference, the code lifetime, the attempt budget, and the next cooldown are all the caller's to apply after an `issue` verdict; the module deliberately contains no code, hash, secret, token, or bearer vocabulary at all, and a source-boundary test enforces that absence. The pure `classifyWhatsappVerificationIssuance(request, currentChallenge)` reads the issuance request `{ verificationIssuanceSchemaVersion, memberRef, phoneRef, actor, asOf }` and either a literal `null` (no prior challenge) or the issuance-relevant projection of the durable challenge `{ verificationIssuanceSchemaVersion, challengeRef, memberRef, phoneRef, status, expiresAt, reissueAfter }` — where `status ∈ { pending | verified | voided }`, `reissueAfter` is the caller-computed instant before which no new code may be minted, and `asOf` is the request instant — and returns one frozen verdict: an issue `{ decision: 'issue', reason, subject, supersededChallengeRef? }` whose `subject` is `{ memberRef, phoneRef }` and whose `reason` is `fresh` (nothing live to replace — no prior, or a terminal or expired one) or `superseding` (a still-live pending prior is being replaced, named as `supersededChallengeRef` for the caller to void); a refusal `{ decision: 'refuse', reason }` (`actor_not_owner` / `subject_mismatch` / `already_verified` / `cooldown_active`, all of which change nothing); or a denial `{ decision: 'denied', reason }` (`malformed_request` / `malformed_current_challenge`) — and **never throws**. It imports only `node:util`. + +```mermaid +flowchart TD + I["request + currentChallenge
(null or issuance projection)"] --> V{"request an exact,
well-formed record?"} + V -- "No" --> D0["deny malformed_request"] + V -- "Yes" --> V2{"challenge null, or an exact
well-formed projection?"} + V2 -- "No" --> D1["deny malformed_current_challenge"] + V2 -- "Yes" --> OW{"actor == memberRef?"} + OW -- "No" --> R0["refuse actor_not_owner
(no change)"] + OW -- "Yes" --> HC{"challenge present?"} + HC -- "No" --> G0["issue fresh"] + HC -- "Yes" --> SB{"same (member, phone)?"} + SB -- "No" --> R1["refuse subject_mismatch
(no change)"] + SB -- "Yes" --> VF{"status verified?"} + VF -- "Yes" --> R2["refuse already_verified
(no change)"] + VF -- "No" --> CD{"asOf < reissueAfter?"} + CD -- "Yes" --> R3["refuse cooldown_active
(no change)"] + CD -- "No" --> LV{"pending and
asOf < expiresAt?"} + LV -- "Yes" --> G1["issue superseding
(name prior to void)"] + LV -- "No" --> G2["issue fresh"] +``` + +Text alternative: a request that is not an exact, well-formed record is denied `malformed_request`; then a `currentChallenge` that is neither a literal `null` (the sole "no prior challenge" signal, which is not malformed) nor an exact well-formed projection is denied `malformed_current_challenge` (the request is read first, so a malformed request outranks). Authorization is the first semantic gate: an `actor` that is not the `memberRef` is `actor_not_owner` with no state change, checked before any challenge state is consulted, so no one can trigger a code-send to a phone on another member's behalf and an unauthorized requester learns nothing about the challenge. With no prior challenge the issue is `fresh`. Otherwise the supplied challenge must describe the same `(memberRef, phoneRef)` or it is `subject_mismatch` with no change — an issuance decision is never made from another pair's challenge. A `verified` pair is `already_verified` with no change (a proven pair never gets another code), checked before the cooldown. A request before `reissueAfter` is `cooldown_active` with no change — and because this applies to a `voided` prior as much as a `pending` one, exhausting §8.16's attempt budget then reissuing for a fresh budget is refused until the cooldown elapses. Otherwise the code may issue: a still-live `pending` prior (`asOf` before `expiresAt`, exclusive) is `superseding` and names that challenge for the caller to void, so at most one live challenge exists per pair; a terminal or already-expired prior leaves nothing live and the issue is `fresh`. + +The headline safety invariant is **owner-bound issuance**: only the signed-in member may request a code for their own verification (`actor` must equal `memberRef`), so no actor can trigger a one-time-code send to a phone on another member's behalf — the enumeration / cross-account-abuse guard — and the gate is checked before any challenge state, so an unauthorized requester learns nothing. The second is **idempotent on verified**: a pair whose challenge is already `verified` never issues another code, so a proven number is never re-sent a code. The third is **cooldown-bounded, brute-force-loop-closing**: a request before `reissueAfter` issues nothing, and because the gate applies to a `voided` prior exactly as to a `pending` one, the exhaust-attempts → void → reissue loop that a pure per-challenge attempt bound (§8.16) cannot see is closed — §8.16's budget cannot be reset faster than the owner's cooldown. The fourth is **at-most-one-live-challenge**: issuing while a live `pending` challenge exists supersedes it and names it for the caller to void, so parallel live challenges — which would multiply the total guess budget for one pair — never accumulate. The fifth is that the contract **mints nothing and confers nothing**: an `issue` verdict authorizes a send and carries only the `subject` and, when superseding, an *existing* challenge ref to void — never a code, a hash, a new challenge id, or a policy value — and it grants no Firebase authentication, membership, discount, or role and does not itself prove control of the phone (that remains §8.16's job); the phone is a channel handle (a bare all-digit value is accepted) while the member and actor are letter-requiring identities, so a phone number can never masquerade as the member a code is for. The sixth is that it **invents no policy**: the cooldown duration, the code lifetime, and the attempt budget are owner policy; the caller supplies the already-computed `reissueAfter` and `expiresAt` instants and this reducer only enforces the gates by comparison, setting no threshold of its own (cf. the #114 owner-decision rule). The seventh is that it is **clockless**: the cooldown and liveness gates compare `asOf` against `reissueAfter`/`expiresAt` by lexical UTC string comparison, which for fixed-width UTC instants over real calendar dates agrees exactly with chronological order, so the reducer reads no clock and constructs no `Date`; the timestamp validator is calendar-aware and rejects impossible dates (a non-leap Feb 29, an Apr 31), which — since malformed only ever withholds — can never enable an issuance past a cooldown. Finally the contract is **hostile-input-safe**: every field of each record is read through an own-enumerable data descriptor with no getter ever invoked, and a proxy (including a revoked one), a non-`Object.prototype` prototype, an inherited, extra (enumerable or non-enumerable), missing, or symbol key, or an out-of-shape value denies as malformed rather than being partially interpreted or throwing. + +This contract invents no policy and duplicates no sibling. It owns the *one-time-code issuance disposition* — may the server mint and send a new code for this pair now, and does that supersede a live prior challenge — and it is deliberately not any neighbour. §8.16 `classifyWhatsappVerificationRedemption` (#419) decides whether one *redemption attempt* proves channel control and how a single challenge's *attempt budget* advances; this issues the challenge that §8.16 later redeems, minting no code and comparing no hash, and bounds the *number and rate* of challenges §8.16 cannot see. §8.0e `classifyMembershipProviderLink` (#367) is the provider-neutral *link/collision* authorizer (whether linking this external account to this member collides with another owner, gated on consent); this triggers no link, resolves no collision, and reads no occupant list — it decides only whether a verification code may be sent. §8.0d `projectMembershipConsentState` (#370) owns the versioned *consent* state; this captures no consent and reads none. It runs no generic rate limiter (the Firestore-backed `rateLimit.js` mechanism is infrastructure, not this decision), generates and hashes no code, sends no template, makes no provider call, enforces no App Check, persists no challenge, normalizes no phone value, and resolves no multi-UID ownership — the caller runs its rate limiter and consent/link checks, projects the stored challenge into this reducer's shape, feeds it the request, and on an `issue` verdict mints+hashes+sends the code, voids any named superseded challenge, and persists the new challenge with the owner's lifetime, attempt budget, and next `reissueAfter`. The one-time code generation and templated send, the hashing and App Check, the durable challenge persistence, the versioned-consent capture, the phone normalization and silent-multi-UID-ownership prevention, and the opt-out, reassignment/recovery, deletion, and audited conflict handling all remain with #87 and its dependencies (#85's Cloud API selection; #81; coordinating AUTH-001 and ABUSE-001). + +The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; holds, reads, or compares no code, hash, or secret; mints no identifier; and grants, sends, or persists nothing beyond the returned verdict. It defines no Firestore schema or Rules, enforces no live rate limit or App Check, and wires into no route or WhatsApp/provider adapter. Source change, tests, merge, Firebase deployment, provider configuration, production data, and live behavior remain separate states; #435 issues no real code and proves none of the external or live states. + ## 9. Consistency and failure model Stripe and Firestore cannot share a distributed transaction. Checkout and refunds are therefore explicit sagas: diff --git a/functions/whatsappVerificationIssuance.js b/functions/whatsappVerificationIssuance.js new file mode 100644 index 0000000..255662b --- /dev/null +++ b/functions/whatsappVerificationIssuance.js @@ -0,0 +1,371 @@ +'use strict'; + +// WHATSAPP-002B — pure one-time verification-code issuance decision (pure contract). +// +// SOURCE ONLY, UNUSED: this module is imported by nothing (not by +// functions/index.js, no route, no Firestore Rules, no callable, no scheduled +// worker). It has zero runtime behavior. It is the anti-abuse decision core for +// the *issuance* half of parent #87 (WHATSAPP-002) — "generate a one-time server +// code ... make it short-lived, one-use, RATE/ATTEMPT LIMITED, and App Check +// protected" — the half §8.16 (the redemption reducer) explicitly leaves with #87. +// It is landed and exhaustively negative-tested first so the eventual wiring is a +// mechanical hookup of already-proven invariants. See SYSTEM_DESIGN.md §8.23. +// +// What it decides: given one request to issue a verification code for a +// (member, phone) pair and the issuance-relevant projection of the durable +// challenge the server already has for that pair (or `null` when there is none), +// whether the server may issue a new one-time code now — `issue` (fresh, or +// superseding a still-live prior challenge) — or must refuse (not the owner, phone +// already verified, or still inside the reissue cooldown), or deny as malformed. It +// is a non-throwing frozen-verdict reducer (the idiom of §8.15/§8.16/§8.22) and +// NEVER throws. +// +// It composes with — and is strictly distinct from — the §8.16 redemption reducer. +// §8.16 bounds how many GUESSES a single challenge permits (per-challenge attempt +// budget, one-use, expiry). This reducer bounds how many CHALLENGES exist and how +// FAST they may be minted for a (member, phone): a cooldown that gates BOTH a +// pending resend AND a post-void reissue (so §8.16's attempt budget cannot be reset +// faster than the owner's cooldown — closing the exhaust→void→reissue loop), and a +// supersession rule so at most one live challenge exists per pair (no parallel +// challenges multiplying the total guess budget). Redemption without issuance +// leaves those two abuse vectors open; this contract closes them. +// +// Holds NO code, NO hash, mints NOTHING. The one-time code, its hash, the new +// challenge reference, the code lifetime, the attempt budget, and the next cooldown +// are all the caller's to mint and apply after an `issue` verdict — this reducer +// only decides the disposition. `supersededChallengeRef` on an `issue: superseding` +// verdict names an EXISTING challenge the caller must void; it is not a minted +// value. There is no code / secret / token / bearer vocabulary anywhere in the +// source, and a source-boundary test enforces that absence. +// +// Confers nothing: an `issue` verdict authorizes sending a code — it grants no +// Firebase auth, membership, discount, or role, and does not itself prove control +// of the phone (that is §8.16's job, later). The phone is a channel handle (a bare +// all-digit value is fine); the member and the requesting actor are letter-requiring +// identities, so a phone number can never masquerade as the member it is a code for. +// +// Invents no policy: the cooldown DURATION, the code lifetime, and the attempt +// budget are owner policy. The caller supplies the already-computed `reissueAfter` +// and `expiresAt` instants; this reducer only enforces the gates by lexical UTC +// comparison. It sets no threshold of its own (cf. the #114 owner-decision rule). +// +// Safety model: +// * Owner-bound: only the signed-in member may request a code for their own +// verification (`actor` must equal `memberRef`); a mismatch is `actor_not_owner` +// and issues nothing, so no one can trigger a code-send to a phone on another +// member's behalf (the enumeration / cross-account abuse guard). Checked before +// any challenge state is consulted, so an unauthorized requester learns nothing. +// * Idempotent on verified: a (member, phone) whose challenge is already `verified` +// never issues another code (`already_verified`) — no reverify spam, and no code +// re-sent to an already-linked number. +// * Cooldown-bounded (anti-brute-force-loop): a request before the challenge's +// `reissueAfter` is `cooldown_active` and issues nothing. The gate applies to a +// `voided` prior as much as a `pending` one, so exhausting §8.16's attempt budget +// and immediately reissuing to get a fresh budget is refused until the cooldown +// elapses. +// * At-most-one-live-challenge: issuing while a `pending`, not-yet-expired +// challenge exists is `issue: superseding` and names that challenge as +// `supersededChallengeRef` for the caller to void — so parallel live challenges +// (which would multiply the total guess budget for one pair) never accumulate. +// * Subject-bound: the supplied current challenge must describe the same +// (member, phone) as the request; an unrelated challenge is `subject_mismatch` +// and issues nothing — an issuance decision is never made from another pair's +// challenge. +// * Mints nothing / content-free: the verdict carries only the subject +// (member/phone the code is for) and, when superseding, the ref of the challenge +// to void — never a code, a hash, a new challenge id, or a policy value. +// * Clockless: the cooldown and liveness gates compare `asOf` against +// `reissueAfter` / `expiresAt` by lexical UTC string comparison over +// calendar-validated instants (fixed-width UTC compares chronologically); the +// reducer reads no clock and constructs no Date. The caller supplies `asOf`. +// * Hostile-input-safe: every field of each record is read through an +// own-enumerable data descriptor with no getter ever invoked; a proxy, foreign +// prototype, inherited/extra/missing/symbol key, or out-of-shape value denies +// as malformed rather than being partially interpreted. +// +// Requires only node:util. Reads no clock, env, network, Firestore, or provider. + +const { + types: { isProxy }, +} = require('node:util'); + +const verificationIssuanceSchemaVersion = 1; + +function immutableEnum(values) { + return Object.freeze(Object.fromEntries(values.map((v) => [v.toUpperCase(), v]))); +} + +// ---- closed vocabularies ------------------------------------------------- + +// The issuance-relevant projection of the durable challenge's lifecycle state, +// mirroring §8.16: `pending` may still be redeemed; `verified` and `voided` are +// terminal. This reducer reads the status only to decide idempotency (verified) and +// whether a live challenge must be superseded (pending + unexpired). +const ISSUANCE_STATUSES = ['pending', 'verified', 'voided']; +const ISSUANCE_DECISIONS = ['issue', 'refuse', 'denied']; +// `fresh` — no live challenge to replace (none, terminal, or expired prior). +// `superseding` — a still-live pending challenge is being replaced; the verdict +// names it so the caller voids it (at most one live challenge per pair). +const ISSUE_REASONS = ['fresh', 'superseding']; +// Well-formed requests that are nonetheless not issued; all change NO state. +const REFUSE_REASONS = [ + 'actor_not_owner', + 'subject_mismatch', + 'already_verified', + 'cooldown_active', +]; +const DENIAL_REASONS = ['malformed_request', 'malformed_current_challenge']; + +const IssuanceStatus = immutableEnum(ISSUANCE_STATUSES); +const IssuanceDecision = immutableEnum(ISSUANCE_DECISIONS); +const IssueReason = immutableEnum(ISSUE_REASONS); +const RefuseReason = immutableEnum(REFUSE_REASONS); +const DenialReason = immutableEnum(DENIAL_REASONS); + +const ISSUANCE_STATUS_SET = new Set(ISSUANCE_STATUSES); + +// ---- record shapes ------------------------------------------------------- + +const REQUEST_FIELDS = [ + 'verificationIssuanceSchemaVersion', + 'memberRef', + 'phoneRef', + 'actor', + 'asOf', +]; +// The issuance-relevant projection of the durable challenge — NOT the full §8.16 +// record. The caller projects the stored challenge into exactly these fields; the +// contract validates its own closed shape (a raw §8.16 challenge, with a different +// field set and schema-version key, denies as malformed rather than being partly +// read). +const CURRENT_CHALLENGE_FIELDS = [ + 'verificationIssuanceSchemaVersion', + 'challengeRef', + 'memberRef', + 'phoneRef', + 'status', + 'expiresAt', + 'reissueAfter', +]; + +// A challenge handle and a phone/WhatsApp channel identifier are both opaque, +// url-safe handles. A normalized phone number is legitimately all digits, so — like +// a provider account id — no letter is required: it is a channel handle, never an +// identity, and must never be treatable as one. +const HANDLE_PATTERN = /^[A-Za-z0-9._-]{1,256}$/; +// A member UID / requesting actor is an identity key. It must contain at least one +// letter, so a bare all-digit value (the shape of a telephone number) can never be +// accepted where a member identity is required — a phone can never masquerade as +// the member a code is being issued for. +const IDENTITY_PATTERN = /^[A-Za-z0-9._-]{1,256}$/; +const IDENTITY_REQUIRES_LETTER = /[A-Za-z]/; +// A fixed-width UTC instant restricted to real calendar dates (see isUtcTimestamp). +// Fixed-width UTC strings over real dates compare lexically exactly as they do +// chronologically, so the cooldown and liveness gates are decided by string +// comparison with no clock. +const UTC_TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/; + +function isHandleString(value) { + return typeof value === 'string' && HANDLE_PATTERN.test(value); +} + +function isIdentityString(value) { + return typeof value === 'string' + && IDENTITY_PATTERN.test(value) + && IDENTITY_REQUIRES_LETTER.test(value); +} + +function isUtcTimestamp(value) { + if (typeof value !== 'string') return false; + const match = UTC_TIMESTAMP_PATTERN.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if (month < 1 || month > 12) return false; + // Calendar-aware day bound — accept only a REAL calendar day. This is what makes + // the fixed-width lexical comparison used for the gates provably agree with + // chronological order: an impossible day (a non-leap Feb 29, Feb 30/31, Apr/Jun/ + // Sep/Nov 31) would, if interpreted as an instant, roll forward past where it sorts + // lexically, so lexical order would stop matching chronological order. Rejecting it + // as malformed keeps the equivalence exact — and since malformed denies, this only + // ever withholds an issuance, never enables one past a cooldown. + const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const DAYS_IN_MONTH = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (day < 1 || day > DAYS_IN_MONTH[month - 1]) return false; + if (hour > 23) return false; + if (minute > 59) return false; + if (second > 59) return false; + return true; +} + +// 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). +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. With symbols already + // rejected, this bounds the total own-key surface to exactly `expectedFields`. + 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; +} + +// Validate one issuance request. +function readRequest(value) { + const request = readExact(value, REQUEST_FIELDS); + if (!request) return null; + if (request.verificationIssuanceSchemaVersion !== verificationIssuanceSchemaVersion) return null; + if (!isIdentityString(request.memberRef)) return null; + if (!isHandleString(request.phoneRef)) return null; + if (!isIdentityString(request.actor)) return null; + if (!isUtcTimestamp(request.asOf)) return null; + return request; +} + +// Validate the issuance-relevant projection of the durable challenge. +function readCurrentChallenge(value) { + const challenge = readExact(value, CURRENT_CHALLENGE_FIELDS); + if (!challenge) return null; + if (challenge.verificationIssuanceSchemaVersion !== verificationIssuanceSchemaVersion) return null; + if (!isHandleString(challenge.challengeRef)) return null; + if (!isIdentityString(challenge.memberRef)) return null; + if (!isHandleString(challenge.phoneRef)) return null; + if (typeof challenge.status !== 'string' || !ISSUANCE_STATUS_SET.has(challenge.status)) return null; + if (!isUtcTimestamp(challenge.expiresAt)) return null; + if (!isUtcTimestamp(challenge.reissueAfter)) return null; + return challenge; +} + +// ---- verdict constructors ------------------------------------------------ + +const DENIALS = Object.freeze(Object.fromEntries( + DENIAL_REASONS.map((reason) => [reason, Object.freeze({ decision: 'denied', reason })]), +)); + +// Every refusal leaves all state untouched — issues nothing, supersedes nothing — +// so each is a context-free frozen singleton. +const REFUSALS = Object.freeze(Object.fromEntries( + REFUSE_REASONS.map((reason) => [reason, Object.freeze({ decision: 'refuse', reason })]), +)); + +function deny(reason) { + return DENIALS[reason]; +} + +function refuse(reason) { + return REFUSALS[reason]; +} + +// Issue a fresh code — no live challenge to replace (no prior, or the prior is +// terminal or expired). The verdict carries only the subject the code is for and +// mints nothing. +function issueFresh(request) { + return Object.freeze({ + decision: 'issue', + reason: 'fresh', + subject: Object.freeze({ + memberRef: request.memberRef, + phoneRef: request.phoneRef, + }), + }); +} + +// Issue a code that supersedes a still-live pending challenge. Names the prior +// challenge so the caller voids it, keeping at most one live challenge per pair. +function issueSuperseding(request, challenge) { + return Object.freeze({ + decision: 'issue', + reason: 'superseding', + subject: Object.freeze({ + memberRef: request.memberRef, + phoneRef: request.phoneRef, + }), + supersededChallengeRef: challenge.challengeRef, + }); +} + +// ---- the decision -------------------------------------------------------- + +function classifyWhatsappVerificationIssuance(requestEvidence, currentChallengeEvidence) { + const request = readRequest(requestEvidence); + if (!request) return deny('malformed_request'); + // A literal `null` is the sole "no challenge yet" signal and is NOT malformed; + // anything else must be an exact well-formed projection. The request is read first, + // so a malformed request outranks a malformed challenge. + let challenge = null; + if (currentChallengeEvidence !== null) { + challenge = readCurrentChallenge(currentChallengeEvidence); + if (!challenge) return deny('malformed_current_challenge'); + } + + // Owner-bound: authorization to trigger a code-send is the first semantic gate. An + // actor who is not the member is refused before any challenge state is consulted, + // so no one can trigger a send to a phone on another member's behalf and an + // unauthorized requester learns nothing about the challenge. + if (request.actor !== request.memberRef) return refuse('actor_not_owner'); + + if (challenge !== null) { + // Subject-bound: an issuance decision is never made from another pair's + // challenge. The supplied projection must describe the same (member, phone). + if (challenge.memberRef !== request.memberRef) return refuse('subject_mismatch'); + if (challenge.phoneRef !== request.phoneRef) return refuse('subject_mismatch'); + + // Idempotent on verified: a proven pair never gets another code. Checked before + // the cooldown so a verified pair reports `already_verified`, not `cooldown_active`. + if (challenge.status === 'verified') return refuse('already_verified'); + + // Cooldown-bounded: no new code until `reissueAfter`. Applies to a `voided` prior + // as much as a `pending` one, so exhausting §8.16's attempt budget then reissuing + // for a fresh budget is refused until the cooldown elapses. Inclusive: `asOf` + // exactly at `reissueAfter` is allowed (the cooldown has elapsed). + if (request.asOf < challenge.reissueAfter) return refuse('cooldown_active'); + + // Permitted. A still-live pending challenge is superseded (and named for the + // caller to void); a terminal or already-expired prior leaves nothing live, so + // the issue is fresh. Liveness is exclusive: `asOf` at exactly `expiresAt` is + // already expired, hence not live to supersede. + if (challenge.status === 'pending' && request.asOf < challenge.expiresAt) { + return issueSuperseding(request, challenge); + } + } + + return issueFresh(request); +} + +module.exports = Object.freeze({ + verificationIssuanceSchemaVersion, + IssuanceStatus, + IssuanceDecision, + IssueReason, + RefuseReason, + DenialReason, + classifyWhatsappVerificationIssuance, +}); diff --git a/functions/whatsappVerificationIssuance.test.js b/functions/whatsappVerificationIssuance.test.js new file mode 100644 index 0000000..f4c007f --- /dev/null +++ b/functions/whatsappVerificationIssuance.test.js @@ -0,0 +1,688 @@ +'use strict'; + +// Tests for the pure WhatsApp verification-code ISSUANCE decision (§8.23, +// WHATSAPP-002B). Negative-heavy: the module is a safety-critical anti-abuse gate +// read from forgeable stored state, so malformed and hostile inputs are normal +// events that must resolve to reason-coded verdicts, never throw. Runs with no +// secrets, no clock, no network, no Firestore. + +const fs = require('fs'); +const path = require('path'); + +const issuance = require('./whatsappVerificationIssuance'); +const { + classifyWhatsappVerificationIssuance: classify, + verificationIssuanceSchemaVersion: SCHEMA, + IssuanceStatus, + IssuanceDecision, + IssueReason, + RefuseReason, + DenialReason, +} = issuance; + +// ---- fixtures ------------------------------------------------------------ + +const MEMBER = 'member_abc'; +const OTHER_MEMBER = 'member_xyz'; +// A phone/WhatsApp handle is legitimately all-digit — it is a channel handle, never +// an identity. +const PHONE = '15551234567'; +const OTHER_PHONE = '15559998888'; +const CHALLENGE_REF = 'chal_0001'; + +// Fixed-width UTC instants. Chronological order is REISSUE_PAST < EXPIRES_PAST < ASOF +// < EXPIRES_FUTURE < REISSUE_FUTURE, which (over real calendar dates) is exactly +// their lexical order — the property the reducer relies on. +const REISSUE_PAST = '2026-07-21T11:00:00Z'; +const EXPIRES_PAST = '2026-07-21T11:55:00Z'; +const ASOF = '2026-07-21T12:00:00Z'; +const EXPIRES_FUTURE = '2026-07-21T12:05:00Z'; +const REISSUE_FUTURE = '2026-07-21T13:00:00Z'; + +function request(over = {}) { + return { + verificationIssuanceSchemaVersion: SCHEMA, + memberRef: MEMBER, + phoneRef: PHONE, + actor: MEMBER, + asOf: ASOF, + ...over, + }; +} + +// Default challenge: pending, live (expires in the future), cooldown elapsed +// (reissueAfter in the past) → the "issue superseding" happy path. +function challenge(over = {}) { + return { + verificationIssuanceSchemaVersion: SCHEMA, + challengeRef: CHALLENGE_REF, + memberRef: MEMBER, + phoneRef: PHONE, + status: 'pending', + expiresAt: EXPIRES_FUTURE, + reissueAfter: REISSUE_PAST, + ...over, + }; +} + +// An independent, hand-authored oracle of the SEMANTIC decision (assumes +// well-formed inputs). Structured differently from the module so agreement is +// meaningful. Produces the FULL expected verdict. +function oracle(req, chal) { + if (req.actor !== req.memberRef) return { decision: 'refuse', reason: 'actor_not_owner' }; + if (chal !== null) { + if (chal.memberRef !== req.memberRef || chal.phoneRef !== req.phoneRef) { + return { decision: 'refuse', reason: 'subject_mismatch' }; + } + if (chal.status === 'verified') return { decision: 'refuse', reason: 'already_verified' }; + if (req.asOf < chal.reissueAfter) return { decision: 'refuse', reason: 'cooldown_active' }; + if (chal.status === 'pending' && req.asOf < chal.expiresAt) { + return { + decision: 'issue', + reason: 'superseding', + subject: { memberRef: req.memberRef, phoneRef: req.phoneRef }, + supersededChallengeRef: chal.challengeRef, + }; + } + } + return { + decision: 'issue', + reason: 'fresh', + subject: { memberRef: req.memberRef, phoneRef: req.phoneRef }, + }; +} + +// ---- module surface ------------------------------------------------------ + +describe('module surface', () => { + test('exports a frozen namespace with the expected members', () => { + expect(Object.isFrozen(issuance)).toBe(true); + expect(typeof classify).toBe('function'); + expect(SCHEMA).toBe(1); + expect(Object.isFrozen(IssuanceStatus)).toBe(true); + expect(Object.isFrozen(IssuanceDecision)).toBe(true); + expect(Object.isFrozen(IssueReason)).toBe(true); + expect(Object.isFrozen(RefuseReason)).toBe(true); + expect(Object.isFrozen(DenialReason)).toBe(true); + }); + + test('enums expose the exact closed vocabularies', () => { + expect(new Set(Object.values(IssuanceStatus))).toEqual(new Set(['pending', 'verified', 'voided'])); + expect(new Set(Object.values(IssuanceDecision))).toEqual(new Set(['issue', 'refuse', 'denied'])); + expect(new Set(Object.values(IssueReason))).toEqual(new Set(['fresh', 'superseding'])); + expect(new Set(Object.values(RefuseReason))).toEqual(new Set([ + 'actor_not_owner', 'subject_mismatch', 'already_verified', 'cooldown_active', + ])); + expect(new Set(Object.values(DenialReason))).toEqual(new Set([ + 'malformed_request', 'malformed_current_challenge', + ])); + }); + + test('the decision/reason vocabulary contains no code, mint, grant, or auth verb', () => { + // Structural guarantee that an issue verdict authorizes a send and nothing more: + // no reason implies minting a code/hash here, nor granting auth/membership/role. + const vocab = [ + ...Object.values(IssuanceDecision), + ...Object.values(IssueReason), + ...Object.values(RefuseReason), + ...Object.values(DenialReason), + ].join(' ').toLowerCase(); + for (const forbidden of ['code', 'hash', 'secret', 'token', 'grant', 'auth', 'member ', 'role', 'verified_now']) { + expect(vocab.includes(forbidden)).toBe(false); + } + // (`already_verified` legitimately contains "verified" — a lifecycle state, not + // an act of granting; the forbidden list avoids that substring deliberately.) + }); +}); + +// ---- decision matrix vs the oracle -------------------------------------- + +describe('decision matrix agrees with an independent oracle', () => { + const cases = [ + ['actor not owner, no challenge', request({ actor: OTHER_MEMBER }), null], + ['actor not owner outranks a present challenge', request({ actor: OTHER_MEMBER }), challenge()], + ['subject mismatch on member', request(), challenge({ memberRef: OTHER_MEMBER })], + ['subject mismatch on phone', request(), challenge({ phoneRef: OTHER_PHONE })], + ['verified pair (cooldown elapsed)', request(), challenge({ status: 'verified' })], + ['verified outranks an active cooldown', request(), challenge({ status: 'verified', reissueAfter: REISSUE_FUTURE })], + ['cooldown active, pending prior', request(), challenge({ status: 'pending', reissueAfter: REISSUE_FUTURE })], + ['cooldown active, voided prior (void->reissue loop)', request(), challenge({ status: 'voided', reissueAfter: REISSUE_FUTURE })], + ['issue fresh, no challenge', request(), null], + ['issue fresh, voided prior cooled', request(), challenge({ status: 'voided', reissueAfter: REISSUE_PAST })], + ['issue fresh, pending-but-expired prior cooled', request(), challenge({ status: 'pending', expiresAt: EXPIRES_PAST, reissueAfter: REISSUE_PAST })], + ['issue superseding, pending live prior cooled', request(), challenge()], + ]; + + for (const [name, req, chal] of cases) { + test(name, () => { + expect(classify(req, chal)).toEqual(oracle(req, chal)); + }); + } + + test('the curated matrix exercises every decision:reason pair', () => { + const seen = new Set( + cases.map(([, req, chal]) => { + const v = classify(req, chal); + return `${v.decision}:${v.reason}`; + }), + ); + expect(seen).toEqual(new Set([ + 'refuse:actor_not_owner', + 'refuse:subject_mismatch', + 'refuse:already_verified', + 'refuse:cooldown_active', + 'issue:fresh', + 'issue:superseding', + ])); + }); +}); + +// ---- owner-bound --------------------------------------------------------- + +describe('owner-bound: only the member may request their own code', () => { + test('an actor other than the member refuses and issues nothing', () => { + const v = classify(request({ actor: OTHER_MEMBER }), null); + expect(v).toEqual({ decision: 'refuse', reason: 'actor_not_owner' }); + }); + + test('actor_not_owner is checked before any challenge state is consulted', () => { + // Even with a verified pair and an active cooldown present, an unauthorized actor + // gets actor_not_owner — leaking nothing about the challenge. + for (const chal of [ + challenge({ status: 'verified' }), + challenge({ status: 'pending', reissueAfter: REISSUE_FUTURE }), + challenge({ memberRef: OTHER_MEMBER }), + ]) { + expect(classify(request({ actor: OTHER_MEMBER }), chal)) + .toEqual({ decision: 'refuse', reason: 'actor_not_owner' }); + } + }); + + test('the owning member is permitted (baseline)', () => { + expect(classify(request({ actor: MEMBER }), null).decision).toBe('issue'); + }); +}); + +// ---- idempotent on verified ---------------------------------------------- + +describe('idempotent on verified: a proven pair never gets another code', () => { + test('a verified challenge refuses already_verified', () => { + expect(classify(request(), challenge({ status: 'verified' }))) + .toEqual({ decision: 'refuse', reason: 'already_verified' }); + }); + + test('already_verified holds regardless of expiry or cooldown', () => { + for (const over of [ + { status: 'verified', expiresAt: EXPIRES_PAST }, + { status: 'verified', reissueAfter: REISSUE_FUTURE }, + { status: 'verified', reissueAfter: REISSUE_PAST, expiresAt: EXPIRES_FUTURE }, + ]) { + expect(classify(request(), challenge(over)).reason).toBe('already_verified'); + } + }); +}); + +// ---- cooldown-bounded ---------------------------------------------------- + +describe('cooldown-bounded: no reissue before reissueAfter', () => { + test('a pending prior inside its cooldown refuses cooldown_active', () => { + expect(classify(request(), challenge({ status: 'pending', reissueAfter: REISSUE_FUTURE }))) + .toEqual({ decision: 'refuse', reason: 'cooldown_active' }); + }); + + test('a VOIDED prior inside its cooldown also refuses (exhaust->void->reissue is blocked)', () => { + // This is the composition with §8.16: exhausting the attempt budget voids the + // challenge, but a fresh budget cannot be minted until the cooldown elapses. + expect(classify(request(), challenge({ status: 'voided', reissueAfter: REISSUE_FUTURE }))) + .toEqual({ decision: 'refuse', reason: 'cooldown_active' }); + }); + + test('asOf exactly at reissueAfter is allowed (inclusive lower bound — cooldown elapsed)', () => { + const at = classify(request({ asOf: ASOF }), challenge({ reissueAfter: ASOF })); + expect(at.decision).toBe('issue'); + }); + + test('asOf one instant before reissueAfter is refused', () => { + const before = classify( + request({ asOf: '2026-07-21T12:59:59Z' }), + challenge({ reissueAfter: REISSUE_FUTURE }), + ); + expect(before).toEqual({ decision: 'refuse', reason: 'cooldown_active' }); + }); +}); + +// ---- at-most-one-live via supersession ----------------------------------- + +describe('at-most-one-live: supersession keeps a single live challenge per pair', () => { + test('a live pending prior is superseded and named for the caller to void', () => { + const v = classify(request(), challenge({ challengeRef: CHALLENGE_REF })); + expect(v.decision).toBe('issue'); + expect(v.reason).toBe('superseding'); + // The named ref is the EXISTING prior challenge — not a minted value. + expect(v.supersededChallengeRef).toBe(CHALLENGE_REF); + }); + + test('a fresh issue names no superseded challenge', () => { + for (const chal of [ + null, + challenge({ status: 'voided', reissueAfter: REISSUE_PAST }), + challenge({ status: 'pending', expiresAt: EXPIRES_PAST, reissueAfter: REISSUE_PAST }), + ]) { + const v = classify(request(), chal); + expect(v.reason).toBe('fresh'); + expect('supersededChallengeRef' in v).toBe(false); + } + }); + + test('asOf exactly at expiresAt is expired — issue is fresh, not superseding', () => { + const v = classify(request({ asOf: ASOF }), challenge({ status: 'pending', expiresAt: ASOF, reissueAfter: REISSUE_PAST })); + expect(v.reason).toBe('fresh'); + expect('supersededChallengeRef' in v).toBe(false); + }); + + test('a live pending prior one instant from expiry still supersedes', () => { + const v = classify( + request({ asOf: ASOF }), + challenge({ status: 'pending', expiresAt: '2026-07-21T12:00:01Z', reissueAfter: REISSUE_PAST }), + ); + expect(v.reason).toBe('superseding'); + }); +}); + +// ---- subject-bound ------------------------------------------------------- + +describe('subject-bound: never decide from another pair\'s challenge', () => { + test('a challenge for a different member refuses subject_mismatch', () => { + expect(classify(request(), challenge({ memberRef: OTHER_MEMBER }))) + .toEqual({ decision: 'refuse', reason: 'subject_mismatch' }); + }); + + test('a challenge for a different phone refuses subject_mismatch', () => { + expect(classify(request(), challenge({ phoneRef: OTHER_PHONE }))) + .toEqual({ decision: 'refuse', reason: 'subject_mismatch' }); + }); + + test('subject_mismatch outranks a would-be verified/cooldown on the wrong pair', () => { + // A verified/cooling challenge for the WRONG pair must not shortcut this request. + expect(classify(request(), challenge({ memberRef: OTHER_MEMBER, status: 'verified' })).reason) + .toBe('subject_mismatch'); + expect(classify(request(), challenge({ phoneRef: OTHER_PHONE, reissueAfter: REISSUE_FUTURE })).reason) + .toBe('subject_mismatch'); + }); +}); + +// ---- mints nothing / content-free --------------------------------------- + +describe('mints nothing / content-free', () => { + test('an issue:fresh verdict has exactly {decision, reason, subject}', () => { + const v = classify(request(), null); + expect(Object.keys(v).sort()).toEqual(['decision', 'reason', 'subject']); + expect(Object.keys(v.subject).sort()).toEqual(['memberRef', 'phoneRef']); + expect(v.subject).toEqual({ memberRef: MEMBER, phoneRef: PHONE }); + }); + + test('an issue:superseding verdict adds only supersededChallengeRef', () => { + const v = classify(request(), challenge()); + expect(Object.keys(v).sort()).toEqual(['decision', 'reason', 'subject', 'supersededChallengeRef']); + }); + + test('no verdict ever carries a code, hash, secret, token, or minted challenge id', () => { + const verdicts = [ + classify(request(), null), + classify(request(), challenge()), + classify(request(), challenge({ status: 'verified' })), + classify(request({ actor: OTHER_MEMBER }), null), + classify(request(), challenge({ reissueAfter: REISSUE_FUTURE })), + classify({}, null), + ]; + const forbidden = /code|hash|secret|token|otp|pin|digest|password|bearer|newchallenge/i; + for (const v of verdicts) { + const json = JSON.stringify(v); + expect(forbidden.test(json)).toBe(false); + // The only ref a verdict may carry is a superseded (existing) one — never a + // freshly-minted challenge id. + if ('supersededChallengeRef' in v) { + expect(v.supersededChallengeRef).toBe(CHALLENGE_REF); + } + } + }); +}); + +// ---- precedence ---------------------------------------------------------- + +describe('precedence', () => { + test('malformed_request outranks malformed_current_challenge', () => { + expect(classify({}, {})).toEqual({ decision: 'denied', reason: 'malformed_request' }); + }); + + test('a valid request with a malformed challenge denies malformed_current_challenge', () => { + expect(classify(request(), {})).toEqual({ decision: 'denied', reason: 'malformed_current_challenge' }); + }); + + test('actor_not_owner outranks subject/verified/cooldown', () => { + expect(classify(request({ actor: OTHER_MEMBER }), challenge({ memberRef: OTHER_MEMBER, status: 'verified', reissueAfter: REISSUE_FUTURE })).reason) + .toBe('actor_not_owner'); + }); + + test('subject_mismatch outranks already_verified and cooldown_active', () => { + expect(classify(request(), challenge({ memberRef: OTHER_MEMBER, status: 'verified', reissueAfter: REISSUE_FUTURE })).reason) + .toBe('subject_mismatch'); + }); + + test('already_verified outranks cooldown_active', () => { + expect(classify(request(), challenge({ status: 'verified', reissueAfter: REISSUE_FUTURE })).reason) + .toBe('already_verified'); + }); +}); + +// ---- null is the sole "no challenge" signal ------------------------------ + +describe('null is the sole no-challenge signal', () => { + test('literal null means no prior challenge and issues fresh', () => { + expect(classify(request(), null)).toEqual({ + decision: 'issue', reason: 'fresh', subject: { memberRef: MEMBER, phoneRef: PHONE }, + }); + }); + + test('undefined is NOT the no-challenge signal — it denies malformed', () => { + expect(classify(request(), undefined)).toEqual({ decision: 'denied', reason: 'malformed_current_challenge' }); + }); + + test('other falsy non-nulls deny malformed', () => { + for (const v of [0, '', false, NaN]) { + expect(classify(request(), v)).toEqual({ decision: 'denied', reason: 'malformed_current_challenge' }); + } + }); +}); + +// ---- malformed request battery ------------------------------------------- + +describe('malformed request denies malformed_request', () => { + const bad = { + 'wrong schema version (0)': request({ verificationIssuanceSchemaVersion: 0 }), + 'wrong schema version (2)': request({ verificationIssuanceSchemaVersion: 2 }), + 'string schema version': request({ verificationIssuanceSchemaVersion: '1' }), + 'empty memberRef': request({ memberRef: '' }), + 'all-digit memberRef (phone masquerade)': request({ memberRef: '15551234567' }), + 'over-long memberRef': request({ memberRef: `m${'a'.repeat(256)}` }), + 'memberRef with space': request({ memberRef: 'mem ber' }), + 'memberRef trailing newline': request({ memberRef: 'member_abc\n' }), + 'memberRef trailing CR': request({ memberRef: 'member_abc\r' }), + 'memberRef trailing LS': request({ memberRef: 'member_abc\u2028' }), + 'memberRef trailing PS': request({ memberRef: 'member_abc\u2029' }), + 'numeric memberRef': request({ memberRef: 123 }), + 'null memberRef': request({ memberRef: null }), + 'empty phoneRef': request({ phoneRef: '' }), + 'phoneRef with space': request({ phoneRef: '1555 1234' }), + 'phoneRef trailing newline': request({ phoneRef: '15551234567\n' }), + 'over-long phoneRef': request({ phoneRef: '1'.repeat(257) }), + 'numeric phoneRef': request({ phoneRef: 15551234567 }), + 'empty actor': request({ actor: '' }), + 'all-digit actor': request({ actor: '15551234567' }), + 'actor trailing newline': request({ actor: 'member_abc\n' }), + 'null actor': request({ actor: null }), + 'asOf not a date': request({ asOf: 'yesterday' }), + 'asOf month 13': request({ asOf: '2026-13-01T00:00:00Z' }), + 'asOf Feb 30': request({ asOf: '2026-02-30T00:00:00Z' }), + 'asOf hour 24': request({ asOf: '2026-07-21T24:00:00Z' }), + 'asOf minute 60': request({ asOf: '2026-07-21T12:60:00Z' }), + 'asOf second 60': request({ asOf: '2026-07-21T12:00:60Z' }), + 'asOf missing Z': request({ asOf: '2026-07-21T12:00:00' }), + 'asOf with millis': request({ asOf: '2026-07-21T12:00:00.000Z' }), + 'asOf trailing newline': request({ asOf: '2026-07-21T12:00:00Z\n' }), + 'numeric asOf': request({ asOf: 1700000000 }), + }; + + for (const [name, value] of Object.entries(bad)) { + test(name, () => { + expect(classify(value, null)).toEqual({ decision: 'denied', reason: 'malformed_request' }); + }); + } + + test('non-leap Feb 29 denies but leap Feb 29 is accepted', () => { + expect(classify(request({ asOf: '2026-02-29T00:00:00Z' }), null).decision).toBe('denied'); + expect(classify(request({ asOf: '2024-02-29T00:00:00Z' }), null).decision).toBe('issue'); + }); +}); + +// ---- malformed current-challenge battery --------------------------------- + +describe('malformed current challenge denies malformed_current_challenge', () => { + const bad = { + 'wrong schema version': challenge({ verificationIssuanceSchemaVersion: 2 }), + 'empty challengeRef': challenge({ challengeRef: '' }), + 'challengeRef with space': challenge({ challengeRef: 'chal 1' }), + 'challengeRef trailing newline': challenge({ challengeRef: 'chal_0001\n' }), + 'numeric challengeRef': challenge({ challengeRef: 1 }), + 'all-digit memberRef': challenge({ memberRef: '15551234567' }), + 'empty memberRef': challenge({ memberRef: '' }), + 'empty phoneRef': challenge({ phoneRef: '' }), + 'phoneRef with space': challenge({ phoneRef: '1 2' }), + 'unknown status': challenge({ status: 'expired' }), + 'numeric status': challenge({ status: 1 }), + 'status pending mixed-case': challenge({ status: 'Pending' }), + 'expiresAt not a date': challenge({ expiresAt: 'soon' }), + 'expiresAt Feb 30': challenge({ expiresAt: '2026-02-30T00:00:00Z' }), + 'expiresAt with millis': challenge({ expiresAt: '2026-07-21T12:05:00.000Z' }), + 'reissueAfter not a date': challenge({ reissueAfter: 'later' }), + 'reissueAfter hour 24': challenge({ reissueAfter: '2026-07-21T24:00:00Z' }), + 'reissueAfter trailing newline': challenge({ reissueAfter: '2026-07-21T11:00:00Z\n' }), + }; + + for (const [name, value] of Object.entries(bad)) { + test(name, () => { + expect(classify(request(), value)).toEqual({ decision: 'denied', reason: 'malformed_current_challenge' }); + }); + } +}); + +// ---- structural hostile-input safety (both positions) -------------------- + +describe('structural hostile-input safety', () => { + function structuralCases(base) { + const missing = base(); + delete missing.asOf; // request path; harmless label for challenge path too + const extra = base(); + extra.extra = 'x'; + const inherited = Object.create({ injected: 'x' }); + Object.assign(inherited, base()); + const nonEnumExtra = base(); + Object.defineProperty(nonEnumExtra, 'hidden', { value: 1, enumerable: false }); + const symbolKey = base(); + symbolKey[Symbol('s')] = 1; + return { missing, extra, inherited, nonEnumExtra, symbolKey }; + } + + test('request: proxy, revoked proxy, array, foreign prototype, and shape deviations deny', () => { + const p = new Proxy(request(), {}); + const { proxy: revoked, revoke } = Proxy.revocable(request(), {}); + revoke(); + const foreign = Object.assign(Object.create({ x: 1 }), request()); + const s = structuralCases(request); + for (const value of [p, revoked, [], request.call, foreign, s.extra, s.inherited, s.nonEnumExtra, s.symbolKey, 'str', 42, true]) { + expect(classify(value, null)).toEqual({ decision: 'denied', reason: 'malformed_request' }); + } + // Array specifically. + expect(classify([SCHEMA, MEMBER, PHONE, MEMBER, ASOF], null).reason).toBe('malformed_request'); + }); + + test('current challenge: proxy, revoked proxy, array, foreign prototype, and shape deviations deny', () => { + const p = new Proxy(challenge(), {}); + const { proxy: revoked, revoke } = Proxy.revocable(challenge(), {}); + revoke(); + const foreign = Object.assign(Object.create({ x: 1 }), challenge()); + const s = structuralCases(challenge); + for (const value of [p, revoked, [], foreign, s.extra, s.inherited, s.nonEnumExtra, s.symbolKey, 'str', 42, true]) { + expect(classify(request(), value)).toEqual({ decision: 'denied', reason: 'malformed_current_challenge' }); + } + }); + + test('an accessor property is never invoked (getter side effect does not fire)', () => { + let invoked = 0; + const hostile = request(); + Object.defineProperty(hostile, 'memberRef', { + get() { invoked += 1; return MEMBER; }, + enumerable: true, + configurable: true, + }); + expect(classify(hostile, null)).toEqual({ decision: 'denied', reason: 'malformed_request' }); + expect(invoked).toBe(0); + }); + + test('a descriptor-forging / trap-counting proxy denies with zero traps fired', () => { + let traps = 0; + const target = request(); + const handler = { + get(t, k, r) { traps += 1; return Reflect.get(t, k, r); }, + getOwnPropertyDescriptor(t, k) { traps += 1; return Reflect.getOwnPropertyDescriptor(t, k); }, + ownKeys(t) { traps += 1; return Reflect.ownKeys(t); }, + has(t, k) { traps += 1; return Reflect.has(t, k); }, + getPrototypeOf(t) { traps += 1; return Reflect.getPrototypeOf(t); }, + }; + const p = new Proxy(target, handler); + expect(classify(p, null)).toEqual({ decision: 'denied', reason: 'malformed_request' }); + expect(traps).toBe(0); // isProxy rejects before any trap can fire + }); +}); + +// ---- never throws -------------------------------------------------------- + +describe('never throws on any input', () => { + const weird = [ + undefined, null, NaN, Infinity, -Infinity, 0, -0, '', 'x', 42, true, false, + Symbol('s'), () => {}, [], [1, 2, 3], {}, new Map(), new Set(), + Object.create(null), new Date(0), /re/, BigInt(1), + ]; + + test('first argument: any weird value resolves to a frozen denial, never a throw', () => { + for (const value of weird) { + let out; + expect(() => { out = classify(value, null); }).not.toThrow(); + expect(out.decision === 'denied' || out.decision === 'issue' || out.decision === 'refuse').toBe(true); + expect(Object.isFrozen(out)).toBe(true); + } + }); + + test('second argument: any weird value resolves without throwing', () => { + for (const value of weird) { + let out; + expect(() => { out = classify(request(), value); }).not.toThrow(); + expect(Object.isFrozen(out)).toBe(true); + } + }); + + test('a cyclic input never throws', () => { + const c = request(); + c.self = c; + expect(() => classify(c, null)).not.toThrow(); + expect(classify(c, null).reason).toBe('malformed_request'); + const d = challenge(); + d.self = d; + expect(classify(request(), d).reason).toBe('malformed_current_challenge'); + }); +}); + +// ---- determinism / immutability / singletons ----------------------------- + +describe('determinism, immutability, singletons', () => { + test('same inputs yield deeply equal, frozen verdicts', () => { + const a = classify(request(), challenge()); + const b = classify(request(), challenge()); + expect(a).toEqual(b); + expect(Object.isFrozen(a)).toBe(true); + expect(Object.isFrozen(a.subject)).toBe(true); + }); + + test('denial and refusal verdicts are shared frozen singletons', () => { + expect(classify({}, null)).toBe(classify({}, null)); + expect(classify(request({ actor: OTHER_MEMBER }), null)).toBe(classify(request({ actor: OTHER_MEMBER }), null)); + expect(classify(request(), challenge({ status: 'verified' }))).toBe(classify(request(), challenge({ status: 'verified' }))); + }); + + test('a strict-mode write to a frozen verdict throws and does not mutate', () => { + 'use strict'; + const v = classify(request(), null); + expect(() => { v.decision = 'hacked'; }).toThrow(); + expect(v.decision).toBe('issue'); + expect(() => { v.subject.memberRef = 'x'; }).toThrow(); + expect(v.subject.memberRef).toBe(MEMBER); + }); + + test('mutating an input record after the call does not affect the returned verdict', () => { + const chal = challenge(); + const v = classify(request(), chal); + chal.challengeRef = 'mutated'; + expect(v.supersededChallengeRef).toBe(CHALLENGE_REF); + }); +}); + +// ---- clockless / lexical UTC = chronological ----------------------------- + +describe('clockless: lexical UTC comparison equals chronological order', () => { + test('fixed-width real-date instants compare lexically as chronologically', () => { + // reissueAfter strictly after asOf → refuse; strictly before/equal → issue. + const later = '2026-12-31T23:59:59Z'; + const earlier = '2020-01-01T00:00:00Z'; + expect(classify(request({ asOf: ASOF }), challenge({ reissueAfter: later })).reason).toBe('cooldown_active'); + expect(classify(request({ asOf: ASOF }), challenge({ reissueAfter: earlier })).decision).toBe('issue'); + }); + + test('year-boundary ordering is respected', () => { + expect(classify( + request({ asOf: '2026-12-31T23:59:59Z' }), + challenge({ reissueAfter: '2027-01-01T00:00:00Z' }), + ).reason).toBe('cooldown_active'); + }); +}); + +// ---- phone can never masquerade as an identity --------------------------- + +describe('phone can never masquerade as the member or actor', () => { + test('an all-digit value is a valid phone handle but never a valid member/actor', () => { + // Valid as phoneRef (handle). + expect(classify(request({ phoneRef: '441234567890' }), null).decision).toBe('issue'); + // Invalid as memberRef or actor (identity requires a letter). + expect(classify(request({ memberRef: '441234567890' }), null).reason).toBe('malformed_request'); + expect(classify(request({ actor: '441234567890' }), null).reason).toBe('malformed_request'); + }); +}); + +// ---- source-boundary checks ---------------------------------------------- + +describe('source boundary', () => { + const src = fs.readFileSync(path.join(__dirname, 'whatsappVerificationIssuance.js'), 'utf8'); + // Strip line and block comments so the checks see only executable source. + const code = src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); + + test('holds no code / hash / secret / token / PII vocabulary in executable source', () => { + for (const forbidden of [ + /\bcode\b/i, /\bhash\b/i, /\bsecret\b/i, /\btoken\b/i, /\bbearer\b/i, + /\bpassword\b/i, /\botp\b/i, /\bpin\b/i, /\bdigest\b/i, /\bcredential\b/i, + /\bplaintext\b/i, /\bemail\b/i, /\baddress\b/i, /\bdob\b/i, + ]) { + expect(forbidden.test(code)).toBe(false); + } + }); + + test('requires only node:util and nothing else', () => { + const requires = [...code.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]); + expect(requires).toEqual(['node:util']); + }); + + test('is imported by nothing in the functions tree (source only, unused)', () => { + const files = fs.readdirSync(__dirname) + .filter((f) => f.endsWith('.js') && f !== 'whatsappVerificationIssuance.js' && f !== 'whatsappVerificationIssuance.test.js'); + for (const f of files) { + const body = fs.readFileSync(path.join(__dirname, f), 'utf8'); + expect(body.includes('whatsappVerificationIssuance')).toBe(false); + } + }); + + test('carries its SYSTEM_DESIGN §8.23 / WHATSAPP-002B provenance header', () => { + expect(src.includes('WHATSAPP-002B')).toBe(true); + expect(src.includes('§8.23')).toBe(true); + expect(src.includes('SOURCE ONLY, UNUSED')).toBe(true); + }); +}); From 578cc1444fcf69ba63c12c07abe8e838f5a47d17 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Tue, 21 Jul 2026 11:16:48 -0700 Subject: [PATCH 2/2] WHATSAPP-002B: lock structural-validation-precedes-actor-gate precedence in tests Test-only hardening (module byte-unchanged). Documents and locks the by-design precedence surfaced in adversarial review: structural validation of both inputs precedes the actor gate (the actor gate is the first *semantic* gate, not the first check). Adds an explicit assertion that a non-owner actor with a malformed challenge denies malformed_current_challenge, and a matrix assertion that no actor != memberRef input ever reaches an issue decision. 114/114 jest, ESLint clean. --- .../whatsappVerificationIssuance.test.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/functions/whatsappVerificationIssuance.test.js b/functions/whatsappVerificationIssuance.test.js index f4c007f..0dd1fe9 100644 --- a/functions/whatsappVerificationIssuance.test.js +++ b/functions/whatsappVerificationIssuance.test.js @@ -364,6 +364,42 @@ describe('precedence', () => { .toBe('actor_not_owner'); }); + // Structural validation of BOTH inputs precedes the actor gate (the actor gate is + // the first *semantic* gate, not the first check). So a non-owner actor paired with + // a structurally malformed currentChallenge reports the structural denial, not + // actor_not_owner. This is by design and safe: the only returns reachable before the + // actor gate are the two malformed *denials*, so actor != memberRef can never reach + // an issue path, and a denial reveals nothing about stored challenge state (it only + // reflects the shape of the object the caller itself assembled). + test('structural malformed_current_challenge outranks actor_not_owner (by design, safe)', () => { + expect(classify(request({ actor: OTHER_MEMBER }), {})) + .toEqual({ decision: 'denied', reason: 'malformed_current_challenge' }); + // ...and malformed_request still outranks both, even with a bad actor + bad challenge. + expect(classify({ actor: OTHER_MEMBER }, {})) + .toEqual({ decision: 'denied', reason: 'malformed_request' }); + }); + + // The safety consequence of the ordering above, stated directly: across the full + // matrix of well-formed challenge shapes (null / every status / matching+mismatching + // subject / past+future cooldown), a request whose actor is not the member NEVER + // yields an issue — it is always refused (or, for malformed challenge input, denied), + // never granted. + test('no actor != memberRef input ever reaches an issue decision', () => { + const challenges = [ + null, + challenge({ status: 'pending', expiresAt: EXPIRES_FUTURE, reissueAfter: REISSUE_PAST }), + challenge({ status: 'pending', expiresAt: EXPIRES_PAST, reissueAfter: REISSUE_PAST }), + challenge({ status: 'voided', reissueAfter: REISSUE_PAST }), + challenge({ status: 'verified', reissueAfter: REISSUE_PAST }), + challenge({ memberRef: OTHER_MEMBER, status: 'pending', expiresAt: EXPIRES_FUTURE, reissueAfter: REISSUE_PAST }), + challenge({ phoneRef: OTHER_PHONE, status: 'pending', expiresAt: EXPIRES_FUTURE, reissueAfter: REISSUE_PAST }), + ]; + for (const chal of challenges) { + const verdict = classify(request({ actor: OTHER_MEMBER }), chal); + expect(verdict.decision).not.toBe('issue'); + } + }); + test('subject_mismatch outranks already_verified and cooldown_active', () => { expect(classify(request(), challenge({ memberRef: OTHER_MEMBER, status: 'verified', reissueAfter: REISSUE_FUTURE })).reason) .toBe('subject_mismatch');