Skip to content

OTP Provider Slice (Prelude, flag-gated) - Implementation Plan โ€‹

Date: 2026-06-22 Branch / worktree: claude/otp-provider-prelude (.claude/worktrees/otp-provider-prelude, fresh off origin/dev) Service: services/api/auth (Cloud Run, Express, port 8084) - Client: apps/web/src/screens/auth/PhonePinSignup.jsxStatus: plan for operator review. No code written yet. Canonical refs: docs/privacy/SEALED_IDENTITY.md sections 11.0 / 11.1 / 11.4 / 11.6 / 7; docs/planning/plans/2026-05-11-sealed-identity-stage-a-phase-4-impl.md.

This plan was produced by a planning workflow (7 parallel code/API maps, a synthesis pass, and a 3-lens adversarial red-team). The red-team findings and their resolutions are folded into the design below and itemized in the ledger at the end.


0. The decision you need to make first โ€‹

The original framing was "a flag-gated provider slice that defers all of Stage B (no app-controlled UID, no Auth-record scrub, no PIN-sealing)." The red-team found this is internally contradictory for an end-to-end signup:

  • Today, the only thing that proves "this phone was verified" to phoneCreateUser is the Firebase phone credential stamped onto the Auth UserRecord by signInWithPhoneNumber.
  • An external OTP provider verifies the phone off-platform, so under the external path there is no Firebase phone session. createUser sits behind verifyFirebaseToken (index.js:119) and expects getUser(uid).phoneNumber to match (phoneCreateUser.js:84-96).
  • To make signup work end-to-end on the external path, something has to mint a Firebase session with an app-controlled UID. That app-controlled-UID custom-token mint is the Stage B signup bootstrap described in SEALED_IDENTITY 11.1, which we listed as deferred.

So the work naturally separates into two slices:

SliceWhat it isStage B entanglementIndependently shippable?
Slice 1The OTP send money choke point + provider adapter + monthly cap counter + the feature flagNone. Pure Stage-A-safe, 11.6-aligned infrastructure. Flag stays firebase; nothing flips.Yes. Self-contained draft PR.
Slice 2verify-otp + signed verification proof + app-controlled-UID custom-token mint + createUser proof path + client verify seamYes. This IS the 11.1 bootstrap mechanic. Openly Stage-B-adjacent.Only on top of Slice 1.

Recommendation: ship Slice 1 first as its own draft PR, then do Slice 2 as a clearly-labeled follow-up that openly owns the bootstrap change and carries the security fixes below. This keeps the deferral honest and lands real value (the money choke point, adapter, cap, flag) with near-zero risk.

The alternative is legitimate too: SEALED_IDENTITY 11.0 already commits to "if we custom-token, go all the way," and you prefer finishing things end-to-end on dev. If you want the full external-OTP signup working on dev behind the flag now, we do Slice 1 + Slice 2 together and simply stop calling the bootstrap "deferred." Both are sound; the choice is whether the Stage B bootstrap enters this work now or next.

Everything below specifies both slices in full so the artifact is implementation-ready either way. Only the PR packaging depends on your answer.


Slice 1 build status (2026-06-22) โ€‹

Built and committed on claude/otp-provider-prelude (commits: plan ad3e1b5f, implementation cdd52fdd). Auth suite green: 123 pass / 1 skip (the Twilio contract row). Files: otpProvider.service.js, lib/otpMetrics.js, routes/phoneOtp.js, index.js mount, package.json + lockfile (@prelude.so/sdk@^0.12.0), and 4 test files. The SDK status literals were verified directly against the package type defs (create: success|retry|challenged|blocked|shadow_blocked; check: success|failure|expired_or_not_found).

Two deliberate deviations from the design above:

  • No supertest devDep. The repo tests handlers by invoking them with mock req/res and mocking the dependency modules (see phone.test.js); the send-otp route test follows that idiom. Leaner and adds no dependency. Supersedes the supertest line in sections 4/5.
  • App Check consume/limited-use deferred to Slice 2. Consume protection requires the client to request a limited-use token, which is Slice 2 client work. Slice 1 uses the standard verifyAppCheck gate; the durable per-IP + per-phone caps + monthly cap are the substantive money protections present now, and the endpoint is dormant (503) under the default flag so there is no live replay surface yet. A NOTE comment in phoneOtp.js tracks the upgrade.

Deploy workflows intentionally NOT edited. Referencing a not-yet-created PRELUDE_API_KEY secret in --update-secrets would break the next auth-api deploy. The code defaults to firebase (dormant), so Slice 1 merges with zero deploy-config risk. Prod is untouched.

Dev enablement checklist (operator-gated; needs your Prelude account) โ€‹

Guardrail: test numbers only until you approve real numbers.

  1. Create/confirm a Prelude account; generate a test/sandbox API token; register test phone numbers with fixed codes in the Prelude dashboard.
  2. Create the Secret Manager secret on lantern-app-dev: PRELUDE_API_KEY = the test token. (Pipe the value from a file / --data-file=-; never inline it in a shell command.)
  3. Edit .github/workflows/deploy-dev.yml auth-api step: append ,OTP_PROVIDER=prelude,OTP_MONTHLY_CAP=2000 to --set-env-vars (line ~1029) and ,PRELUDE_API_KEY=PRELUDE_API_KEY:latest to --update-secrets (line ~1030).
  4. Local dev: add OTP_PROVIDER=prelude, OTP_MONTHLY_CAP=2000, PRELUDE_API_KEY=<test token> to the gitignored repo-root .env.local.
  5. Smoke against a registered test number: send-otp returns 200 (no SMS, not billed); over-cap returns 429 OTP_CAPACITY.

1. Slice 1 - OTP send choke point (Stage-A-safe, ship now) โ€‹

1.1 Provider adapter โ€‹

services/api/auth/src/services/otpProvider.service.js, modeled on the existing src/lib/email.js pattern (lazy SDK singleton, apiKey passed as an argument, normalized envelope, missing-key soft guard, never throws raw provider errors at the route).

// Provider-agnostic envelopes (the ONLY shapes routes see):
//   send:  { ok, requestId?, errorCode? }
//   check: { ok, status: 'approved'|'rejected'|'expired', errorCode? }
//
// errorCode closed set (contract-tested):
//   NOT_CONFIGURED, PROVIDER_BLOCKED, PROVIDER_RATE_LIMIT,
//   PROVIDER_UNAVAILABLE, OTP_INVALID, OTP_EXPIRED

export async function sendOtp({ apiKey, phone, signals })
export async function checkOtp({ apiKey, phone, code })
export function normalizeSendStatus(providerStatus, reason)   // pure
export function normalizeCheckStatus(providerStatus)          // pure; throws on unknown
  • Provider selection via process.env.OTP_PROVIDER with a switch (prelude | twilio); firebase short-circuits before the adapter is ever called.
  • Prelude impl (@prelude.so/sdk): verification.create({ target: { type:'phone_number', value: phone }, signals }) and verification.check({ target, code }). Status mapping:
    • create: success / retry / challenged -> { ok:true, requestId }; blocked / shadow_blocked -> { ok:false, errorCode:'PROVIDER_BLOCKED' } (note: HTTP 200, branch on status not HTTP code).
    • check: success -> approved; failure -> { ok:false, status:'rejected', errorCode:'OTP_INVALID' }; expired_or_not_found -> { ok:false, status:'expired', errorCode:'OTP_EXPIRED' }; PSD2 statuses (transaction_*) -> throw "unrecognized" so the contract test forces a decision.
    • SDK 429 -> PROVIDER_RATE_LIMIT (carry Retry-After); 5xx / network / timeout -> PROVIDER_UNAVAILABLE. Explicit AbortController timeout (~8s) - a money-path call must not hang.
  • Twilio fallback stub (sendTwilio / checkTwilio, case 'twilio'): real-shaped, reads process.env.TWILIO_*, throws NOT_IMPLEMENTED until needed. Contract-test row marked .skip. This is what makes the provider a config swap, not a rewrite.

Open item (verify before coding): confirm the exact @prelude.so/sdk package name, current version, and that verification.create / verification.check request+response shapes match the above (npm view + docs.prelude.so). The adapter does not depend on the SDK's TS type names.

1.2 POST /auth/phone/send-otp (the single money-spending choke point) โ€‹

Mounted as its own explicit prefix app.use('/auth/phone/send-otp', sendOtpGate, ...) placed before both app.use('/auth/phone', ...unauthPhone, phoneAdminRoutes) (index.js:122) and app.use('/auth/phone', ...unauthPhone, phoneRoutes) (index.js:126), mirroring the reclaim / createUser mounts at :114 / :119. This prevents the existing unauthPhone prefix middleware from double-applying App Check and a looser ipRateLimit(15,...) to this route. A route test asserts exactly one App Check verification and one (the intended) rate-limit layer run. (Red-team: security #6, completeness minor.)

Middleware stack, all before the provider call:

#LayerMechanism
1App Check (consume)send-otp-specific variant that calls verifyToken with limited-use / consume semantics (the shared verifyAppCheck does not consume, so a harvested token is replayable within its TTL). Client requests a limited-use token for this call. (Red-team security #4.)
2Per-IP (durable)checkKeyDurable('send-otp-ip:'+ip, 5, 10*60*1000) - must be cross-instance, because in-memory ipRateLimit is per-process on Cloud Run and bypassable across instances. (Red-team security #5.)
3Per-phone (durable)checkKeyDurable('send-otp:'+e164, 5, 60*60*1000)
4Monthly cap (durable, transactional)reserveOtpSend() (1.4)
5ProvidersendOtp({ apiKey, phone, signals })

Request { phone }. Responses: 200 { ok:true } (we do not return requestId; check is keyed on phone+code) - 400 INVALID_PHONE - 401 APP_CHECK_REQUIRED / 403 APP_CHECK_FAILED - 429 RATE_LIMITED {retryAfterMs} (IP or per-phone) - 429 OTP_CAPACITY {message} (monthly cap, graceful "onboarding in batches" per 11.6, not a 500) - 503 OTP_PROVIDER_DISABLED (flag is firebase) - 502 PROVIDER_UNAVAILABLE - 403 PROVIDER_BLOCKED.

Scope rule (11.6): the monthly cap applies to new-signup sends only. Returning logins (PIN + custom token) never hit this endpoint. Existing-user new-device verification is a deferred follow-up and must not be routed through the capped counter when built.

1.3 Layer-2 hard ceiling (mandatory before any flip) โ€‹

A provider-side and/or GCP-billing hard spend ceiling is required, not optional, before OTP_PROVIDER is flipped to prelude on any environment. Layer 1 (the app counter) is graceful; number-rotation pumping that evades per-IP/per-phone is bounded only by the monthly cap, so the billing ceiling is the real backstop. (Red-team security #5.) Configured out of band; noted here, not code.

1.4 Monthly send counter โ€‹

services/api/auth/src/lib/otpMetrics.js:

  • Doc: flat collection metrics_otpSends/{YYYY-MM} (UTC bucket from new Date().toISOString().slice(0,7)), mirroring the existing _rateLimits flat-collection idiom. (The literal metrics/otpSends/{YYYY-MM} in 11.6 is an invalid odd-segment .doc() path; the SEALED_IDENTITY edit corrects this.)
  • reserveOtpSend() via db.runTransaction: read count, reject with {allowed:false} if >= OTP_MONTHLY_CAP, else tx.set(ref, { count: FieldValue.increment(1), updatedAt: serverTimestamp() }, { merge:true }). This is a stronger pattern than the bare FieldValue.increment sketch in 11.6 (the transaction is required for race-free cap enforcement); the SEALED_IDENTITY edit notes the upgrade.
  • releaseOtpSend() (compensating FieldValue.increment(-1)): called only on definitive pre-dispatch failures where no SMS could have been billed - PROVIDER_BLOCKED (fraud-refused, 200, no send), PROVIDER_RATE_LIMIT (provider 429, no send), NOT_CONFIGURED, connection-refused. Do NOT release on AbortController timeout - the SMS may already have dispatched, so we conserve budget (fail toward over-counting, never under-counting). (Red-team security #5 + completeness major: timeout-after-dispatch.)
  • OTP_MONTHLY_CAP is an env var (default chosen at review from the 500 / 1000 / 2000 bracket = ~$29 / $58 / $117 on Twilio, ~$18 / $35 / $70 on Prelude).

1.5 Feature flag โ€‹

  • Server: OTP_PROVIDER in {firebase, prelude, twilio}, default firebase. Helper isExternalOtp() returns OTP_PROVIDER !== 'firebase'. When firebase, send-otp / verify-otp mount but return 503 OTP_PROVIDER_DISABLED (fail loud, never silently spend).
  • Client: VITE_OTP_PROVIDER (reuses the existing VITE_* flag-family prefix), default/unset -> Firebase path unchanged.

1.6 Slice 1 deliverables โ€‹

Adapter + send-otp endpoint + counter + flag + config/secrets + tests. On a firebase env this changes nothing for users (endpoints return 503, no client calls them). It is a clean, low-risk draft PR.


2. Slice 2 - verify + bootstrap mint + account creation (Stage-B-adjacent) โ€‹

This slice owns the app-controlled-UID custom-token bootstrap. It must carry every security fix below.

2.1 POST /auth/phone/verify-otp โ€‹

Stack: consume-App-Check + durable per-IP + durable per-phone guess limit (checkKeyDurable('verify-otp:'+e164, 10, 10*60*1000) - cross-instance, because code-guessing is the security-critical brute force, not just enumeration; red-team security minor). No monthly counter (verify costs nothing).

Flow: normalize phone (400 INVALID_PHONE) -> checkOtp (OTP_INVALID/OTP_EXPIRED -> 401; PROVIDER_UNAVAILABLE -> 502) -> on ok:

  1. Generate newUid once (crypto.randomUUID(), app-controlled, NOT phone-derived).
  2. Existing-account reuse (blocker fix): look up an existing UID by phoneHash (the Stage A index, phone.js lookup). If one exists, reuse it; only mint a fresh UID when no prior account exists. Otherwise returning users who re-enter signup fork into orphaned, decoupled accounts (lost encryptedSeed / authProofHash). (Red-team completeness blocker #1.)
  3. Write a single-use server-side verification marker otpVerifications/{markerId} = { uid, phoneHash, purpose:'phone_create', createdAt, expireAt(+10min) }. This corroborates the proof with server state so a leaked signing secret alone cannot forge phone verification. (Red-team security blocker #1.)
  4. Mint customToken = createCustomToken(uid) and proof = mintProof({ phone, uid, markerId }), using the same uid in both (so proof.uid === decoded(customToken).uid; if they diverge every signup fails PHONE_MISMATCH). (Red-team completeness blocker #2.)
  5. Return { proof, customToken, expiresInSec }.

2.2 Signed verification proof โ€‹

services/api/auth/src/lib/otpProof.js: mintProof({phone, uid, markerId}), verifyProof(token).

  • Payload { phone, uid, markerId, purpose:'phone_create', iat, exp(iat+600), jti }; HMAC-SHA256(OTP_PROOF_SECRET, base64url(payload)); token = base64url(payload).hexMac; constant-time compare (mirror customToken.service.js:46-55).
  • OTP_PROOF_SECRET is a verification-bypass signing key, not a hash pepper. Distinct Secret Manager secret, restricted access, distinct per environment (local != dev != prod, so a leaked .env.local cannot forge proofs accepted by deployed services), documented rotation/incident playbook (rotating it invalidates in-flight proofs, 10-min blast radius). (Red-team security blocker #1 + minor.)

2.3 createUser consumption (strictly additive, flag-off path byte-for-byte unchanged) โ€‹

Edit phoneCreateUser.js as a guarded prepend, leaving the existing :84-96 getUser().phoneNumber block textually identical and simply unreachable when the proof path runs. The optional proof body field is parse-but-ignore when the path is not taken. (Red-team scope #2.)

Per-request branch (so a server-side flag flip during a rolling deploy and the admin Firebase-link arm both keep working; red-team completeness major + scope minor):

  • proof present -> proof path: verify HMAC + purpose + exp; require normalizePhoneNumber(proof.phone) === norm; require req.user.uid === proof.uid; consume the otpVerifications marker transactionally (markerId); burn jti transactionally; and enforce the existing-account guard.
  • proof absent -> legacy getUser().phoneNumber path (admins on Firebase-link, and any in-flight Firebase signup mid-deploy).

This means we do not gate purely on isExternalOtp(); we branch on proof presence so a flip-back never strands in-flight external signups and never 403s admin signups on a prelude env.

One phone -> one account (blocker fix): enforce phoneHash uniqueness transactionally in createUser (reject if any users doc already has this phoneHash), not just the per-uid phoneSalt idempotency. Combined with the 2.1 existing-UID reuse, this closes the multi-account-from-one-verified-phone hole. (Red-team security blocker #2, completeness blocker #1.)

Untouched: the login mint (issueCustomToken / createCustomToken), authProofHash, phoneHash computation, ban check, idempotency. Only what proves the phone was verified changes, and only under the proof path.

2.4 Phone-less Auth record audit (required) โ€‹

The bootstrap mint creates a Firebase Auth UserRecord with no phoneNumber. Before shipping Slice 2, audit every server reader of Firebase Auth phoneNumber and confirm none break for phone-less users: phoneRecycling.js, phoneAdmin.js / check-admin in phone.js, adminProviderLink.js. Document the result (this is the "comprehensive testing for auth changes" rule). Add an invariant + test: the bootstrap UserRecord MUST keep phoneNumber null/unset for the life of the slice (preserves SEALED_IDENTITY 11.5 residual #1 - the phone never touches the Auth record). (Red-team completeness blocker #3, scope minor.)

2.5 Client changes - PhonePinSignup.jsx (non-admin only, behind VITE_OTP_PROVIDER) โ€‹

Flag read near :183. Under the flag and not admin:

  • Send (handleStep1, :267-294): skip the invisible reCAPTCHA setup; POST /auth/phone/send-otp with the limited-use App Check header; 200 -> setStep(2), set an otpSent flag (no confirmationResult). Error mapping (429/403/400/503) to existing friendly copy.
  • Resend (handleResendCode, :373-392): re-POST /auth/phone/send-otp (no verifier teardown).
  • Verify (handleStep2, :344): POST /auth/phone/verify-otp { phone, code } -> { proof, customToken }; signInWithCustomToken(auth, customToken) (already imported :18) -> setPhoneUser(credential.user); stash proof in otpProofRef. Error mapping (OTP_INVALID / OTP_EXPIRED).
  • createAccount (:483-497): add proof: otpProofRef.current to the createPhoneUser payload; thread it through signupApi.js:65.

Flag off/unset -> none of the above runs; the Firebase signInWithPhoneNumber / confirm path is byte-for-byte unchanged.


3. Config & secrets โ€‹

VarWhereNotes
OTP_PROVIDERCloud Run envfirebase(default) / prelude / twilio, per env
OTP_MONTHLY_CAPCloud Run envinteger bracket, tunable without code redeploy
PRELUDE_API_KEYSecret Managernew secret in both dev and prod projects
OTP_PROOF_SECRETSecret Managerbypass signing key; distinct per env; restricted access
TWILIO_*Secret Manager (later)documented now, wired when the Twilio impl lands
VITE_OTP_PROVIDERapps/web build envfirebase default
  • Append the two secrets and two env vars to --update-secrets / --set-env-vars in both .github/workflows/deploy-dev.yml (~:1024-1031) and deploy-prod.yml (~:362-369). The two workflows drift (prod does not even mount PHONE_HASH_PEPPER); add to both now even though prod stays firebase, so the proof path does not 503 there later.
  • Add to repo-root .env.local for local dev (Prelude test key + a local-only OTP_PROOF_SECRET).
  • Add @prelude.so/sdk (dep) and supertest (devDep) to services/api/auth/package.json; regenerate the lockfile; confirm npm ci installs them in the auth workspace CI job.
  • Never inline secret values in shell commands (reference $VAR from .env.local).

4. File-by-file change list โ€‹

Add (Slice 1): services/api/auth/src/services/otpProvider.service.js; services/api/auth/src/lib/otpMetrics.js; route module for send-otp; tests: otpProvider.service.test.js, otpProvider.contract.test.js, otpMetrics.test.js, and the send-otp route test.

Add (Slice 2): services/api/auth/src/lib/otpProof.js; verify-otp route (same module or sibling); services/api/auth/src/routes/__tests__/phoneCreateUser.test.js (does not exist today - the proof-path edge cases have no home otherwise); otpProof.test.js; verify-otp route test.

Edit: services/api/auth/src/index.js (mount order per 1.2 + sendOtpGate); phoneCreateUser.js (guarded-prepend proof path, phoneHash-uniqueness, marker+jti burn - Slice 2); services/api/auth/package.json (deps); services/api/auth/openapi.json (document both new endpoints + the optional proof field on createUser - the admin portal renders this spec; red-team completeness major); apps/web/src/screens/auth/PhonePinSignup.jsx + apps/web/src/lib/signupApi.js (Slice 2 client seams); deploy-dev.yml + deploy-prod.yml; repo-root .env.local; docs/privacy/SEALED_IDENTITY.md + the Stage A Phase 4 plan (record what shipped, correct the invalid doc-path literal, note the transactional cap upgrade - keep the tracker current per operator feedback).


5. Edge-case + test matrix (augmented with red-team gaps) โ€‹

Conventions: auth tests are pass/fail-gated (no coverage floor); vi.mock before dynamic await import(); mock the adapter in route tests, the SDK in adapter tests; Firestore via in-memory Map for transactions; supertest harness for status codes.

#CaseExpectedSlice
1Wrong codeverify-otp 401 OTP_INVALID; no proof/token2
2Expired/unknown code401 OTP_EXPIRED2
3Correct code200 {proof, customToken}; proof.uid === decoded(customToken).uid2
4Resendsecond send 200; counter +1 again1
5Per-phone send limit (durable)6th/hr -> 429, provider NOT called1
6Per-IP send limit (durable, cross-instance)6th/10min -> 4291
7Monthly cap hit429 OTP_CAPACITY, provider NOT called, counter not past cap1
8App Check missing/invalid (+ consume: replayed token rejected)401/403, nothing else runs1
9Provider 5xxPROVIDER_UNAVAILABLE -> 502; counter rolled back1
9bProvider timeout after possible dispatch502; counter NOT rolled back (budget conserved)1
10Provider fraud-block (200 blocked)PROVIDER_BLOCKED -> 403; counter rolled back1
11Provider 429PROVIDER_RATE_LIMIT -> 429; counter rolled back (no SMS billed)1
12Replay of proof (jti reuse)409 PROOF_REPLAYED2
12bMarker reuse (server-state corroboration)second consume of same markerId -> 4092
13Expired proof401 PROOF_EXPIRED2
14Tampered proof / bad sig403 PHONE_MISMATCH (constant-time)2
15proof.phone != body phone403 PHONE_MISMATCH2
16proof.uid != req.user.uid403 PHONE_MISMATCH2
17Concurrent sends, same phonetransaction serializes monthly total; drive the full middleware stack, not just the helper; assert no over-count + per-phone burst bound documented1
18Flag OFF (firebase)endpoints 503; createUser uses getUser().phoneNumber; a stray proof in the body is ignored, not honored; existing Firebase tests greenboth
18bcreateUser flag-on, no proof (admin Firebase-link)falls back to legacy phone path, NOT 4032
19Flag ON + test number (CI)fixed code -> success; deterministic; not billed1/2
20apiKey unsetNOT_CONFIGURED; provider never called; route 5031
21Invalid phone (send & verify)400 INVALID_PHONE before any provider/counter callboth
22Verify brute-force (durable, cross-instance)11th/10min -> 4292
23Banned phone403 BANNED even via proof path2
24Idempotent createUser (same UID) + phoneHash uniqueness (second signup, same phone)same-UID -> 409 treated success; different-UID same-phoneHash -> rejected (no fork)2
25Returning user re-runs signupconverges to existing UID by phoneHash lookup; no orphaned account2
26Crash-and-retry mid-signupsecond verify-otp does not orphan the first UID2
27Unknown provider statusnormalizeCheckStatus throws -> fails vitest run1
28Contract: every documented Prelude status mapsit.each over fixtures; build-failing1
29Malformed/oversized body (missing phone/code, >1mb)400, no provider/counter callboth
30Orphaned UID (verify-otp succeeds, createUser never called)documented behavior (burned monthly slot + bare Auth UID); decide GC posture2
31Proof minted under old secret, verified after rotationclean PROOF_EXPIRED/PHONE_MISMATCH, not a 5002
32Clock skew between verify-otp and createUser instancesexp tolerance defined; test boundary2
33Server-side flag flips mid-flight (rolling deploy)a proof minted on prelude is still honored by a createUser instance reading firebase (branch on proof presence)2

The contract test (#27/#28) is the build-failing mechanism (PR #603 model), since there is no coverage floor.


6. Coexistence / rollout / verification โ€‹

  • Firebase is the live default everywhere until an env is explicitly flipped; on a firebase env nothing changes for users.
  • Per-env flip: dev first (OTP_PROVIDER=prelude + VITE_OTP_PROVIDER=prelude), prod stays firebase. Prod promotion is operator-initiated only (per the no-prod-rollout-proposals rule, this plan does not propose it). Layer-2 billing ceiling (1.3) is a hard prerequisite before any flip.
  • Rollback: flip OTP_PROVIDER back to firebase - instantaneous, no data migration; the proof-presence branch keeps in-flight external signups working through the transition.
  • CI is fully mocked and free (adapter/route tests mock the SDK/adapter; CI does not hit Prelude's network). The live Prelude test-number smoke is a local/manual step, not part of npm run validate. (Corrects the earlier "CI builds against test numbers" overstatement; red-team completeness major.)
  • npm run validate -w services/api/auth (= vitest run) must pass; run npm run validate once before the PR; draft PR, base dev.

7. Red-team findings ledger โ€‹

All three lenses returned planIsSound:false on the first draft. Resolutions above:

Security (blocker x2, major x4, minor x3):

  • B1 OTP_PROOF_SECRET is a bypass master key -> server-state marker corroboration (2.1 #3, 2.3) + distinct per-env secret + documented as a signing key (2.2).
  • B2 one phone -> many accounts -> phoneHash uniqueness in createUser + existing-UID reuse + single-use marker (2.1, 2.3, tests 24/25/26).
  • M App Check replayable -> consume semantics on send-otp/verify-otp (1.2, 2.1).
  • M in-memory per-IP / verify limits per-instance -> durable cross-instance + mandatory Layer-2 ceiling (1.2, 1.3, 2.1).
  • M counter keeps increment on provider 429 -> release on 429 (1.4, test 11).
  • M routing precedence double-applies middleware -> explicit prefix mounts before both /auth/phone lines + assertion test (1.2).
  • m verify brute-force in-memory -> durable (2.1). m flag-off honors stray proof -> branch is proof-presence + flag, stray proof inert when off (2.3, test 18). m local secret shared -> per-env secret (2.2).

Completeness (blocker x3, major x4, minor x2):

  • B existing-user account reuse dropped -> UID reuse by phoneHash (2.1, tests 25/26).
  • B single-newUid threading underspecified -> one UID into both mint+proof (2.1, test 3).
  • B phone-less Auth record breaks other readers -> audit + invariant (2.4).
  • M counter rollback not idempotent (timeout-after-dispatch) -> no release on timeout (1.4, test 9b).
  • M concurrency overclaim -> drive full stack in test + document per-phone burst bound (test 17).
  • M missing cases -> added (#29-33). M CI contradiction + unverified SDK -> corrected (6) + verify-before-coding (1.1).
  • M openapi.json missing -> added (4). m mount ordering -> explicit prefixes (1.2). m phoneCreateUser.test.js missing -> added (4).

Scope discipline (major x3, minor x3):

  • M Stage B entanglement -> surfaced as the section-0 decision; the bootstrap is explicitly owned (Slice 2), not hidden.
  • M flag-off invariance -> guarded prepend, byte-for-byte legacy path (2.3, test 18).
  • M independent shippability -> two-slice split (section 0).
  • m doc-path correction + transactional-upgrade framing (1.4, 4). m phone-absence invariant (2.4). m admin arm under server flip (2.3, test 18b).

8. Open questions (resolve at review) โ€‹

  1. Slice packaging (section 0): two slices (recommended) or one combined slice that owns the bootstrap now.
  2. OTP_MONTHLY_CAP initial bracket (500 / 1000 / 2000).
  3. Verify @prelude.so/sdk exact name/version + create/check shapes before coding.
  4. Prelude verification-window duration and per-number internal limits (unconfirmed in docs; verify empirically with a test number) - affects resend/expiry UX.
  5. Max registrable test numbers + free real-delivery credit amount (sales).
  6. Orphaned-UID GC posture (test 30): leave, or reap bare phone-less custom-token UIDs.
  7. Counter month boundary: UTC (chosen) vs billing-period-aligned.

9. SEALED_IDENTITY section 7 non-negotiable-constraint check โ€‹

  • Right #6 (no data sales): Prelude states never-sells; Telesign avoided. OK.
  • 3.2 k-anonymity: the OTP counter is internal ops telemetry, never merchant-surfaced. OK.
  • No per-user behavioral profiles / no fingerprinting: counter is an aggregate integer; Prelude signals are optional and not wired in this slice; limiter keys on IP + normalized phone only. OK.
  • No new readable phone->UID linkage / no weakening privacy for capability: the provider sees only membership metadata (phone X requested a code at time T), never phone->UID; the bootstrap UID is random, not phone-derived; the proof is short-lived + single-use; the Auth record carries no phoneNumber (2.4 invariant). OK.
  • Phase-1 capital posture: founder-time + minimal spend; two-layer cap + free test numbers + success-only billing. OK.
  • Stage B: the slice does NOT scrub existing Auth records, does NOT PIN-seal phone->UID, does NOT build encryptedUserIdBlob or the sealed lookup, and does NOT backfill. The app-controlled-UID bootstrap (Slice 2) is the one Stage-B-adjacent mechanic, surfaced explicitly in section 0 rather than hidden. OK with that caveat acknowledged.

Built with VitePress