Skip to content

Sealed-account ban reconciliation (phone-context token) - Manual Test Plan โ€‹

FieldValue
Branch / PRclaude/sealed-identity-stage-c-safety (no PR yet)
Environmentlocal dev: web :5173 + auth-api :8084 (both hit LIVE lantern-app-dev Firebase)
SpecSEALED_BAN_RECONCILIATION_SPEC.md
Build (commit)started at bd766093 + in-run fixes (see Notes / issues)
TesterMechelle
Date2026-07-08

Guardrail: Prelude test numbers only (e.g. the number already used for smoke). Do not use a real number.

Summary โ€‹

#ScenarioResultNotes
0Setup: PHONE_CONTEXT_SECRET set locally + auth-api restarted[x] pass [ ] failsecret -> WORKTREE .env.local; auth-api restarted (strict mode active)
1Regression: 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)
2Regression: recovery-phrase login (ForgotPassphrase) still works[x] pass [ ] fail [ ] blockedrecovery-phrase /token also carries the token
3Sealed userId ban -> login ARMS a hash-only banned_accounts row + 403[x] pass [ ] fail [ ] blockedarmed row evidence=source:login-harvest; banRecordIds=[] (seal holds)
4After arming: re-login blocked at /lookup; re-registration blocked at signup[x] pass [ ] fail [ ] blocked4a: re-login 403 at /lookup (no /token). 4b: signup blocked at client /check-banned pre-check (pre-OTP); verify-otp + createUser back it
5Number-only ban-phone on a sealed account -> /token blocks (phone axis)[x] pass [ ] fail [ ] blockedscripted: 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 [ ] blockedcurl /token no-token AND bogus-token both -> 400 (fires before the ban gate); /token has no App Check
7Config fail-open: unset PHONE_CONTEXT_SECRET -> login still works[x] pass [ ] fail [ ] blockedlogin OK with null token AFTER the .nullish fix (400'd before)
8Reinstate: admin /unban -> login self-clears the login-harvest row[x] pass (FIXED via Option B) [ ] fail [ ] blockedgap 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 โ€‹

  1. Provision the local signing key (dev-only value; the runtime-generated value never gets typed into a command literal):
    bash
    printf 'PHONE_CONTEXT_SECRET=%s\n' "$(openssl rand -hex 32)" >> /home/mechelle/repos/lantern_app/.claude/worktrees/otp-provider-prelude/.env.local
    (Or leave it unset to test scenario 7 first.) NOTE: target the worktree .env.local the 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 appended PHONE_CONTEXT_SECRET= fuses onto the previous variable (this .env.local had none).
  2. Restart the auth-api on :8084 so it picks up the new env (WSL2 --watch is unreliable; restart manually).
  3. Confirm the token path is active: a /lookup response should now include a non-null phoneContextToken (Network tab).
  4. Tool: services/api/auth/scripts/ban-doctor.mjs <number> shows the three ban axes + any banned_accounts row (run from services/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 /lookup 403 (PhonePinLogin.jsx:129), and NO /token call. The block moved upstream from /token (yesterday's arming) to /lookup now 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-banned pre-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).

Built with VitePress