Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
42 changes: 42 additions & 0 deletions workers/identity/migrations/0023_account_delete.sql
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 20 additions & 12 deletions workers/identity/src/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,20 @@ export async function readJsonBody(req: Request): Promise<Record<string, unknown
* The Bearer gate every `/account/*` route runs first, in order:
* 1. extract + validate the token (C1 — kid + iss + jti-revocation +
* `aud="account"` pin) → 401 on missing/invalid;
* 2. resolve the account owner, and REFUSE A SUSPENDED OWNER (401
* `account_suspended`) — the read-time suspend chokepoint. C1's validator
* is stateless by design (SCOPE-d), so suspension is enforced HERE: the
* account bearer is the account-admin equivalent of a session, and the
* session path already refuses suspended owners read-time
* (findActiveSession). Applying the same chokepoint to the bearer means a
* moderation suspend severs the account surface immediately, not after the
* token's short TTL lapses. (This is stricter than the "OAuth tokens expire
* naturally" note on migration 0011, deliberately so — that note governs
* VAULT tokens spent at the vault RS, which has no user table to consult;
* the account surface runs at the issuer, which can and does check.)
* 2. resolve the account owner, and REFUSE A DELETED OR SUSPENDED OWNER —
* the read-time refusal chokepoint. C1's validator is stateless by
* design (SCOPE-d), so both are enforced HERE: the account bearer is the
* account-admin equivalent of a session, and the session path already
* refuses deleted/suspended owners read-time (findActiveSession).
* Applying the same chokepoint to the bearer means a moderation suspend
* (or a self-serve delete, migration 0023) severs the account surface
* immediately, not after the token's short TTL lapses. A deleted owner
* gets the SAME 401 `invalid_token` body as a missing row (no oracle,
* not even the distinguishable `account_suspended` a suspended owner
* gets); (this is stricter than the "OAuth tokens expire naturally" note
* on migration 0011, deliberately so — that note governs VAULT tokens
* spent at the vault RS, which has no user table to consult; the
* account surface runs at the issuer, which can and does check.)
* 3. require `verb`-or-higher account authority → 403 on an underscoped token.
*
* Returns the account id AND the loaded owner (so callers needing the plan don't
Expand Down Expand Up @@ -165,7 +168,12 @@ export async function requireAccount(
}
const accountId = result.token.accountId;
const user = await getUserById(db, accountId);
if (!user) {
// A DELETED account (migration 0023) gets the exact SAME response as a
// missing row — no oracle, and deliberately NOT the distinguishable
// `account_suspended` a live-but-suspended owner gets below: deletion is a
// stronger, one-way fact than suspension, so it degrades all the way to
// "doesn't exist" rather than to a named refusal.
if (!user || user.deletedAt !== null) {
return { ok: false, response: authError(401, "invalid_token", "account not found") };
}
if (user.suspendedAt !== null) {
Expand Down
7 changes: 6 additions & 1 deletion workers/identity/src/account-mcp-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,12 @@ async function authenticate(db: D1Database, req: Request, deps: OAuthDeps): Prom
}

const user = await getUserById(db, accountId);
if (!user) return { ok: false, status: 401, error: "invalid_token", message: "account not found" };
// A DELETED account (migration 0023) gets the exact same "account not
// found" response as a missing row — no oracle, mirroring requireAccount
// (account-api.ts) — never the distinguishable `account_suspended` below.
if (!user || user.deletedAt !== null) {
return { ok: false, status: 401, error: "invalid_token", message: "account not found" };
}
if (user.suspendedAt !== null) {
return { ok: false, status: 401, error: "account_suspended", message: "this account is suspended" };
}
Expand Down
9 changes: 5 additions & 4 deletions workers/identity/src/account-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
* with its own status so the refusal reason is legible):
* 1. **Session.** A live `parachute_id_session` cookie. `sessionUser` resolves
* it through `findActiveSession`, which JOINs `users` on `suspended_at IS
* NULL` — so a SUSPENDED owner's session is refused here (the read-time
* chokepoint, admin.ts suspend lever), and no account token is minted. No
* session / suspended → 401.
* NULL AND deleted_at IS NULL` — so a SUSPENDED (admin.ts suspend lever)
* or DELETED (migration 0023) owner's session is refused here, and no
* account token is minted. No session / suspended / deleted → 401.
* 2. **CSRF.** A POST that performs a privileged mint, so it carries the
* double-submit token (`__csrf` in the JSON body — the SDK posts JSON, not a
* form; same `csrfTokenValid` core as the console's form POSTs).
Expand Down Expand Up @@ -96,7 +96,8 @@ function jsonError(status: number, error: string, description: string): Response

export async function handleAccountToken(db: D1Database, req: Request, deps: OAuthDeps): Promise<Response> {
// 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");
Expand Down
34 changes: 18 additions & 16 deletions workers/identity/src/auth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -113,7 +114,7 @@ export async function finishPrimaryAuth(
): Promise<Response> {
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) });
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<string | null> {
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;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions workers/identity/src/billing-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BillingSweepSummary> {
// Bind the SELECT and every per-row conditional write to the SAME instant, so
Expand All @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions workers/identity/src/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
10 changes: 7 additions & 3 deletions workers/identity/src/drip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`;
Expand All @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions workers/identity/src/oauth-authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,9 @@ async function authorizeCore(
const csrf = ensureCsrfToken(req);
const extra: Record<string, string> = 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.
Expand Down Expand Up @@ -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.");
}
Expand Down
Loading