Sealed-account ban reconciliation (phone-context token) - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | claude/sealed-identity-stage-c-safety (no PR yet) |
| Environment | local dev: web :5173 + auth-api :8084 (both hit LIVE lantern-app-dev Firebase) |
| Spec | SEALED_BAN_RECONCILIATION_SPEC.md |
| Build (commit) | started at bd766093 + in-run fixes (see Notes / issues) |
| Tester | Mechelle |
| Date | 2026-07-08 |
Guardrail: Prelude test numbers only (e.g. the number already used for smoke). Do not use a real number.
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 0 | Setup: PHONE_CONTEXT_SECRET set locally + auth-api restarted | [x] pass [ ] fail | secret -> WORKTREE .env.local; auth-api restarted (strict mode active) |
| 1 | Regression: normal phone+PIN login still works (paired token) | [x] pass [ ] fail [ ] blocked | /token carries a real token; exposed + fixed a fail-open null-token 400 (see Notes) |
| 2 | Regression: recovery-phrase login (ForgotPassphrase) still works | [x] pass [ ] fail [ ] blocked | recovery-phrase /token also carries the token |
| 3 | Sealed userId ban -> login ARMS a hash-only banned_accounts row + 403 | [x] pass [ ] fail [ ] blocked | armed row evidence=source:login-harvest; banRecordIds=[] (seal holds) |
| 4 | After arming: re-login blocked at /lookup; re-registration blocked at signup | [x] pass [ ] fail [ ] blocked | 4a: re-login 403 at /lookup (no /token). 4b: signup blocked at client /check-banned pre-check (pre-OTP); verify-otp + createUser back it |
| 5 | Number-only ban-phone on a sealed account -> /token blocks (phone axis) | [x] pass [ ] fail [ ] blocked | scripted: paired token + phone-banned -> /token 403 BANNED (phone axis only); without token -> 400 (S6). Account restored after |
| 6 | #621: scripted /token with NO token -> 400 PHONE_CONTEXT_REQUIRED | [x] pass [ ] fail [ ] blocked | curl /token no-token AND bogus-token both -> 400 (fires before the ban gate); /token has no App Check |
| 7 | Config fail-open: unset PHONE_CONTEXT_SECRET -> login still works | [x] pass [ ] fail [ ] blocked | login OK with null token AFTER the .nullish fix (400'd before) |
| 8 | Reinstate: admin /unban -> login self-clears the login-harvest row | [x] pass (FIXED via Option B) [ ] fail [ ] blocked | gap found (portal /unban left the login-harvest row -> /lookup deadlock), then FIXED: /token self-clears it on the reinstated owner's next login. Verified live. See Notes |
(EC-6 cross-binding and EC-1..EC-17 logic are covered by the automated suite in phone.test.js; this plan is the live end-to-end smoke.)
Run outcome (2026-07-08 to 07-13): Scenarios 0-7 PASS live. Scenario 8 surfaced a portal-reinstate deadlock (userId unban left the seal-preserving login-harvest row, blocking the reinstated user at /lookup) which is now FIXED (spec 6 Option B: /token self-clears the stale row; verified live). 4 fixes shipped during the run: fail-open null-token .nullish, ban-doctor provenance tag, admin CTA width, and the Option B reinstate self-clear. Open follow-ups in Notes: a portal unban-phone/appeal backend for DELIBERATE ban-phone reinstatement (Option A), Moderation ban context, ban-doctor sealed --user hint, invite deep-link cold-load, biometrics-skip UX.
Setup / preconditions โ
- Provision the local signing key (dev-only value; the runtime-generated value never gets typed into a command literal):bash(Or leave it unset to test scenario 7 first.) NOTE: target the worktree
printf 'PHONE_CONTEXT_SECRET=%s\n' "$(openssl rand -hex 32)" >> /home/mechelle/repos/lantern_app/.claude/worktrees/otp-provider-prelude/.env.local.env.localthe running auth-api actually reads (--env-file=../../../.env.local), not the main-repo copy; and ensure the file ends with a newline first, or the appendedPHONE_CONTEXT_SECRET=fuses onto the previous variable (this.env.localhad none). - Restart the auth-api on
:8084so it picks up the new env (WSL2--watchis unreliable; restart manually). - Confirm the token path is active: a
/lookupresponse should now include a non-nullphoneContextToken(Network tab). - Tool:
services/api/auth/scripts/ban-doctor.mjs <number>shows the three ban axes + anybanned_accountsrow (run fromservices/api/auth,node --env-file=../../../.env.local scripts/ban-doctor.mjs <number>).
Scenarios โ
1. Regression: normal login โ
Log in with the test number + correct PIN. Expect success. In the Network tab, the /token request body carries a phoneContextToken, and /lookup returned one. No 400.
Result (2026-07-08): PASS. /token carries a real token (decoded payload in "Test 2" below). This first 400'd because the client forwards /lookup's null token in fail-open and TokenBody was z.string().optional() (rejects null); fixed to .nullish() + 2 regression tests.
2. Regression: recovery-phrase login โ
Use "Forgot passphrase" / recovery-phrase flow on the test number. Expect success. Confirm the /token call there also carries phoneContextToken (ForgotPassphraseModal).
Result (2026-07-08): PASS. Recovery-phrase /token also carries the token.
3. Sealed userId ban -> arming โ
Ban the test account by userId (admin Users page -> Ban, or POST /auth/moderation/ban). Then attempt login. Expect 403 ("cannot be used to sign in"). Run ban-doctor <number>: a new banned_accounts row with evidence: source:login-harvest, phoneHash only, no userId / banRecordIds. This is the arming.
Result (2026-07-08): PASS (EC-1). Banned via admin portal (permanent). Login -> POST /token 403. ban-doctor confirmed all three axes:
- Axis 1:
YVxNXwn... active=true evidence=source:login-harvest expiresAt=null(permanent)(armed at login) - Axis 2:
banned=true banReason=testing banRecordIds=[](empty; no phone-row cross-link) - Axis 3:
disabled=true
banRecordIds=[] is the seal proving itself: the admin ban never reached the phoneHash, so the phone block could only be armed at login, with no userId back-link. NOTE: for a sealed account ban-doctor cannot resolve the userId from the phone (Axis 2/3 show (no userId)); pass --user <id> to see them.
4. After arming: re-registration blocked โ
With the armed row present: (a) attempt login again -> blocked at /lookup (403 before /token); (b) attempt to sign up fresh on the same number (start-over) -> 403 BANNED at signup. Confirms the durable re-registration block.
Result (2026-07-13): PASS.
- 4a (re-login): login on the banned number ->
POST /lookup403 (PhonePinLogin.jsx:129), and NO/tokencall. The block moved upstream from/token(yesterday's arming) to/lookupnow that the durable row exists. - 4b (re-registration): minted a test invite (
npm run otp:test:invite), opened#/signup?invite=..., entered the banned number + birthdate -> "This phone number cannot be used to create a Lantern account" at the Send-Verification step. Blocked by the client/check-bannedpre-check (PhonePinSignup.jsx:237) BEFORE any OTP is sent;/verify-otp(phoneOtp.js:211) and/createUser(phoneCreateUser.js:152) are server backstops on the same row. The armed row survives start-over, so re-registration is blocked three layers deep.
5. Number-only ban-phone -> /token enforcement (sealed) โ
On a not-userId-banned sealed account, ban-phone the number (POST /auth/moderation/ban-phone). Attempt login. Expect 403. (Proves the phone axis is enforced at /token for sealed accounts via the paired token, the #621 number-only closure. /lookup will also hard-block once the row is active; to observe the /token path specifically, watch the first request after arming.)
Result (2026-07-13): PASS (scripted). A browser can't isolate this - once the phone row is active, /lookup hard-blocks before /token, so a browser login looks identical to 4a. Tested via a direct /token POST instead: ban-phone the number (otp:test:ban, account NOT userId-banned), mint a valid paired phone-context token for the phoneHash, then POST /token with the real userId + proof + token -> 403 BANNED. Only the phone axis trips (Axis 2/3 clear), and only because the paired token supplied the phoneHash a sealed user doc lacks. Without the token -> 400 PHONE_CONTEXT_REQUIRED (S6), so the phone-axis enforcement at /token specifically requires the paired token. #621 number-only closure confirmed. Account restored via otp:test:unban after (all axes clear).
6. #621 scripted bypass โ
Replay a /token POST directly (e.g. via DevTools "copy as fetch") without phoneContextToken, using a known userId + proof. Expect 400 PHONE_CONTEXT_REQUIRED (not a mint). Confirms strict.
Result (2026-07-13): PASS. Direct curl to POST :8084/auth/phone/token (no App Check on this route, phone.js:383) with the known userId + proof and NO phoneContextToken -> 400 PHONE_CONTEXT_REQUIRED. A bogus/forged token -> the same 400 (uniform refusal, no oracle). Both fire BEFORE the ban gate (the account is banned, yet we get PHONE_CONTEXT_REQUIRED not the ban 403), so a scripted client that skips /lookup cannot mint. #621 residual #2 closed.
7. Config fail-open โ
Remove PHONE_CONTEXT_SECRET from .env.local, restart auth-api. Log in normally. Expect success (no 400): a deploy without the secret must not lock out login. (Arming + sealed phone-axis are simply inert in this mode.)
Test 1
- Login was successful
{
"userId": "1af5b6a2-38e9-4582-8b43-4f3cf0d7d2bc",
"proofHmac": "5fb12afb4b58710b51f224ce54fb83d9d66f1e513d7b55cc39cf3d144a2ecad7",
"phoneContextToken": null
}- Side quest but very minor: When logging in and asking if I want to enable biometrics, when I skipped it showed the login screen vs a "loading" screen instead.
Test 2
- No longer see the null phone context.
{
"userId": "1af5b6a2-38e9-4582-8b43-4f3cf0d7d2bc",
"proofHmac": "5fb12afb4b58710b51f224ce54fb83d9d66f1e513d7b55cc39cf3d144a2ecad7",
"phoneContextToken": "eyJwaG9uZUhhc2giOiJ2MTplNzg4ZDIyODY0Y2Y2MTIwYWZhNTIwMzEzZDI4NzRhYzQ3YTI4ZjEyMzllNzFkZWI0ZTdhOTJiY2JiOTI5YWQ1IiwicHVycG9zZSI6InBob25lX2NvbnRleHQiLCJpYXQiOjE3ODM1MjYxNjEsImV4cCI6MTc4MzUyNjI4MSwibm9uY2UiOiI0ZDJjZjM2My0wYjkxLTQzNWYtYjMxYi1kYzE3ZmQ0MDg4MTYifQ.211878440f05ee7e56afda007b2aeebc745faa6b4751bbaf53adab9958ddf9cd"
}- Side quest: Still getting the same behavior.
8. Reinstate (Option A) โ
After scenario 3/4, reinstate: POST /auth/moderation/unban (userId) and POST /auth/moderation/unban-phone (number). Then ban-doctor <number> shows all axes clear + the row overturned. Log in -> success.
Result (2026-07-13): PORTAL GAP (important). Unbanned via the admin portal (Users -> Unban). ban-doctor after: Axis 2 banned=false + Axis 3 disabled=false (userId axis cleared), but Axis 1 login-harvest row still active=true. liftUserBan overturns only rows in users.banRecordIds (banEnforcement.service.js:46-49), and the login-harvest row has none (seal-preserving), so /unban cannot reach it -> the reinstated user is still 403 at /lookup (the deadlock, spec 6). Neither fix is built: Option B (login-harvest self-clears at the next proof-paired login once the userId ban is lifted) is not implemented, and a portal unban-phone/appeal path needs the (unbuilt) case backend. CLI escape hatch: ban-doctor --fix overturned the row -> all axes clear -> login works. Follow-up: make reinstate portal-complete (Option B self-clear, or a number-blind portal unban-phone via the appeal flow).
FIX (2026-07-13, commit f0037d13): Option B implemented + verified live. /token now self-clears a stale login-harvest row when the userId ban is already lifted (data.banned !== true) AND a proven pair is held (pairedHash); /lookup soft-passes a login-harvest-only block so /token can adjudicate. So admin /unban alone now reinstates a sealed account. Verified end-to-end against the live deadlock state (a lingering login-harvest row + users.banned=false): /lookup -> 200 soft-pass (was 403), /token -> 200 + custom token (was 403), ban-doctor shows the row overturned. A DELIBERATE ban-phone row is deliberately NOT self-cleared (stays Option A: moderator + number from the appeal flow - by design, since it is an intentional block, not a stale artifact). Unit-covered: self-clear mints, still-banned re-arms, deliberate never clears, /lookup soft-pass (auth suite 266 pass).
Notes / issues โ
Bug found + fixed mid-run (fail-open null token). In config fail-open /lookup returns phoneContextToken: null; the client forwards it verbatim, and /token's TokenBody used z.string().optional(), which rejects null -> a schema 400 before any fail-open logic ran (scenario 7 would have failed for the real client). Fixed to z.string().nullish() in phone.js + 2 regression tests (null accepted in fail-open; null still -> PHONE_CONTEXT_REQUIRED in strict). Suite 47/47.
Env-path gotcha (corrected in Setup above). The running auth-api reads the WORKTREE .env.local (--env-file=../../../.env.local), not the main-repo copy the original setup step pointed at. Also: the file had no trailing newline, so a naive >> append fused PHONE_CONTEXT_SECRET= onto the previous variable (OTP_TEST_PHONE); append with a leading newline.
Tooling. ban-doctor now prints the evidence= provenance tag on each Axis-1 row (arming is now visible). Open follow-up: for a sealed account it cannot resolve the userId from the phone, so Axis 2/3 need --user <id>; it should detect the sealed auth_lookup row and hint that.
UX side-quest (unrelated to ban work). Skipping the enable-biometrics prompt after login drops to the login screen instead of a loading/redirect state. Reproduces consistently. Minor; tracked separately.
Invite deep-link cold-load (minor). #/signup?invite=<token> intermittently rendered "Invite Required" on a cold load (the invite token read as empty at mount; /invite/validate never fired); a manual browser refresh fixed it. Matches the cold-load race the code flags (PhonePinSignup.jsx:141-145). Minor; separate from the ban work.
Test-helper tooling. npm run otp:test:invite mints a fresh #/signup?invite= link; otp:test:ban / otp:test:unban / otp:test:reset / otp:test:status (tooling/scripts/otp-local-test.mjs) manage the test account + ban state without the admin portal.
FIXED (2026-07-13, Option B): login-harvest reinstate self-clear. The portal-reinstate deadlock is closed: /token self-clears a stale login-harvest row once the userId ban is lifted (proven pair + data.banned !== true), and /lookup soft-passes a login-harvest-only block. So admin /unban alone now reinstates a sealed account (verified live: deadlock state -> /lookup 200, /token 200+token, row overturned). REMAINING (Option A, separate follow-up): a DELIBERATE ban-phone row still needs a moderator to lift it with the number, which needs the in-portal appeal/case backend (not built) - by design, since a deliberate number ban is not a stale artifact.
FOLLOW-UP: Moderation has no ban context. The Moderation section surfaces no ban considerations, and a Users-page ban creates no moderation case (Cases list still mock; no userReports/case backend). Same cluster as the reinstate gap above; build alongside the appeal flow.
CTA width (fixed 2026-07-13). Ban/Unban buttons in the user detail panel stretched full-width because .ban-info/.ban-actions are flex columns (default align-items: stretch). Added scoped align-self: flex-start on .ban-info > .btn / .ban-actions > .btn so they hug their label; the ban-reason box stays full-width (apps/admin/src/shared/styles/styles.css).