WHATSAPP-002B: pure one-time verification-code issuance decision (source only)#436
Merged
Conversation
✅ Deploy Preview for luminous-fox-7c393f ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
…nce 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #435. Child of #87 (WHATSAPP-002 — "Link a verified WhatsApp number and versioned consent to a member UID"). Parent #87 stays open and owner-gated (
needs-external-config).What this adds (source only, unused)
One pure, deterministic, non-throwing frozen-verdict reducer for the issuance half of #87's scope line "generate a one-time server code … make it short-lived, one-use, rate/attempt limited, and App Check protected" — the half the landed redemption reducer §8.16 (#419) explicitly leaves with #87 — plus its exhaustive test and a SYSTEM_DESIGN §8.23 section. Imported by nothing (no index, route, Rules, callable, or worker) → zero runtime behavior; nothing here awaits the owner-gated deploy.
classifyWhatsappVerificationIssuance(request, currentChallenge)decides, for one request to issue a code for a(member, phone)pair and the issuance-relevant projection of the durable challenge the server already has for that pair, whether a new one-time code may be issued now:request:{ verificationIssuanceSchemaVersion, memberRef, phoneRef, actor, asOf }.currentChallenge: a literalnull(no prior challenge) or{ verificationIssuanceSchemaVersion, challengeRef, memberRef, phoneRef, status, expiresAt, reissueAfter },status ∈ { pending | verified | voided }, wherereissueAfteris the caller-computed instant before which no new code may be minted.{ decision: 'issue', reason: 'fresh', subject }— nothing live to replace;{ decision: 'issue', reason: 'superseding', subject, supersededChallengeRef }— replaces a still-live pending prior, naming it for the caller to void;{ decision: 'refuse', reason }—actor_not_owner|subject_mismatch|already_verified|cooldown_active(all change nothing);{ decision: 'denied', reason }—malformed_request|malformed_current_challenge.It never throws and imports only
node:util. It mints nothing (no code, hash, or new challenge id) and holds no code/secret vocabulary at all (a source-boundary test enforces the absence).Why it exists alongside §8.16 (the composition)
§8.16 (redemption) bounds how many guesses a single challenge permits (per-challenge attempt budget, one-use, expiry). It cannot see two abuse vectors:
This reducer closes both: the cooldown gate applies to a
voidedprior exactly as to apendingone (so a fresh budget cannot be minted faster than the owner's cooldown), and supersession keeps at most one live challenge per pair. Redemption without issuance leaves those holes open.Safety invariants (all test-locked)
actormust equalmemberRef); otherwiseactor_not_owner, 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.verifiedpair never gets another code (already_verified), regardless of expiry/cooldown.reissueAfteriscooldown_active; the gate coversvoidedpriors, closing the exhaust→void→reissue loop.supersedingand names the priorsupersededChallengeRefto void; a terminal/expired prior isfreshand names nothing.subjectand (when superseding) an existing ref to void — never a code, hash, new id, or policy value — and grants no auth/membership/role; the phone is a channel handle (all-digit OK) while member/actor are letter-requiring identities, so a phone can never masquerade as the member.reissueAfter/expiresAtand this reducer only enforces the gates by lexical UTC comparison (cf. the MEMBERS-DUES-001 — Build server-authoritative annual membership checkout and renewal #114 owner-decision rule).Date); descriptor-based reads (no getter ever invoked); proxies (incl. revoked), foreign prototypes, inherited/extra/missing/symbol keys, and out-of-shape values deny rather than being partially interpreted or thrown. A literalnullis the sole "no prior challenge" signal.Tests
functions/whatsappVerificationIssuance.test.js(114 cases): frozen surface + a "no code/mint/grant/auth vocab" enum check; the full decision matrix vs an independent hand-authored oracle + a decision:reason coverage assertion; each invariant above with its boundary (asOf == reissueAfterallowed;asOf == expiresAtexpired → fresh not superseding; void-prior cooldown; supersededChallengeRef is an existing ref); precedence (malformed_request≻malformed_current_challenge;actor_not_owner≻ subject ≻ verified ≻ cooldown) plus a lock that structural validation of both inputs precedes the actor gate — the actor gate is the first semantic gate, so a non-owner actor with a malformed challenge deniesmalformed_current_challenge, and noactor != memberRefinput over the full well-formed challenge matrix ever reaches anissue;null-is-the-sole-no-challenge-signal (undefined/0/'' deny); malformed-request and malformed-current-challenge batteries (proxy/revoked/array/foreign-prototype/inherited/extra-enumerable-and-non-enumerable/missing/symbol/accessor-never-invoked/trap-counting-proxy/bad-version/bad-enum/bad-ref/impossible-and-leap dates/trailing line-terminators); hostile-input never-throws (both argument positions incl. cyclic); determinism/immutability/singletons incl. a strict-mode tamper and post-call input mutation; clockless lexical=chronological; phone-can't-masquerade; and comment-stripped source-boundary checks (word-bounded code/hash/secret/PII vocabulary, header §8.23/WHATSAPP-002B, imported-by-nothing, requires onlynode:util).Local pre-flight: ESLint 8.56.0 exit 0; jest 114/114 pass (secrets unset).
Distinct from every sibling
classifyWhatsappVerificationRedemption(WHATSAPP-002A — one-time number-control-proof redemption decision (source only) #419) decides whether one attempt proves channel control and advances a single challenge's attempt budget; this issues the challenge §8.16 later redeems — mints no code, compares no hash — and bounds the number and rate of challenges §8.16 cannot see.classifyMembershipProviderLink(MEMBERS-IDENTITY-001C — Provider-neutral external-account link + collision contract (source only, unused) #367) is the provider-neutral link/collision authorizer; this triggers no link and resolves no collision.projectMembershipConsentState(MEMBERS-IDENTITY-001D — Provider-neutral versioned consent-state derivation (source only, unused) #370) owns the versioned consent state; this captures and reads no consent.rateLimit.jsis a generic limiter mechanism (infrastructure); this is the domain decision that composes a rate/verification signal — it re-implements no limiter.Out of scope (stays with gated parent #87 and deps #85/#81, AUTH-001/ABUSE-001)
Code generation/hashing/send, App Check, the durable challenge store, the live rate limiter, versioned-consent capture, phone normalization, multi-UID conflict handling, and all WhatsApp/provider wiring. This contract performs none of them; it only decides the issuance disposition and returns it.
Not a deploy proof
Source change, tests, merge, and Firebase deployment are separate states. Merging this deploys nothing and changes no live behavior.