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
phoneCreateUseris the Firebase phone credential stamped onto the Auth UserRecord bysignInWithPhoneNumber. - An external OTP provider verifies the phone off-platform, so under the external path there is no Firebase phone session.
createUsersits behindverifyFirebaseToken(index.js:119) and expectsgetUser(uid).phoneNumberto 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:
| Slice | What it is | Stage B entanglement | Independently shippable? |
|---|---|---|---|
| Slice 1 | The OTP send money choke point + provider adapter + monthly cap counter + the feature flag | None. Pure Stage-A-safe, 11.6-aligned infrastructure. Flag stays firebase; nothing flips. | Yes. Self-contained draft PR. |
| Slice 2 | verify-otp + signed verification proof + app-controlled-UID custom-token mint + createUser proof path + client verify seam | Yes. 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
supertestdevDep. The repo tests handlers by invoking them with mockreq/resand mocking the dependency modules (seephone.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
verifyAppCheckgate; 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. ANOTEcomment inphoneOtp.jstracks 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.
- Create/confirm a Prelude account; generate a test/sandbox API token; register test phone numbers with fixed codes in the Prelude dashboard.
- 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.) - Edit
.github/workflows/deploy-dev.ymlauth-api step: append,OTP_PROVIDER=prelude,OTP_MONTHLY_CAP=2000to--set-env-vars(line ~1029) and,PRELUDE_API_KEY=PRELUDE_API_KEY:latestto--update-secrets(line ~1030). - Local dev: add
OTP_PROVIDER=prelude,OTP_MONTHLY_CAP=2000,PRELUDE_API_KEY=<test token>to the gitignored repo-root.env.local. - Smoke against a registered test number:
send-otpreturns 200 (no SMS, not billed); over-cap returns 429OTP_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_PROVIDERwith aswitch(prelude|twilio);firebaseshort-circuits before the adapter is ever called. - Prelude impl (
@prelude.so/sdk):verification.create({ target: { type:'phone_number', value: phone }, signals })andverification.check({ target, code }). Status mapping:- create:
success/retry/challenged->{ ok:true, requestId };blocked/shadow_blocked->{ ok:false, errorCode:'PROVIDER_BLOCKED' }(note: HTTP 200, branch onstatusnot 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. ExplicitAbortControllertimeout (~8s) - a money-path call must not hang.
- create:
- Twilio fallback stub (
sendTwilio/checkTwilio,case 'twilio'): real-shaped, readsprocess.env.TWILIO_*, throwsNOT_IMPLEMENTEDuntil 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/sdkpackage name, current version, and thatverification.create/verification.checkrequest+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:
| # | Layer | Mechanism |
|---|---|---|
| 1 | App 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.) |
| 2 | Per-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.) |
| 3 | Per-phone (durable) | checkKeyDurable('send-otp:'+e164, 5, 60*60*1000) |
| 4 | Monthly cap (durable, transactional) | reserveOtpSend() (1.4) |
| 5 | Provider | sendOtp({ 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 fromnew Date().toISOString().slice(0,7)), mirroring the existing_rateLimitsflat-collection idiom. (The literalmetrics/otpSends/{YYYY-MM}in 11.6 is an invalid odd-segment.doc()path; the SEALED_IDENTITY edit corrects this.) reserveOtpSend()viadb.runTransaction: read count, reject with{allowed:false}if>= OTP_MONTHLY_CAP, elsetx.set(ref, { count: FieldValue.increment(1), updatedAt: serverTimestamp() }, { merge:true }). This is a stronger pattern than the bareFieldValue.incrementsketch in 11.6 (the transaction is required for race-free cap enforcement); the SEALED_IDENTITY edit notes the upgrade.releaseOtpSend()(compensatingFieldValue.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 onAbortControllertimeout - 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_CAPis 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}, defaultfirebase. HelperisExternalOtp()returnsOTP_PROVIDER !== 'firebase'. Whenfirebase,send-otp/verify-otpmount but return503 OTP_PROVIDER_DISABLED(fail loud, never silently spend). - Client:
VITE_OTP_PROVIDER(reuses the existingVITE_*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:
- Generate
newUidonce (crypto.randomUUID(), app-controlled, NOT phone-derived). - Existing-account reuse (blocker fix): look up an existing UID by
phoneHash(the Stage A index,phone.jslookup). 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 (lostencryptedSeed/authProofHash). (Red-team completeness blocker #1.) - 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.) - Mint
customToken = createCustomToken(uid)andproof = mintProof({ phone, uid, markerId }), using the sameuidin both (soproof.uid === decoded(customToken).uid; if they diverge every signup failsPHONE_MISMATCH). (Red-team completeness blocker #2.) - 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 (mirrorcustomToken.service.js:46-55). OTP_PROOF_SECRETis 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.localcannot 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):
proofpresent -> proof path: verify HMAC +purpose+exp; requirenormalizePhoneNumber(proof.phone) === norm; requirereq.user.uid === proof.uid; consume theotpVerificationsmarker transactionally (markerId); burnjtitransactionally; and enforce the existing-account guard.proofabsent -> legacygetUser().phoneNumberpath (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-otpwith the limited-use App Check header;200->setStep(2), set anotpSentflag (noconfirmationResult). 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); stashproofinotpProofRef. Error mapping (OTP_INVALID/OTP_EXPIRED). createAccount(:483-497): addproof: otpProofRef.currentto thecreatePhoneUserpayload; thread it throughsignupApi.js:65.
Flag off/unset -> none of the above runs; the Firebase signInWithPhoneNumber / confirm path is byte-for-byte unchanged.
3. Config & secrets โ
| Var | Where | Notes |
|---|---|---|
OTP_PROVIDER | Cloud Run env | firebase(default) / prelude / twilio, per env |
OTP_MONTHLY_CAP | Cloud Run env | integer bracket, tunable without code redeploy |
PRELUDE_API_KEY | Secret Manager | new secret in both dev and prod projects |
OTP_PROOF_SECRET | Secret Manager | bypass signing key; distinct per env; restricted access |
TWILIO_* | Secret Manager (later) | documented now, wired when the Twilio impl lands |
VITE_OTP_PROVIDER | apps/web build env | firebase default |
- Append the two secrets and two env vars to
--update-secrets/--set-env-varsin both.github/workflows/deploy-dev.yml(~:1024-1031) anddeploy-prod.yml(~:362-369). The two workflows drift (prod does not even mountPHONE_HASH_PEPPER); add to both now even though prod staysfirebase, so the proof path does not 503 there later. - Add to repo-root
.env.localfor local dev (Prelude test key + a local-onlyOTP_PROOF_SECRET). - Add
@prelude.so/sdk(dep) andsupertest(devDep) toservices/api/auth/package.json; regenerate the lockfile; confirmnpm ciinstalls them in the auth workspace CI job. - Never inline secret values in shell commands (reference
$VARfrom.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.
| # | Case | Expected | Slice |
|---|---|---|---|
| 1 | Wrong code | verify-otp 401 OTP_INVALID; no proof/token | 2 |
| 2 | Expired/unknown code | 401 OTP_EXPIRED | 2 |
| 3 | Correct code | 200 {proof, customToken}; proof.uid === decoded(customToken).uid | 2 |
| 4 | Resend | second send 200; counter +1 again | 1 |
| 5 | Per-phone send limit (durable) | 6th/hr -> 429, provider NOT called | 1 |
| 6 | Per-IP send limit (durable, cross-instance) | 6th/10min -> 429 | 1 |
| 7 | Monthly cap hit | 429 OTP_CAPACITY, provider NOT called, counter not past cap | 1 |
| 8 | App Check missing/invalid (+ consume: replayed token rejected) | 401/403, nothing else runs | 1 |
| 9 | Provider 5xx | PROVIDER_UNAVAILABLE -> 502; counter rolled back | 1 |
| 9b | Provider timeout after possible dispatch | 502; counter NOT rolled back (budget conserved) | 1 |
| 10 | Provider fraud-block (200 blocked) | PROVIDER_BLOCKED -> 403; counter rolled back | 1 |
| 11 | Provider 429 | PROVIDER_RATE_LIMIT -> 429; counter rolled back (no SMS billed) | 1 |
| 12 | Replay of proof (jti reuse) | 409 PROOF_REPLAYED | 2 |
| 12b | Marker reuse (server-state corroboration) | second consume of same markerId -> 409 | 2 |
| 13 | Expired proof | 401 PROOF_EXPIRED | 2 |
| 14 | Tampered proof / bad sig | 403 PHONE_MISMATCH (constant-time) | 2 |
| 15 | proof.phone != body phone | 403 PHONE_MISMATCH | 2 |
| 16 | proof.uid != req.user.uid | 403 PHONE_MISMATCH | 2 |
| 17 | Concurrent sends, same phone | transaction serializes monthly total; drive the full middleware stack, not just the helper; assert no over-count + per-phone burst bound documented | 1 |
| 18 | Flag OFF (firebase) | endpoints 503; createUser uses getUser().phoneNumber; a stray proof in the body is ignored, not honored; existing Firebase tests green | both |
| 18b | createUser flag-on, no proof (admin Firebase-link) | falls back to legacy phone path, NOT 403 | 2 |
| 19 | Flag ON + test number (CI) | fixed code -> success; deterministic; not billed | 1/2 |
| 20 | apiKey unset | NOT_CONFIGURED; provider never called; route 503 | 1 |
| 21 | Invalid phone (send & verify) | 400 INVALID_PHONE before any provider/counter call | both |
| 22 | Verify brute-force (durable, cross-instance) | 11th/10min -> 429 | 2 |
| 23 | Banned phone | 403 BANNED even via proof path | 2 |
| 24 | Idempotent createUser (same UID) + phoneHash uniqueness (second signup, same phone) | same-UID -> 409 treated success; different-UID same-phoneHash -> rejected (no fork) | 2 |
| 25 | Returning user re-runs signup | converges to existing UID by phoneHash lookup; no orphaned account | 2 |
| 26 | Crash-and-retry mid-signup | second verify-otp does not orphan the first UID | 2 |
| 27 | Unknown provider status | normalizeCheckStatus throws -> fails vitest run | 1 |
| 28 | Contract: every documented Prelude status maps | it.each over fixtures; build-failing | 1 |
| 29 | Malformed/oversized body (missing phone/code, >1mb) | 400, no provider/counter call | both |
| 30 | Orphaned UID (verify-otp succeeds, createUser never called) | documented behavior (burned monthly slot + bare Auth UID); decide GC posture | 2 |
| 31 | Proof minted under old secret, verified after rotation | clean PROOF_EXPIRED/PHONE_MISMATCH, not a 500 | 2 |
| 32 | Clock skew between verify-otp and createUser instances | exp tolerance defined; test boundary | 2 |
| 33 | Server-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
firebaseenv nothing changes for users. - Per-env flip: dev first (
OTP_PROVIDER=prelude+VITE_OTP_PROVIDER=prelude), prod staysfirebase. 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_PROVIDERback tofirebase- 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; runnpm run validateonce before the PR; draft PR, basedev.
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_SECRETis 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/phonelines + 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) โ
- Slice packaging (section 0): two slices (recommended) or one combined slice that owns the bootstrap now.
OTP_MONTHLY_CAPinitial bracket (500 / 1000 / 2000).- Verify
@prelude.so/sdkexact name/version + create/check shapes before coding. - Prelude verification-window duration and per-number internal limits (unconfirmed in docs; verify empirically with a test number) - affects resend/expiry UX.
- Max registrable test numbers + free real-delivery credit amount (sales).
- Orphaned-UID GC posture (test 30): leave, or reap bare phone-less custom-token UIDs.
- 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
signalsare 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
encryptedUserIdBlobor 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.