diff --git a/package.json b/package.json index f9505fe..568c75f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openparachute/cloud", - "version": "0.0.8-rc.129", + "version": "0.0.8-rc.130", "private": true, "description": "Open Parachute PBC's Vault Cloud \u2014 one Durable Object per vault on Cloudflare, OAuth issuer + self-serve console (accounts + vault ownership).", "license": "AGPL-3.0", diff --git a/workers/identity/migrations/0023_account_delete.sql b/workers/identity/migrations/0023_account_delete.sql new file mode 100644 index 0000000..6343f1b --- /dev/null +++ b/workers/identity/migrations/0023_account_delete.sql @@ -0,0 +1,42 @@ +-- Account deletion (A-1): the substrate for the 24-hour undo window Aaron +-- ratified. `DELETE /account` (A-3, a later PR) severs authentication and +-- mails an undo token, but defers all actual destruction to an hourly sweep +-- (A-4) that runs past the window — this migration only adds the columns +-- that state lives in. NOTHING WRITES THEM YET: there is no route, no setter, +-- and no sweep in this PR. It is proven inert by the full existing suite +-- staying green with these columns present but always NULL. +-- +-- NOTE ON MIGRATION NUMBERING: the vault-delete train's PR-2a also wanted +-- 0023; this train claimed it first, so PR-2a re-bases onto 0024 (see +-- cloud#226). +-- +-- `users.deleted_at` — ISO-8601 timestamp when the account entered its +-- delete-undo window (NULL = not deleted; this PR never sets it). Mirrors +-- migration 0011's `suspended_at` no-oracle posture rather than inventing a +-- second style — the tombstoned row stays in `users` until the sweep +-- converges, so every read path that can act on an account must also refuse +-- one whose `deleted_at` is set, the same way they already refuse a +-- suspended one: +-- - sessions: the session JOIN refuses a deleted user, same as suspended +-- (findActiveSession, sessions.ts); +-- - login/magic: blocked with the SAME neutral responses an unknown +-- account or a suspended one gets (wrong-password message / "check your +-- email" page / "that code didn't work") — deletion is never revealed, +-- no oracle, and — unlike suspension — never distinguishably so: the +-- account-bearer gate (requireAccount, account-api.ts) answers a deleted +-- owner with the exact SAME body as a missing row (401 `invalid_token`), +-- not the distinguishable `account_suspended` a live-but-suspended owner +-- gets, because "deleted" is a stronger, one-way fact than "suspended"; +-- - the onboarding drip + the billing/usage/snapshot sweeps: every +-- enumeration excludes a tombstoned owner, so a deleted account is never +-- emailed and never wakes a vault DO; +-- - vault DATA: untouched by this PR — its fate is A-2/A-4's job. +-- +-- `users.delete_undo_hash` — opaque hash of the mailed undo token (NULL until +-- the future delete route sets it, A-3). Unused by anything in this PR. +-- +-- `users.delete_notice_sent_at` — ISO-8601 timestamp the deletion-notice +-- email was sent (NULL until A-3). Unused by anything in this PR. +ALTER TABLE users ADD COLUMN deleted_at TEXT; +ALTER TABLE users ADD COLUMN delete_undo_hash TEXT; +ALTER TABLE users ADD COLUMN delete_notice_sent_at TEXT; diff --git a/workers/identity/src/account-api.ts b/workers/identity/src/account-api.ts index 7f1d645..3e485f4 100644 --- a/workers/identity/src/account-api.ts +++ b/workers/identity/src/account-api.ts @@ -125,17 +125,20 @@ export async function readJsonBody(req: Request): Promise { // Gate 1 — session. `sessionUser` refuses a missing cookie AND a suspended - // owner (findActiveSession's suspended_at JOIN) — the mint chokepoint. + // or deleted owner (findActiveSession's suspended_at/deleted_at JOIN) — + // the mint chokepoint. const user = await sessionUser(db, req, deps); if (!user) { return jsonError(401, "unauthenticated", "no active session — sign in first"); diff --git a/workers/identity/src/auth-handlers.ts b/workers/identity/src/auth-handlers.ts index 678df7d..3820340 100644 --- a/workers/identity/src/auth-handlers.ts +++ b/workers/identity/src/auth-handlers.ts @@ -100,10 +100,11 @@ function checkForm(req: Request, form: FormData, deps: OAuthDeps): boolean { * DON'T mint a session — stash a pending login and send them to the code prompt. * Otherwise mint the session and go to `next`. * - * SUSPENDED accounts never mint here — this is the chokepoint every primary-auth - * path funnels through, so the guard is defense-in-depth behind the per-surface - * neutral responses (login's wrong-password message, magic's neutral pages). The - * bare /login redirect is deliberately indistinct from a lapsed session. + * SUSPENDED or DELETED accounts never mint here — this is the chokepoint every + * primary-auth path funnels through, so the guard is defense-in-depth behind + * the per-surface neutral responses (login's wrong-password message, magic's + * neutral pages). The bare /login redirect is deliberately indistinct from a + * lapsed session. */ export async function finishPrimaryAuth( db: D1Database, @@ -113,7 +114,7 @@ export async function finishPrimaryAuth( ): Promise { const now = deps.now?.() ?? new Date(); const user = await getUserById(db, userId); - if (!user || user.suspendedAt) return redirectResponse("/login"); + if (!user || user.suspendedAt || user.deletedAt) return redirectResponse("/login"); if (await isTotpEnrolled(db, userId)) { const token = await createPendingLogin(db, userId, next, now); return redirectResponse("/login/2fa", { "set-cookie": buildPendingLoginCookie(token) }); @@ -221,10 +222,10 @@ export async function handleMagicRequestPost( // reveals account existence nor lets the endpoint be used to bomb an inbox. if (throttle.allowed) { const existing = await getUserByEmail(db, email); - // SUSPENDED account: the exact same neutral "check your email" page as - // every other outcome (no oracle), but nothing is minted or sent — no - // magic_links row, no email, and no dev echo header (there is no link). - if (existing?.suspendedAt) { + // SUSPENDED or DELETED account: the exact same neutral "check your email" + // page as every other outcome (no oracle), but nothing is minted or sent — + // no magic_links row, no email, and no dev echo header (there is no link). + if (existing?.suspendedAt || existing?.deletedAt) { const csrfToken = ensureCsrfToken(req).token; return wantsJson ? jsonResponse({ ok: true }, 200) : htmlResponse(renderMagicSent({ email, csrfToken }), 200); } @@ -284,15 +285,15 @@ function magicError(req: Request, message: string, email: string): Response { * their email; otherwise create-or-fetch (a concurrent link for a new address * could have created the row first). Shared by the link's GET /auth/verify * and the code's POST /auth/code so both spellings resolve identically. - * Returns null for a SUSPENDED account — the never-mint chokepoint; the - * caller decides how that surfaces (the link's dead-link page vs the code's - * neutral failure message), but neither ever mints. + * Returns null for a SUSPENDED or DELETED account — the never-mint + * chokepoint; the caller decides how that surfaces (the link's dead-link page + * vs the code's neutral failure message), but neither ever mints. */ async function resolveVerifiedUser(db: D1Database, consumed: ConsumedMagicLink, now: Date): Promise { let userId = consumed.userId; const existing = userId ? await getUserById(db, userId) : await getUserByEmail(db, consumed.email); if (existing) { - if (existing.suspendedAt) return null; + if (existing.suspendedAt || existing.deletedAt) return null; userId = existing.id; await markEmailVerified(db, userId); return userId; @@ -460,10 +461,11 @@ export async function handleLogin2faPost(db: D1Database, req: Request, deps: OAu } await clearLoginFailures(deps.rateLimiter, key); await consumePendingLogin(db, rawToken); - // A pending login can predate a suspension (the 2FA path mints its own - // session, bypassing finishPrimaryAuth) — same never-mint rule applies. + // A pending login can predate a suspension or deletion (the 2FA path mints + // its own session, bypassing finishPrimaryAuth) — same never-mint rule + // applies. const pendingUser = await getUserById(db, pending.userId); - if (!pendingUser || pendingUser.suspendedAt) { + if (!pendingUser || pendingUser.suspendedAt || pendingUser.deletedAt) { return redirectResponse("/login", { "set-cookie": clearPendingLoginCookie() }); } const session = await createSession(db, pending.userId, now); diff --git a/workers/identity/src/billing-lifecycle.ts b/workers/identity/src/billing-lifecycle.ts index 13a52f9..3f56b6d 100644 --- a/workers/identity/src/billing-lifecycle.ts +++ b/workers/identity/src/billing-lifecycle.ts @@ -496,6 +496,10 @@ interface DueRow { * pair, and push the new caps into their vault DOs. Runs on the hourly cron * tick (ops.ts, alongside the drip); per-user failures log + continue (the * drip/usage posture — self-heals next hour). NEVER deletes anything. + * + * Excludes a tombstoned account (`deleted_at`, migration 0023) — a deleted + * account due for a downgrade would otherwise still get its caps pushed, + * waking a vault DO for an owner already mid-deletion. */ export async function runBillingSweep(db: D1Database, deps: OAuthDeps, now: Date): Promise { // Bind the SELECT and every per-row conditional write to the SAME instant, so @@ -506,6 +510,7 @@ export async function runBillingSweep(db: D1Database, deps: OAuthDeps, now: Date .prepare( `SELECT id, plan, pending_plan FROM users WHERE pending_plan IS NOT NULL AND plan_downgrade_at IS NOT NULL AND plan_downgrade_at <= ? + AND deleted_at IS NULL ORDER BY plan_downgrade_at ASC LIMIT ?`, ) .bind(nowIso, BILLING_SWEEP_CAP) diff --git a/workers/identity/src/console.ts b/workers/identity/src/console.ts index ec39006..b80a7bf 100644 --- a/workers/identity/src/console.ts +++ b/workers/identity/src/console.ts @@ -205,10 +205,11 @@ export async function handleLoginPost(db: D1Database, req: Request, deps: OAuthD return loginError(req, "Too many attempts. Please wait a few minutes and try again.", email); } const user = email ? await getUserByEmail(db, email) : null; - // A SUSPENDED account fails with the exact wrong-password message — even on - // the correct password (suspension is never revealed; migration 0011). The - // failure is recorded like any other so the fence stays indistinguishable. - if (!user || !(await verifyPassword(user, password)) || user.suspendedAt) { + // A SUSPENDED or DELETED account fails with the exact wrong-password + // message — even on the correct password (neither is ever revealed; + // migrations 0011 + 0023). The failure is recorded like any other so the + // fence stays indistinguishable. + if (!user || !(await verifyPassword(user, password)) || user.suspendedAt || user.deletedAt) { await recordLoginFailure(deps.rateLimiter, key, now); return loginError(req, "Incorrect email or password.", email); } diff --git a/workers/identity/src/drip.ts b/workers/identity/src/drip.ts index c0249f8..8edd295 100644 --- a/workers/identity/src/drip.ts +++ b/workers/identity/src/drip.ts @@ -98,6 +98,10 @@ const NO_LEDGER_ROW = "NOT EXISTS (SELECT 1 FROM drip_sends d WHERE d.user_id = * rows are minted-token evidence (the auth-code grant always writes one); * grants rows are consent evidence (written at approval even if redemption * failed) — either one means the user already met their AI, so no nudge. + * + * Every kind also excludes a tombstoned account (`deleted_at`, migration + * 0023) — a deleted account inside any window would otherwise be emailed, + * since these queries select straight from `users` by `created_at`. */ export async function eligibleFor( db: D1Database, @@ -112,14 +116,14 @@ export async function eligibleFor( switch (kind) { case "welcome": sql = `SELECT u.id, u.email FROM users u - WHERE u.created_at > ?2 AND u.drip_unsubscribed IS NULL AND ${NO_LEDGER_ROW} + WHERE u.created_at > ?2 AND u.drip_unsubscribed IS NULL AND u.deleted_at IS NULL AND ${NO_LEDGER_ROW} ORDER BY u.created_at LIMIT ?3`; binds = [kind, t(WELCOME_WINDOW_MS), limit]; break; case "connect-nudge": sql = `SELECT u.id, u.email FROM users u WHERE u.created_at <= ?2 AND u.created_at > ?3 - AND u.drip_unsubscribed IS NULL AND ${NO_LEDGER_ROW} + AND u.drip_unsubscribed IS NULL AND u.deleted_at IS NULL AND ${NO_LEDGER_ROW} AND NOT EXISTS (SELECT 1 FROM tokens t WHERE t.user_id = u.id AND t.client_id <> ?4) AND NOT EXISTS (SELECT 1 FROM grants g WHERE g.user_id = u.id AND g.client_id <> ?4) ORDER BY u.created_at LIMIT ?5`; @@ -128,7 +132,7 @@ export async function eligibleFor( case "feedback": sql = `SELECT u.id, u.email FROM users u WHERE u.created_at <= ?2 AND u.created_at > ?3 - AND u.drip_unsubscribed IS NULL AND ${NO_LEDGER_ROW} + AND u.drip_unsubscribed IS NULL AND u.deleted_at IS NULL AND ${NO_LEDGER_ROW} ORDER BY u.created_at LIMIT ?4`; binds = [kind, t(FEEDBACK_MIN_AGE_MS), t(FEEDBACK_MAX_AGE_MS), limit]; break; diff --git a/workers/identity/src/oauth-authorize.ts b/workers/identity/src/oauth-authorize.ts index dcd5018..00080f7 100644 --- a/workers/identity/src/oauth-authorize.ts +++ b/workers/identity/src/oauth-authorize.ts @@ -299,8 +299,9 @@ async function authorizeCore( const csrf = ensureCsrfToken(req); const extra: Record = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {}; // The session is already proven live (findActiveSession JOINs users WHERE - // suspended_at IS NULL), so the user row always resolves here — `?? ""` is - // defensive only, matching the codebase's convention elsewhere. + // suspended_at IS NULL AND deleted_at IS NULL), so the user row always + // resolves here — `?? ""` is defensive only, matching the codebase's + // convention elsewhere. const consentUser = await getUserById(db, session.userId); // Only the unnamed-verb pick branch needs the owner's vault list (the dropdown // that replaced the free-text input); every other branch skips the extra read. @@ -436,9 +437,10 @@ async function handleLoginSubmit(db: D1Database, req: Request, form: FormData, d return renderLoginPage(req, params, "Too many attempts. Please wait a few minutes and try again."); } const user = email ? await getUserByEmail(db, email) : null; - // SUSPENDED accounts get the same wrong-password message here too (this is - // the other public login-submit path) — never a session, never an oracle. - if (!user || !(await verifyPassword(user, password)) || user.suspendedAt) { + // SUSPENDED or DELETED accounts get the same wrong-password message here + // too (this is the other public login-submit path) — never a session, + // never an oracle. + if (!user || !(await verifyPassword(user, password)) || user.suspendedAt || user.deletedAt) { await recordLoginFailure(deps.rateLimiter, key, now); return renderLoginPage(req, params, "Incorrect email or password."); } diff --git a/workers/identity/src/sessions.ts b/workers/identity/src/sessions.ts index 2bb9974..d1b4f85 100644 --- a/workers/identity/src/sessions.ts +++ b/workers/identity/src/sessions.ts @@ -50,18 +50,20 @@ export async function createSession(db: D1Database, userId: string, now: Date = /** * Find a live (un-expired) session by id. A session whose user is SUSPENDED - * (users.suspended_at, migration 0011) is refused here — the single read-time - * chokepoint that invalidates a suspended user's sessions on their next - * request, across every cookie-authed surface (console, /oauth/authorize - * login-skip, consent submit). The suspend action (admin.ts) also deletes the - * rows outright; this join is the backstop for anything it raced. + * (users.suspended_at, migration 0011) or DELETED (users.deleted_at, + * migration 0023) is refused here — the single read-time chokepoint that + * invalidates such a user's sessions on their next request, across every + * cookie-authed surface (console, /oauth/authorize login-skip, consent + * submit). The suspend action (admin.ts) also deletes the rows outright; this + * join is the backstop for anything it raced (and, once the delete route + * exists, will be the backstop there too). */ export async function findActiveSession(db: D1Database, id: string, now: Date = new Date()): Promise { const row = await db .prepare( `SELECT s.id, s.user_id, s.created_at, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.id = ? AND u.suspended_at IS NULL`, + WHERE s.id = ? AND u.suspended_at IS NULL AND u.deleted_at IS NULL`, ) .bind(id) .first(); diff --git a/workers/identity/src/snapshots.ts b/workers/identity/src/snapshots.ts index c105ad8..530e99e 100644 --- a/workers/identity/src/snapshots.ts +++ b/workers/identity/src/snapshots.ts @@ -91,14 +91,16 @@ export async function runSnapshotSweep( const now = deps.now?.() ?? new Date(); const day = now.toISOString().slice(0, 10); + // Excludes a tombstoned owner (`deleted_at`, migration 0023) — otherwise a + // deleted account's vault still gets snapshotted nightly. const res = opts.onlyVault ? await env.DB.prepare( - `SELECT v.name, v.owner_user_id, u.plan FROM vaults v JOIN users u ON u.id = v.owner_user_id WHERE v.name = ? LIMIT 1`, + `SELECT v.name, v.owner_user_id, u.plan FROM vaults v JOIN users u ON u.id = v.owner_user_id WHERE v.name = ? AND u.deleted_at IS NULL LIMIT 1`, ) .bind(opts.onlyVault) .all<{ name: string; owner_user_id: string; plan: string }>() : await env.DB.prepare( - `SELECT v.name, v.owner_user_id, u.plan FROM vaults v JOIN users u ON u.id = v.owner_user_id ORDER BY v.name LIMIT ?`, + `SELECT v.name, v.owner_user_id, u.plan FROM vaults v JOIN users u ON u.id = v.owner_user_id WHERE u.deleted_at IS NULL ORDER BY v.name LIMIT ?`, ) .bind(runCap + 1) .all<{ name: string; owner_user_id: string; plan: string }>(); diff --git a/workers/identity/src/usage.ts b/workers/identity/src/usage.ts index 66f8c01..d0c67a6 100644 --- a/workers/identity/src/usage.ts +++ b/workers/identity/src/usage.ts @@ -72,11 +72,17 @@ export async function runUsageRollup( // +1 over the cap so "stopped at the cap" is distinguishable from "drained". // `onlyVault` narrows to a single row (the run is inherently uncapped then). + // JOIN users + exclude a tombstoned owner (`deleted_at`, migration 0023) — + // otherwise a deleted account's vault still wakes its DO every night. const res = opts.onlyVault - ? await env.DB.prepare("SELECT name, owner_user_id FROM vaults WHERE name = ? LIMIT 1") + ? await env.DB.prepare( + "SELECT v.name, v.owner_user_id FROM vaults v JOIN users u ON u.id = v.owner_user_id WHERE v.name = ? AND u.deleted_at IS NULL LIMIT 1", + ) .bind(opts.onlyVault) .all<{ name: string; owner_user_id: string }>() - : await env.DB.prepare("SELECT name, owner_user_id FROM vaults ORDER BY name LIMIT ?") + : await env.DB.prepare( + "SELECT v.name, v.owner_user_id FROM vaults v JOIN users u ON u.id = v.owner_user_id WHERE u.deleted_at IS NULL ORDER BY v.name LIMIT ?", + ) .bind(runCap + 1) .all<{ name: string; owner_user_id: string }>(); const rows = res.results ?? []; diff --git a/workers/identity/src/users.ts b/workers/identity/src/users.ts index bd9bb98..5f39cc3 100644 --- a/workers/identity/src/users.ts +++ b/workers/identity/src/users.ts @@ -70,6 +70,20 @@ export interface User { * vault data untouched. */ suspendedAt: string | null; + /** + * ISO-8601 timestamp when the account entered its 24-hour delete-undo + * window (migration 0023); null = not deleted. A-1 (this substrate) never + * WRITES this — only reads it, at the read-time refusal chokepoints + * documented on the migration. Set by the future `DELETE /account` route + * (A-3), cleared by the undo endpoint (A-4). + */ + deletedAt: string | null; + /** Opaque hash of the mailed undo token (migration 0023). Unused until the + * delete route (A-3) starts writing it. */ + deleteUndoHash: string | null; + /** ISO-8601 timestamp the deletion-notice email was sent (migration 0023). + * Unused until the delete route (A-3) starts writing it. */ + deleteNoticeSentAt: string | null; /** * Stripe linkage (migration 0012) — set by the checkout.session.completed * webhook; null for free users and comped accounts. The lifecycle handlers @@ -108,6 +122,9 @@ interface Row { plan: string; role: string; suspended_at: string | null; + deleted_at: string | null; + delete_undo_hash: string | null; + delete_notice_sent_at: string | null; stripe_customer_id: string | null; stripe_subscription_id: string | null; pending_plan: string | null; @@ -130,6 +147,9 @@ function rowToUser(r: Row): User { plan: coercePlanId(r.plan), role: coerceRole(r.role), suspendedAt: r.suspended_at, + deletedAt: r.deleted_at, + deleteUndoHash: r.delete_undo_hash, + deleteNoticeSentAt: r.delete_notice_sent_at, stripeCustomerId: r.stripe_customer_id, stripeSubscriptionId: r.stripe_subscription_id, // Same defensive coercion as `plan`; null stays null (no pending change). @@ -259,6 +279,9 @@ export async function createUser( plan: "trial", role: "user", suspendedAt: null, + deletedAt: null, + deleteUndoHash: null, + deleteNoticeSentAt: null, stripeCustomerId: null, stripeSubscriptionId: null, pendingPlan: "expired", diff --git a/workers/identity/test/account-api.test.ts b/workers/identity/test/account-api.test.ts index 96c34bd..2451176 100644 --- a/workers/identity/test/account-api.test.ts +++ b/workers/identity/test/account-api.test.ts @@ -584,6 +584,26 @@ describe("C3 — suspended owner refused across the surface", () => { }); }); +// --- A-1: the read-time DELETE chokepoint (migration 0023) ------------------- + +describe("C3/A-1 — deleted owner refused across the surface, indistinguishably from missing", () => { + test("a deleted owner's live account token → 401 invalid_token, 'account not found' — the EXACT missing-row body, never account_suspended", async () => { + const { userId, token } = await seedOwnerWithPlan("deleted@example.com"); + await seedVault("tombstoned", userId); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), userId) + .run(); + + // The token was minted while active, but the account surface re-checks + // deletion read-time — and unlike suspension above, degrades ALL THE WAY + // to the same body a token for a row that never existed gets (no oracle + // that "deleted" is a distinct state from "never had an account"). + const res = await handleAccountVaultsList(db(), accountReq("GET", "/account/vaults", { token }), accountDeps()); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "invalid_token", error_description: "account not found" }); + }); +}); + // --- validateVaultScopes (pure — now @openparachute/door-contract's shared // canon, re-exported from account-api.ts; hub-parity P3) ------------------- diff --git a/workers/identity/test/account-mcp.test.ts b/workers/identity/test/account-mcp.test.ts index f69087c..8b2d5b4 100644 --- a/workers/identity/test/account-mcp.test.ts +++ b/workers/identity/test/account-mcp.test.ts @@ -339,6 +339,22 @@ describe("account MCP — auth gate", () => { expect(((await res.json()) as any).error).toBe("account_suspended"); expectChallenge(res); }); + + // A-1 (migration 0023): a deleted owner degrades to the SAME "account not + // found" body a missing row gets — not the distinguishable account_suspended + // above (deletion is a stronger, one-way fact). + test("a deleted owner → 401 invalid_token, same body as a missing account", async () => { + const id = await seedOwner("a-deleted@example.com"); + const { token } = await mintVaultsToken(id, { blanket: true }); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run(); + const res = await handleAccountMcp(db(), mcpReq(token, rpc("tools/list")), mcpDeps()); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: "invalid_token", + error_description: "account not found", + }); + expectChallenge(res); + }); }); // --- THE OWNERSHIP SEAM: coverage resolves by ownership, live ---------------- diff --git a/workers/identity/test/account-token.test.ts b/workers/identity/test/account-token.test.ts index cc80ded..e8dbeeb 100644 --- a/workers/identity/test/account-token.test.ts +++ b/workers/identity/test/account-token.test.ts @@ -228,3 +228,18 @@ describe("POST /account/token — suspended owner refused at mint", () => { expect(((await res.json()) as { error: string }).error).toBe("unauthenticated"); }); }); + +// --- A-1: the delete chokepoint (migration 0023) ------------------------------ + +describe("POST /account/token — deleted owner refused at mint", () => { + test("a deleted owner's session refuses at mint (findActiveSession's deleted_at JOIN)", async () => { + const { userId, sessionId } = await seedOwner("deleted-c2@example.com"); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), userId) + .run(); + + const res = await handleAccountToken(db(), goodReq(sessionId), deps()); + expect(res.status).toBe(401); + expect(((await res.json()) as { error: string }).error).toBe("unauthenticated"); + }); +}); diff --git a/workers/identity/test/auth.test.ts b/workers/identity/test/auth.test.ts index 8641eb4..83c3c3f 100644 --- a/workers/identity/test/auth.test.ts +++ b/workers/identity/test/auth.test.ts @@ -241,6 +241,20 @@ describe("magic link — send + verify", () => { expect(user!.emailVerified).toBe(true); }); + // A-1 (migration 0023): a DELETED account gets the identical neutral page, + // and — unlike an ordinary unknown-email request — nothing is minted or + // sent at all (mirrors the suspended-account branch right above it in + // auth-handlers.ts handleMagicRequestPost). + test("a DELETED account's magic-link request → the same neutral page, but NOTHING is sent", async () => { + const { id } = await seedUser("del-magic-req@example.com", "correct horse"); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run(); + const sender = captureSender(); + const res = await handleMagicRequestPost(env.DB, magicReq("del-magic-req@example.com"), deps(), sender); + expect(res.status).toBe(200); + expect(await res.text()).toContain("Check your email"); + expect(sender.sent).toHaveLength(0); + }); + test("rate-limited: past the window max, further sends emit nothing (still neutral 200)", async () => { const sender = captureSender(); const now = new Date("2026-07-02T12:00:00Z"); @@ -731,6 +745,21 @@ describe("sign-in code — verify by CODE instead of the link", () => { expect(cookieVal(res, "parachute_id_session")).toBeNull(); }); + // A-1 (migration 0023): same story, but DELETED — resolveVerifiedUser's + // deleted_at branch, mirroring the suspended one directly above. + test("a magic code minted before the account gets DELETED refuses to mint after — same neutral failure, no oracle", async () => { + const sender = captureSender(); + const now = new Date("2026-07-16T12:00:00Z"); + await handleMagicRequestPost(env.DB, magicReq("predel-code@example.com"), deps(() => now), sender); + const { code } = sender.sent[0]!; + const created = await createUser(env.DB, "predel-code@example.com", "", now, { emailVerified: false }); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(now.toISOString(), created.id).run(); + const res = await handleCodeVerifyPost(env.DB, codeVerifyReq("predel-code@example.com", code), deps(() => now)); + expect(res.status).toBe(200); + expect(await res.text()).toContain("request a fresh link"); + expect(cookieVal(res, "parachute_id_session")).toBeNull(); + }); + test("brute-force fence: repeated wrong codes for one (ip,email) lock out — a subsequently CORRECT code is refused too", async () => { const sender = captureSender(); const now = new Date("2026-07-16T12:00:00Z"); @@ -945,6 +974,27 @@ describe("TOTP 2FA — enroll, login gate, backup codes, disable", () => { expect(await replay.text()).toContain("match"); }); + // A-1 (migration 0023): the account is deleted BETWEEN the password step + // (which stashed the pending login) and the code step — handleLogin2faPost's + // own re-check must refuse, never trusting the pending row alone. + test("a pending 2FA login whose account gets DELETED before the code step refuses — no session, cookie cleared", async () => { + const { id } = await seedUser("totp-del@example.com", "correct horse"); + const sessionId = await seedSession(id); + const { secret } = await enroll(sessionId); + const login = await handleLoginPost(env.DB, consoleLoginReq("totp-del@example.com", "correct horse"), deps(() => T_LOGIN)); + const pending = cookieVal(login, "parachute_id_pending")!; + + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(T_LOGIN.toISOString(), id).run(); + + const code = await totpCodeAt(secret, T_LOGIN); + const res = await handleLogin2faPost(env.DB, login2faReq(code, pending), deps(() => T_LOGIN)); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/login"); + expect(cookieVal(res, "parachute_id_session")).toBeNull(); + // The pending cookie is cleared, not left dangling for a retry. + expect(getSetCookies(res).some((c) => c.startsWith("parachute_id_pending=;"))).toBe(true); + }); + test("a backup code signs in once, then is consumed", async () => { const { id } = await seedUser("totp4@example.com", "correct horse"); const sessionId = await seedSession(id); diff --git a/workers/identity/test/conformance.test.ts b/workers/identity/test/conformance.test.ts index 84c1785..8be4b01 100644 --- a/workers/identity/test/conformance.test.ts +++ b/workers/identity/test/conformance.test.ts @@ -981,6 +981,33 @@ describe("authorize flow — login, consent, skip-consent, errors", () => { expect(await res.text()).toContain("Incorrect email or password"); }); + // A-1 (migration 0023): the OTHER public login-submit path (auth/authorize's + // own inline form) needs the same refusal console.ts's /login already has. + test("login submit for a DELETED account gets the same wrong-password message, even on the correct password — no session", async () => { + const { id } = await seedUser("del-authz@example.com", "hunter2"); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run(); + const { clientId } = await seedApprovedClient(); + const { challenge } = await makePkce(); + const res = await handleAuthorizePost( + env.DB, + loginReq( + { + client_id: clientId, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "vault:default:read", + code_challenge: challenge, + code_challenge_method: "S256", + }, + { email: "del-authz@example.com", password: "hunter2" }, + ), + deps(), + ); + expect(res.status).toBe(200); + expect(await res.text()).toContain("Incorrect email or password"); + expect(res.headers.get("set-cookie") ?? "").not.toContain("parachute_id_session="); + }); + test("consent 'issued by' FALLS BACK to the issuer host when no bound request/resource origin (#42)", async () => { const { id: userId } = await seedUser(); await seedVault("default", userId); diff --git a/workers/identity/test/console.test.ts b/workers/identity/test/console.test.ts index 5a3bdd5..80d66fb 100644 --- a/workers/identity/test/console.test.ts +++ b/workers/identity/test/console.test.ts @@ -451,6 +451,22 @@ describe("console — signup", () => { expect(await dupe.text()).toContain("already exists"); }); + // A-1 (migration 0023): the tombstoned row still resolves by email (nothing + // filters getUserByEmail), so the ordinary collision path already degrades + // neutrally — no route change needed. Pins that: no 500, no second row. + test("signup against a DELETED account's email degrades to the same collision message — never a 500, never a second row", async () => { + const { id } = await seedUser("del-signup@example.com", "longenough1"); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run(); + + const res = await signup("del-signup@example.com", "anotherpassword1"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("already exists"); + const rows = await env.DB.prepare("SELECT COUNT(*) AS n FROM users WHERE email = ?") + .bind("del-signup@example.com") + .first<{ n: number }>(); + expect(rows?.n).toBe(1); // still just the one (tombstoned) row + }); + test("signup without the CSRF token is refused", async () => { const res = await app.fetch( new Request(`${ISSUER}/signup`, { @@ -537,6 +553,31 @@ describe("login brute-force fence", () => { }); }); +// --- A-1: the delete chokepoint (migration 0023) ------------------------------ + +describe("A-1 — a deleted account's password login", () => { + async function loginAttempt(email: string, password: string): Promise { + return app.fetch( + new Request(`${ISSUER}/login`, { + method: "POST", + body: new URLSearchParams({ __csrf: CSRF, email, password }), + headers: { "content-type": "application/x-www-form-urlencoded", origin: ISSUER, cookie: `parachute_id_csrf=${CSRF}` }, + }), + env, + ); + } + + test("the exact wrong-password message, even on the CORRECT password — no oracle, no session minted", async () => { + const { id } = await seedUser("del-login@example.com", "correcthorse9"); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run(); + + const res = await loginAttempt("del-login@example.com", "correcthorse9"); + expect(res.status).toBe(200); // the error re-render, not a redirect + expect(await res.text()).toContain("Incorrect email or password"); + expect(res.headers.get("set-cookie") ?? "").not.toContain("parachute_id_session="); + }); +}); + describe("password KDF posture (#28)", () => { /** Craft a LEGACY (pre-sha512) verifier the way users.ts used to write them. */ async function legacySha256Verifier(password: string): Promise { diff --git a/workers/identity/test/drip.test.ts b/workers/identity/test/drip.test.ts index 761ff82..09cdf20 100644 --- a/workers/identity/test/drip.test.ts +++ b/workers/identity/test/drip.test.ts @@ -208,6 +208,17 @@ describe("day-0 welcome", () => { expect(second.sent.welcome).toBe(1); expect(await ledgerRows(u.id)).toEqual(["welcome"]); }); + + // A-1 (migration 0023): a tombstoned account inside the welcome window must + // never be emailed — the predicate this test would fail without. + test("a DELETED account inside the welcome window does NOT get welcomed", async () => { + const u = await seedUserAt("del-welcome@example.com", minutesAgo(10)); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(NOW.toISOString(), u.id).run(); + const sender = dripSender(); + const summary = await quietly(() => runDrip(env, sender, at)); + expect(summary.sent.welcome).toBe(0); + expect(sender.sent).toHaveLength(0); + }); }); // --- day-3 connect nudge -------------------------------------------------------- @@ -274,6 +285,17 @@ describe("day-3 connect nudge", () => { const third = await quietly(() => runDrip(env, sender, at)); expect(third.sent["connect-nudge"]).toBe(0); }); + + // A-1 (migration 0023): a tombstoned account 3-4 days old, with no AI + // activity, must still never get the nudge. + test("a DELETED account due for the nudge does NOT get it", async () => { + const u = await seedUserAt("del-nudge@example.com", daysAgo(3.5)); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(NOW.toISOString(), u.id).run(); + const sender = dripSender(); + const summary = await quietly(() => runDrip(env, sender, at)); + expect(summary.sent["connect-nudge"]).toBe(0); + expect(sender.sent).toHaveLength(0); + }); }); // --- day-14 feedback -------------------------------------------------------------- @@ -306,6 +328,17 @@ describe("day-14 feedback", () => { const summary = await quietly(() => runDrip(env, sender, at)); expect(summary.sent.feedback).toBe(0); }); + + // A-1 (migration 0023): a tombstoned account 14-15 days old must not get + // the feedback ask either. + test("a DELETED account due for feedback does NOT get it", async () => { + const u = await seedUserAt("del-fb@example.com", daysAgo(14.5)); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(NOW.toISOString(), u.id).run(); + const sender = dripSender(); + const summary = await quietly(() => runDrip(env, sender, at)); + expect(summary.sent.feedback).toBe(0); + expect(sender.sent).toHaveLength(0); + }); }); // --- the per-run cap --------------------------------------------------------------- diff --git a/workers/identity/test/snapshots.test.ts b/workers/identity/test/snapshots.test.ts index cfdba13..28aa33d 100644 --- a/workers/identity/test/snapshots.test.ts +++ b/workers/identity/test/snapshots.test.ts @@ -347,6 +347,19 @@ describe("runSnapshotSweep", () => { expect(summary).toEqual({ day: TODAY, vaults: 0, taken: 0, skipped: 0, failed: 0, capped: false }); expect(await mirrorRows()).toEqual([]); }); + + // A-1 (migration 0023): a tombstoned owner's vault is excluded from + // enumeration entirely. No interceptor is registered for it — if the sweep + // tried to snapshot it anyway, disableNetConnect would refuse the fetch and + // this test would fail loudly. + test("a tombstoned owner's vault is excluded from the sweep — zero vault-worker fetches", async () => { + const { id: owner } = await seedUser("deleted-snapshot@example.com"); + await seedVault("deleted-snapshot-v", owner); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), owner).run(); + const summary = await quietly(() => runSnapshotSweep(env, sweepDeps())); + expect(summary).toEqual({ day: TODAY, vaults: 0, taken: 0, skipped: 0, failed: 0, capped: false }); + expect(await mirrorRows()).toEqual([]); + }); }); // --- listSnapshotsForVaults --------------------------------------------------------- diff --git a/workers/identity/test/trial-lifecycle.test.ts b/workers/identity/test/trial-lifecycle.test.ts index 50bf01b..4d62353 100644 --- a/workers/identity/test/trial-lifecycle.test.ts +++ b/workers/identity/test/trial-lifecycle.test.ts @@ -167,6 +167,26 @@ describe("trial → expired sweep", () => { expect(pushes.length).toBe(0); // nothing pushed }); + // A-1 (migration 0023): a DUE trial whose owner is DELETED must be excluded + // from the sweep entirely — no downgrade applied, no vault-worker push + // (waking a DO for an owner already mid-deletion). + test("a due, DELETED account is excluded from the sweep — no downgrade, no vault push", async () => { + const { id } = await seedUser("sweepdeleted@example.com"); + await seedVault("sweepdeleted-box", id); + await env.DB.prepare("UPDATE users SET plan_downgrade_at = ?, deleted_at = ? WHERE id = ?") + .bind(new Date(Date.now() - 60_000).toISOString(), new Date().toISOString(), id) + .run(); + + const { d, pushes } = recordingDeps(); + const summary = await runBillingSweep(env.DB, d, new Date()); + expect(summary).toEqual({ due: 0, applied: 0 }); + + const user = await getUserById(env.DB, id); + expect(user!.plan).toBe("trial"); // untouched + expect(user!.pendingPlan).toBe("expired"); // still armed — the sweep never saw it + expect(pushes.length).toBe(0); // zero vault-worker fetches + }); + test("createUser establishes the full trial state machine — a THREE-MONTH clock", async () => { const { id } = await seedUser("machinestate@example.com"); const user = await getUserById(env.DB, id); diff --git a/workers/identity/test/usage.test.ts b/workers/identity/test/usage.test.ts index d6da350..f2d893f 100644 --- a/workers/identity/test/usage.test.ts +++ b/workers/identity/test/usage.test.ts @@ -256,6 +256,19 @@ describe("runUsageRollup", () => { expect(summary).toEqual({ day: TODAY, vaults: 0, recorded: 0, failed: 0, capped: false }); expect(await usageRows()).toEqual([]); }); + + // A-1 (migration 0023): a tombstoned owner's vault is excluded from + // enumeration entirely. No interceptor is registered for it — if the sweep + // tried to read it anyway, disableNetConnect (beforeAll) would refuse the + // fetch and this test would fail loudly. + test("a tombstoned owner's vault is excluded from the rollup — zero vault-worker fetches", async () => { + const { id: owner } = await seedUser("deleted-usage@example.com"); + await seedVault("deleted-usage-v", owner); + await env.DB.prepare("UPDATE users SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), owner).run(); + const summary = await quietly(() => runUsageRollup(env, rollupDeps())); + expect(summary).toEqual({ day: TODAY, vaults: 0, recorded: 0, failed: 0, capped: false }); + expect(await usageRows()).toEqual([]); + }); }); // --- latestUsageForVaults --------------------------------------------------------