diff --git a/.changeset/self-serve-account-deletion.md b/.changeset/self-serve-account-deletion.md new file mode 100644 index 00000000..c171f901 --- /dev/null +++ b/.changeset/self-serve-account-deletion.md @@ -0,0 +1,5 @@ +--- +"dotflowy": minor +--- + +Add self-serve account deletion (More menu → Delete account). Confirmed by an emailed link, it cancels any active subscription, erases your outline, and deletes your account — the flow the privacy policy commits to. diff --git a/docs/adr/0050-self-serve-account-deletion.md b/docs/adr/0050-self-serve-account-deletion.md new file mode 100644 index 00000000..e6ca7513 --- /dev/null +++ b/docs/adr/0050-self-serve-account-deletion.md @@ -0,0 +1,44 @@ +# Self-serve account deletion: ordered teardown across three systems, aborting before the point of no return + +The privacy policy commits to "you can delete your account yourself, in the app" ([#161](https://github.com/cameronapak/dotflowy/issues/161)), and the legal pages can't publish while that promise points at a flow that doesn't exist ([#226](https://github.com/cameronapak/dotflowy/issues/226)) — so deletion is beta-blocking. The hard part isn't the button; it's that one "delete" fans out across **three systems that share no transaction**: the per-user Durable Object (outline + kv side-collections), D1 (Better Auth identity + the Stripe `subscription` row), and Stripe itself (a live subscription, possibly a $99/3-year founding prepay). Any step can succeed while the next fails. This ADR fixes the meaning of "delete" for beta and the teardown order that makes every reachable partial-failure state a _safe_ one. + +## The decisions + +1. **Immediate hard delete, no undo window.** Deletion is irreversible at the app surface the moment it completes — no soft-delete marker, no "scheduled for deletion" login state, no self-serve undo. Rejected a grace/undo window: it drags in a whole subsystem (a `deletedAt` column + a scheduled purge via DO alarm or cron + a deletion-pending auth state) for a beta-marginal feature. The safety net for an _accidental_ delete is the operator restoring from PITR (decision 8), not a product undo. A self-serve undo window is **fog / post-beta**. + +2. **Built on Better Auth's `user.deleteUser`, not a hand-rolled endpoint.** Enabling `user.deleteUser` gives us the `/delete-user` endpoint, the D1 cascade of `session`/`account`/`verification`/`user`, session revocation, and an optional email-token confirmation — all riding the existing `/api/auth/*` routing. A custom Worker endpoint would re-implement session revoke + cascade and drift from Better Auth's contract. The `beforeDelete(user)` and `afterDelete(user)` hooks are the seams for everything Better Auth doesn't know about (Stripe, the DO, the email-bearing side tables). The hooks close over the per-request `env` (auth is built per request in `createAuth(env, origin, ctx)`), so they can reach `env.USER_OUTLINE`, `env.STRIPE_SECRET_KEY`, and `env.DB`. + +3. **The confirmation gate is intent + identity: type-to-confirm in the app, then an emailed confirmation link.** Type-to-confirm alone fails the identity test on a shared or unlocked machine, and identity proof alone lets a mis-click through — an irreversible action needs both. Intent is a "type DELETE" gate in the dialog; identity is **clicking a link emailed to the account address** (`sendDeleteAccountVerification`, funneled through the one `worker/email.ts` seam like password reset). Email control is the strongest, uniform proof — it works identically for password AND Google-only accounts, with no per-account-type branching — and an emailed confirmation for permanent deletion is the mature-app norm (GitHub, Google). The callback that the link hits requires a live session and redirects to a client-supplied `callbackURL`, so it also gives us the post-delete hardReset for free (a full navigation). Rejected type-to-confirm-only (no identity proof). + + > **Amended during build (kept here, not rewritten away):** the grill picked "password re-auth for password accounts, email-token only for Google-only" to minimize friction on the common case. Better Auth's `deleteUser` route makes that split un-buildable without bypassing its endpoint: once `sendDeleteAccountVerification` is configured, `POST /delete-user` ALWAYS routes to the send-email branch and returns before deleting, even when a valid password is supplied — so configuring the sender forces email confirmation for everyone regardless. Given that, email-confirm-for-everyone is both the library-supported path AND strictly more robust (uniform, strongest proof, less client code), so it wins outright. The extra friction is a feature for an irreversible action. Both `beforeDelete` and `afterDelete` fire on the token-callback path (verified in the Better Auth source), so the teardown below is unaffected. + +4. **Teardown order: cancel Stripe → delete the D1 subscription row → wipe the DO → (Better Auth deletes the D1 identity).** Steps 1–3 run inside `beforeDelete(user)`; step 4 is `deleteUser`'s own cascade, reached **only if `beforeDelete` returns without throwing**. The order is derived by ranking the partial-failure states worst-to-mildest and putting the riskiest, most-external, must-not-orphan work first: + - **Worst — identity gone, Stripe still live:** the account no longer exists but Stripe keeps charging a subscription with no account to cancel it from and no webhook target to reconcile it. A silent recurring charge for a deleted account. Cancelling Stripe **first**, where a failure aborts the whole delete, makes this state **unreachable by construction**. + - **Bad-ish — DO wiped, identity survives:** the user logs back in to an empty outline (re-seeds the welcome bullets). Annoying, recoverable, no money or privacy harm. + - **Mildest — Stripe cancelled + sub row gone, identity survives:** they keep their account but lost their paid plan; they can re-subscribe or retry the delete. Cancel never charges, so it's money-safe. + Since `beforeDelete` throwing aborts the D1 identity delete, the only reachable partial-failure states are the _mild_ ones, and each is idempotently fixable by retrying (decision 6). Rejected **DO-wipe-after-identity-delete**: if identity delete succeeds and the DO wipe then fails, the DO **orphans** — live user data in storage with no account pointing at it and no logged-in user left to retry — which directly violates the "we deleted your data" promise. Orphaned _data_ is strictly worse than an orphaned _empty outline_. + +5. **Stripe is cancelled immediately, and the `subscription` row is deleted explicitly.** Immediate `stripe.subscriptions.cancel()`, **not** `cancel_at_period_end` — an "active until period end" subscription pointed at a deleted user is precisely the orphan decision 4 designs out. Better Auth's `deleteUser` does **not** cascade the Stripe plugin's `subscription` table (it has no user-delete relation), so we delete rows for `referenceId = user.id` ourselves. A **comped** user (an operator-inserted `active` row with no Stripe ids, per [#170](https://github.com/cameronapak/dotflowy/issues/170)) and a free user (no row) both **skip the Stripe call** and just clear any D1 row — the Stripe step no-ops whenever there's no `stripeSubscriptionId`. + +6. **Every step is idempotent; retry after a partial failure is safe.** Skip Stripe when there's no active subscription; deleting an already-absent `subscription` row is a no-op; the DO's `purge()` RPC (`ctx.storage.deleteAll()`) is safe to call twice. And the strongest guard is structural: `deleteUser` revokes the session, so **once the full delete succeeds there is no session left to authenticate a second delete**. A partial failure aborts _before_ session revoke, so the user is still signed in and can simply retry — each step re-runs and no-ops whatever already happened. + +7. **Erasure includes the email-bearing side tables, best-effort, in `afterDelete`.** Beyond Better Auth's cascade, `afterDelete(user)` scrubs the `waitlist` and per-email invite rows ([#251](https://github.com/cameronapak/dotflowy/issues/251)) matching the user's email — they carry PII (the email) that the "delete my data" promise covers. This runs _after_ the identity delete and is **non-fatal**: the account is already gone, so a failed scrub is logged (Sentry), not surfaced as an error. Kept deliberately narrow — no attempt to chase every derived record; the account and the outline are the substance. + +8. **"Deleted" at the app surface is honest about the 30-day PITR floor.** Durable Objects carry automatic 30-day Point-in-Time Recovery ([#155](https://github.com/cameronapak/dotflowy/issues/155)), so `storage.deleteAll()` removes the outline from every live path immediately but the operator retains a recoverable backup for up to 30 days. The privacy policy must say so plainly — deletion is immediate and irreversible in-app; backups purge automatically within 30 days — matching the map's privacy-honesty rule (per-user DOs isolate users from each other, not from the operator). The confirmation dialog names it in one line; [#226](https://github.com/cameronapak/dotflowy/issues/226) carries the full statement. This is disclosure, not a user-facing undo — PITR restore is a manual operator support action. + +9. **Refunds are decoupled from deletion.** Deleting cancels the subscription immediately; it does **not** auto-refund. The ToS's 14-day refund window on annual/founding stays a **manual support action** (Stripe dashboard / support@) — baking prorated-refund logic into an irreversible teardown path is error-prone scope creep. The confirmation copy warns explicitly, which matters most for founding ($99 / 3-year prepay): "Deleting cancels your subscription immediately with no automatic refund; contact support within 14 days if you're eligible." + +## UX and mechanism + +- **Entry point:** a danger-styled "Delete account" item in the header More menu, opening a `DeleteAccountDialog` mounted once in `__root.tsx` (the `DeleteConfirmDialog` pattern). The dialog carries the type-to-confirm field, the identity proof (password field, or "we'll email you a confirmation link" for Google-only accounts), and the refund + 30-day-backup warnings. +- **Post-delete:** on success the client does a **`hardReset` → `window.location.replace("/")`**, never SPA navigation — module singletons (collections, the sync fiber, view/selection state) persist across the auth boundary, and only a full document load tears them down (the cross-account-leak rule; ADR 0011's hardReset teardown). It lands on the signed-out AuthScreen with a "Your account has been deleted" confirmation. + +## Testing + +The full flow **isn't e2e-reachable** — `seedOutline` mocks `/api/auth`, so the real `deleteUser` + hook path never runs under Playwright (the same constraint as password reset). So: the **pure guards** get unit tests (skip-Stripe-when-no-subscription, `purge()` idempotency, the row-scrub SQL), and the end-to-end flow gets a **manual verification checklist in the PR** covering the password path, the Google-only email-token path, a comped-user delete (Stripe no-op), and a forced mid-teardown failure to confirm the abort leaves the account intact and retryable. + +## Non-goals (v1) and noted edges + +- **Self-serve undo / grace window** — deferred (decision 1); the operator PITR restore is the accidental-delete safety net. +- **Automatic refunds** — manual support action (decision 9). +- **Owner edge case:** `OWNER_USER_ID` maps the owner account to the constant `'default'` DO (ADR 0011), which holds the seeded legacy outline; an owner self-deleting via the app would `purge()` that data. Realistically the owner won't self-delete through the UI, so beta ships no special-casing — noted here so a future change to owner semantics revisits it deliberately. diff --git a/src/components/auth-screen.tsx b/src/components/auth-screen.tsx index 41b6eb7f..5c61f3ff 100644 --- a/src/components/auth-screen.tsx +++ b/src/components/auth-screen.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type FormEvent } from "react"; +import { toast } from "sonner"; import { hardReset, @@ -62,6 +63,25 @@ export function AuthScreen() { if (message) setError(message); }, []); + // Account deletion (ADR 0050) completes server-side and redirects here + // signed-out with ?account-deleted — the positive confirmation that the + // teardown ran. Strip the param so a reload doesn't re-toast, and only the + // first Strict-Mode effect run (which still sees the param) fires. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (!params.has("account-deleted")) return; + params.delete("account-deleted"); + const query = params.toString(); + window.history.replaceState( + null, + "", + window.location.pathname + + (query ? `?${query}` : "") + + window.location.hash, + ); + toast.success("Your account has been deleted.", { duration: 10_000 }); + }, []); + async function onGoogle() { setError(null); setBusy(true); diff --git a/src/components/delete-account-dialog.tsx b/src/components/delete-account-dialog.tsx new file mode 100644 index 00000000..4b77c82b --- /dev/null +++ b/src/components/delete-account-dialog.tsx @@ -0,0 +1,172 @@ +import { Loader2Icon, MailCheckIcon, TriangleAlertIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { + NETWORK_ERROR_MESSAGE, + requestAccountDeletion, +} from "../lib/auth-client"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; + +/** + * Self-serve account deletion dialog (ADR 0050, ticket #224), opened from the + * header More menu. Two stages: + * + * 1. "form" — the type-to-confirm intent gate ("DELETE") plus the three + * surprises stated plainly (permanent, cancels the subscription with no + * automatic refund, backups purge in 30 days). Submitting does NOT delete + * anything: it calls requestAccountDeletion, which SENDS a confirmation + * email (identity proof — uniform across password + Google-only accounts). + * 2. "sent" — "check your email"; the emailed link is what actually deletes + * (its callback redirects here signed-out, a full-navigation teardown). + * + * The word to type is "DELETE" (case-insensitive, trimmed) — an intent gate, + * not identity proof; the email link is the identity proof. + */ + +const CONFIRM_WORD = "DELETE"; + +type Stage = "form" | "sent"; + +export function DeleteAccountDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [stage, setStage] = useState("form"); + const [confirmText, setConfirmText] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Reset to a clean form whenever the dialog closes, so reopening never shows + // a stale "sent" screen or a half-typed confirmation. + useEffect(() => { + if (!open) { + setStage("form"); + setConfirmText(""); + setBusy(false); + setError(null); + } + }, [open]); + + const confirmed = confirmText.trim().toUpperCase() === CONFIRM_WORD; + + async function onSubmit() { + if (!confirmed || busy) return; + setBusy(true); + setError(null); + try { + const res = await requestAccountDeletion(); + if (res.error) { + setError(res.error.message ?? "Couldn't start deletion. Try again."); + setBusy(false); + return; + } + setStage("sent"); + } catch { + setError(NETWORK_ERROR_MESSAGE); + } finally { + setBusy(false); + } + } + + return ( + + + {stage === "form" ? ( + <> + + + + Delete your account + + + This permanently deletes your account and your entire outline. + It cannot be undone. + + + +
    +
  • Your outline and all your data are permanently erased.
  • +
  • + Any active subscription is cancelled immediately, with no + automatic refund. Contact support within 14 days if you're + eligible. +
  • +
  • Backups are purged within 30 days.
  • +
  • + We'll email you a confirmation link — deletion only happens once + you click it. +
  • +
+ +
+ + setConfirmText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onSubmit(); + }} + placeholder={CONFIRM_WORD} + aria-invalid={error ? true : undefined} + /> + {error &&

{error}

} +
+ + + + + + + ) : ( + <> + + + + Check your email + + + We sent a confirmation link to your email address. Click it to + permanently delete your account. The link expires in 24 hours; + until you click it, nothing is deleted. + + + + + + + )} +
+
+ ); +} diff --git a/src/components/header-more-menu.tsx b/src/components/header-more-menu.tsx index 89787dba..c13d0633 100644 --- a/src/components/header-more-menu.tsx +++ b/src/components/header-more-menu.tsx @@ -17,6 +17,7 @@ import { SparklesIcon, SunIcon, SunMoonIcon, + Trash2Icon, } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -36,6 +37,7 @@ import { getTreeIndex } from "../data/tree-store"; import { getViewRootId } from "../data/view-state"; import { connectGoogle, signOutAndReload } from "../lib/auth-client"; import { openChangelog } from "./changelog-opener"; +import { DeleteAccountDialog } from "./delete-account-dialog"; import { McpConnectDialog } from "./mcp-connect-dialog"; import { openOpmlImport } from "./opml-import-opener"; import { useShowCompleted } from "./show-completed-provider"; @@ -207,9 +209,10 @@ export function HeaderMoreMenu() { // old loud header CTA. Presence IS the signal; opening the dialog marks // everything read, so both the dot and the item emphasis clear themselves. const unseen = useUnseenReleaseCount(); - // The connect dialog is a sibling of the menu (not nested in its content) so - // it survives the menu closing on item select. + // The connect + delete dialogs are siblings of the menu (not nested in its + // content) so they survive the menu closing on item select. const [connectOpen, setConnectOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); return ( <> @@ -389,16 +392,27 @@ export function HeaderMoreMenu() { Connect Google + signOutAndReload()}> + + Sign out + + + + + {/* Self-serve account deletion (ADR 0050 / #224). Destructive-styled + and set apart below its own separator so it can't be mis-clicked + for Sign out; the dialog carries the type-to-confirm gate. */} signOutAndReload()} + onClick={() => setDeleteOpen(true)} > - - Sign out + + Delete account + ); } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index cf4af68f..086d1160 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -72,6 +72,20 @@ export function hardReset(target: string = "/") { window.location.replace(target); } +/** + * Start self-serve account deletion (ADR 0050). Email confirmation is + * configured server-side, so this call only SENDS the confirmation email — it + * does NOT delete anything. The actual teardown (cancel Stripe → wipe DO → + * delete identity) runs when the user clicks the emailed link, whose + * /delete-user/callback redirects to `callbackURL`: a full top-level navigation + * that lands signed-out on the AuthScreen with ?account-deleted, which is the + * hardReset singleton-teardown by construction (no client reload needed). + * Returns Better Auth's `{ data, error }`. + */ +export function requestAccountDeletion() { + return authClient.deleteUser({ callbackURL: "/?account-deleted=1" }); +} + /** * Sign out, then hard-reset (Better Auth's documented onSuccess pattern). On * failure (offline, 5xx) the session cookie is still valid and no teardown ran, diff --git a/worker/auth.ts b/worker/auth.ts index b8244590..7a642f76 100644 --- a/worker/auth.ts +++ b/worker/auth.ts @@ -18,6 +18,9 @@ import { APIError, createAuthMiddleware } from "better-auth/api"; import { mcp } from "better-auth/plugins"; import Stripe from "stripe"; +import type { UserOutlineDO } from "./outline-do"; + +import { deleteAccountData, scrubUserPii } from "./delete-account"; import { sendEmail } from "./email"; import { isRedeemableInvite, normalizeEmail, redeemInvite } from "./invites"; import { FOUNDING_SEAT_LIMIT, countFoundingSeats } from "./plan"; @@ -51,6 +54,12 @@ export interface AuthEnv { * dashboard endpoint in prod, `stripe listen` locally. Unset = webhook * signature verification fails closed. */ STRIPE_WEBHOOK_SECRET?: string; + /** The per-user outline Durable Object namespace — the account-deletion + * teardown wipes the deleted user's DO through it (ADR 0050). */ + USER_OUTLINE: DurableObjectNamespace; + /** The owner's Better Auth `user.id` (owner-continuity bridge, index.ts). The + * delete teardown reads it so it wipes the SAME DO the request router uses. */ + OWNER_USER_ID?: string; } /** @@ -83,11 +92,37 @@ function resetPasswordEmail(url: string) { }; } +/** The account-deletion confirmation email (ADR 0050). Deletion is gated on + * clicking this link — the uniform, strongest proof of intent + identity for + * an irreversible action, and it works identically for password and + * Google-only accounts. The copy is explicit about the three things that + * surprise people: it's immediate + permanent, it cancels the subscription + * with no automatic refund, and backups purge within 30 days (ADR 0050 / the + * privacy page, #226). */ +function deleteAccountEmail(url: string) { + return { + subject: "Confirm your Dotflowy account deletion", + text: `Confirm you want to permanently delete your Dotflowy account:\n\n${url}\n\nThis permanently deletes your outline and account — it cannot be undone. Any active subscription is cancelled immediately with no automatic refund (contact support within 14 days if you're eligible). Backups are purged within 30 days.\n\nThis link expires in 24 hours. If you didn't ask to delete your account, ignore this email — nothing will happen.`, + html: `
+

Confirm account deletion

+

Click below to permanently delete your Dotflowy account and outline. This cannot be undone.

+

Delete my account

+

Any active subscription is cancelled immediately with no automatic refund (contact support within 14 days if you're eligible). Backups are purged within 30 days. This link expires in 24 hours.

+

If you didn't ask to delete your account, ignore this email — nothing will happen.

+
`, + }; +} + export function createAuth( env: AuthEnv, requestOrigin?: string, executionCtx?: ExecutionContext, ) { + // One Stripe client for both the billing plugin and the account-deletion + // teardown (which cancels subscriptions before wiping data — ADR 0050). + const stripeClient = new Stripe( + env.STRIPE_SECRET_KEY ?? "sk_test_placeholder", + ); return betterAuth({ // Better Auth accepts a D1 binding directly (kysely under the hood). database: env.DB, @@ -125,6 +160,50 @@ export function createAuth( // so a stolen one dies with the old password. revokeSessionsOnPasswordReset: true, }, + // Self-serve account deletion (ADR 0050, ticket #224 — the privacy pages + // commit to it). Configuring `sendDeleteAccountVerification` routes EVERY + // delete through an email-confirmation link: POST /delete-user only sends + // the mail; the actual teardown runs in the /delete-user/callback the link + // hits (which requires a live session), so both hooks below fire exactly + // once, on confirmation. This is uniform across password AND Google-only + // accounts (no per-type branching), and the callback's redirect to the + // client-supplied callbackURL is a full navigation — the hardReset + // singleton-teardown by construction (see auth-client.ts). + user: { + deleteUser: { + enabled: true, + // The confirmation email. Better Auth awaits this (no background-task + // handler), and there's no enumeration channel to hide (the caller is + // an authenticated session), so a plain await is correct — the "email + // sent" response waits for the send. sendEmail never throws. + sendDeleteAccountVerification: async ({ user, url }) => { + await sendEmail(env, { + to: user.email, + ...deleteAccountEmail(url), + }); + }, + // The safe-ordered teardown. Throwing ABORTS the D1 identity delete, so + // any failure leaves the account fully intact and retryable — the worst + // state (identity gone, Stripe still charging) can't be reached. + beforeDelete: async (user) => { + await deleteAccountData( + { + db: env.DB, + stripe: stripeClient, + userOutline: env.USER_OUTLINE, + ownerUserId: env.OWNER_USER_ID, + }, + user.id, + ); + }, + // Best-effort PII scrub of the email-bearing side tables Better Auth's + // cascade doesn't own. Runs after the identity is gone, so it never + // throws (nothing left to roll back). + afterDelete: async (user) => { + await scrubUserPii({ db: env.DB }, user.email); + }, + }, + }, // "Sign in with Google" — sign-IN only. `disableSignUp: true` is the same // invite gate as the /sign-up/email hook, expressed for OAuth: a Google // callback with no matching account is rejected server-side instead of @@ -266,9 +345,7 @@ export function createAuth( plugins: [ mcp({ loginPage: "/" }), stripe({ - stripeClient: new Stripe( - env.STRIPE_SECRET_KEY ?? "sk_test_placeholder", - ), + stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET ?? "", // No Stripe customer until first checkout: alpha users need no // backfill, and the free tier is the absence of a subscription row. diff --git a/worker/delete-account.test.ts b/worker/delete-account.test.ts new file mode 100644 index 00000000..351e61e5 --- /dev/null +++ b/worker/delete-account.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; + +import { + OWNER_DO_ID, + resolveDoName, + shouldAbortOnStripeCancelError, +} from "./delete-account"; + +describe("resolveDoName", () => { + it("routes a normal user to their own user.id", () => { + expect(resolveDoName("user_abc")).toBe("user_abc"); + expect(resolveDoName("user_abc", "user_owner")).toBe("user_abc"); + }); + + it("maps the owner to the constant 'default' DO (owner-continuity bridge)", () => { + expect(resolveDoName("user_owner", "user_owner")).toBe(OWNER_DO_ID); + expect(OWNER_DO_ID).toBe("default"); + }); + + it("ignores an unset owner bridge", () => { + expect(resolveDoName("user_owner", undefined)).toBe("user_owner"); + expect(resolveDoName("user_owner", "")).toBe("user_owner"); + }); +}); + +describe("shouldAbortOnStripeCancelError", () => { + it("does NOT abort on a 4xx invalid-request (subscription missing / already canceled — the idempotent retry case)", () => { + expect( + shouldAbortOnStripeCancelError({ type: "StripeInvalidRequestError" }), + ).toBe(false); + expect( + shouldAbortOnStripeCancelError({ + type: "StripeInvalidRequestError", + code: "resource_missing", + }), + ).toBe(false); + }); + + it("aborts on transient / unknown Stripe errors (we can't confirm the cancel — retry the whole delete)", () => { + expect( + shouldAbortOnStripeCancelError({ type: "StripeConnectionError" }), + ).toBe(true); + expect(shouldAbortOnStripeCancelError({ type: "StripeAPIError" })).toBe( + true, + ); + }); + + it("aborts fail-safe on a non-Stripe / shapeless error", () => { + expect(shouldAbortOnStripeCancelError(new Error("boom"))).toBe(true); + expect(shouldAbortOnStripeCancelError(null)).toBe(true); + expect(shouldAbortOnStripeCancelError(undefined)).toBe(true); + expect(shouldAbortOnStripeCancelError("nope")).toBe(true); + }); +}); diff --git a/worker/delete-account.ts b/worker/delete-account.ts new file mode 100644 index 00000000..5c862c62 --- /dev/null +++ b/worker/delete-account.ts @@ -0,0 +1,147 @@ +/// + +/** + * Self-serve account deletion — the teardown that fans one "delete" out across + * three transactionless systems, ordered so every reachable partial failure is + * a SAFE one (ADR 0050). Called from Better Auth's `deleteUser` hooks in + * worker/auth.ts: `deleteAccountData` in `beforeDelete` (a throw there aborts + * the D1 identity delete), `scrubUserPii` in `afterDelete`. + * + * The order — cancel Stripe → delete the D1 subscription row → wipe the DO — + * puts the riskiest, most-external, must-not-orphan work FIRST, so the worst + * state (identity gone but Stripe still charging) is unreachable by + * construction. See ADR 0050 for the full ranking + rationale. + */ + +import type Stripe from "stripe"; + +import type { UserOutlineDO } from "./outline-do"; + +import { normalizeEmail } from "./invites"; + +/** The Durable Object name for the pre-auth / owner outline (mirrors index.ts's + * OWNER_DO_ID — kept here as the single source so the delete path and the + * request router can't drift on which DO holds a given user's data). */ +export const OWNER_DO_ID = "default"; + +/** + * The DO name for a user's outline. Permanent-by-`user.id` (ADR 0011), except + * the owner-continuity bridge: when `ownerUserId` is set and matches, the owner + * maps to the constant OWNER_DO_ID where their pre-auth outline lives. Pure so + * both the request router (index.ts) and the delete teardown resolve the SAME + * DO — deleting must wipe exactly the DO the app reads/writes. + */ +export function resolveDoName(userId: string, ownerUserId?: string): string { + return ownerUserId && userId === ownerUserId ? OWNER_DO_ID : userId; +} + +/** + * Whether a Stripe cancel error should ABORT the whole delete (rethrow) or be + * treated as an idempotent no-op (swallow). The discriminator is Stripe's error + * `type`: a `StripeInvalidRequestError` is a 4xx — the subscription is missing + * or already canceled (exactly the state a retry after a partial failure lands + * in), so there is nothing left to cancel and the delete may proceed. Anything + * else (connection/API/5xx — "we don't know if it canceled") aborts, so the + * delete is retried rather than risk leaving a live subscription behind. Pure, + * so it's unit-tested without a Stripe client. + */ +export function shouldAbortOnStripeCancelError(err: unknown): boolean { + const type = (err as { type?: string } | null)?.type; + return type !== "StripeInvalidRequestError"; +} + +/** Idempotent immediate cancel of one subscription. Immediate (not + * `cancel_at_period_end`): a subscription living on past a deleted user is the + * orphan the ordering exists to prevent. */ +async function cancelSubscription( + stripe: Stripe, + subscriptionId: string, +): Promise { + try { + await stripe.subscriptions.cancel(subscriptionId); + } catch (err) { + if (shouldAbortOnStripeCancelError(err)) throw err; + // Missing / already-canceled: a retry landing on an already-torn-down + // subscription. Nothing to do; let the delete proceed. + console.warn( + `stripe cancel no-op (${subscriptionId}): already canceled or missing`, + ); + } +} + +export interface DeleteAccountDeps { + db: D1Database; + stripe: Stripe; + userOutline: DurableObjectNamespace; + /** env.OWNER_USER_ID — the owner-continuity bridge (usually unset). */ + ownerUserId?: string; +} + +/** + * beforeDelete: tear down everything Better Auth's D1 cascade does NOT own, + * in the safe order. Throwing here aborts the identity delete (Better Auth's + * contract), so a failure leaves the account fully intact and retryable. + * + * Every step is idempotent: a retry after a partial failure re-runs and no-ops + * whatever already happened (already-canceled Stripe sub → swallowed; the D1 + * row is already gone → DELETE matches nothing; purge() on an empty DO is a + * no-op). + */ +export async function deleteAccountData( + deps: DeleteAccountDeps, + userId: string, +): Promise { + // 1. Cancel live Stripe subscriptions FIRST (external + riskiest + must not + // outlive the account). Comped rows carry no stripeSubscriptionId, so they + // are skipped here and removed by step 2. + const subs = await deps.db + .prepare( + `SELECT stripeSubscriptionId FROM subscription + WHERE referenceId = ?1 + AND status IN ('active', 'trialing') + AND stripeSubscriptionId IS NOT NULL`, + ) + .bind(userId) + .all<{ stripeSubscriptionId: string }>(); + for (const { stripeSubscriptionId } of subs.results) { + await cancelSubscription(deps.stripe, stripeSubscriptionId); + } + + // 2. Remove the D1 subscription rows (Better Auth's deleteUser does NOT + // cascade the Stripe plugin's table). Covers comped rows too. + await deps.db + .prepare(`DELETE FROM subscription WHERE referenceId = ?1`) + .bind(userId) + .run(); + + // 3. Wipe the outline DO (its private SQLite + kv side-collections, atomic — + // ADR 0050). LAST, so a failure here leaves the account intact rather than + // orphaning live data under a deleted identity. 30-day PITR still holds an + // operator-recoverable backup; see ADR 0050. + const doName = resolveDoName(userId, deps.ownerUserId); + const stub = deps.userOutline.get(deps.userOutline.idFromName(doName)); + await stub.purge(); +} + +/** + * afterDelete: best-effort scrub of the email-bearing side tables Better Auth + * doesn't know about — the `waitlist` and per-email `invites` rows (both keyed + * on the normalized email). Runs AFTER the identity is already deleted, so a + * failure here must NOT throw (the account is gone; there is nothing to roll + * back) — log and move on. Uses `normalizeEmail` (the same normalization both + * tables store under) so the DELETE actually matches. + */ +export async function scrubUserPii( + deps: Pick, + email: string, +): Promise { + const normalized = normalizeEmail(email); + try { + await deps.db.batch([ + deps.db.prepare(`DELETE FROM waitlist WHERE email = ?1`).bind(normalized), + deps.db.prepare(`DELETE FROM invites WHERE email = ?1`).bind(normalized), + ]); + } catch (err) { + console.error(`account PII scrub failed (post-delete, non-fatal):`, err); + } +} diff --git a/worker/index.ts b/worker/index.ts index 1a4b402a..8919c5c4 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -36,6 +36,7 @@ import type { AuthEnv } from "./auth"; import type { Node } from "./wire"; import { createAuth } from "./auth"; +import { OWNER_DO_ID, resolveDoName } from "./delete-account"; import { mintInvites, normalizeEmail, @@ -133,13 +134,14 @@ const KV_COLLECTIONS = new Set([ * the owner's `user.id` maps that one account back to 'default', carrying their * existing data over with zero copy. Removable once that data is wherever it * belongs. + * + * The name resolution itself lives in worker/delete-account.ts (`resolveDoName` + * + `OWNER_DO_ID`) so the request router here and the account-deletion teardown + * resolve the SAME DO — deleting must wipe exactly the DO the app reads/writes + * (ADR 0050). */ -const OWNER_DO_ID = "default"; - function resolveUserId(sessionUserId: string, env: Env): string { - if (env.OWNER_USER_ID && sessionUserId === env.OWNER_USER_ID) - return OWNER_DO_ID; - return sessionUserId; + return resolveDoName(sessionUserId, env.OWNER_USER_ID); } /** diff --git a/worker/outline-do.ts b/worker/outline-do.ts index d1a49454..499a5bf7 100644 --- a/worker/outline-do.ts +++ b/worker/outline-do.ts @@ -534,7 +534,8 @@ export class UserOutlineDO extends DurableObject { JSON.stringify(f.ops), ); } - const finalSeq = frames[frames.length - 1].seq; + // Non-empty: the `if (!frames.length)` guard above already returned. + const finalSeq = frames[frames.length - 1]!.seq; this.setSeq(finalSeq); this.sql.exec( "DELETE FROM changelog WHERE seq <= ?", @@ -572,7 +573,8 @@ export class UserOutlineDO extends DurableObject { } } } - return frames[frames.length - 1].seq; + // Non-empty: the `if (!frames.length)` guard above already returned. + return frames[frames.length - 1]!.seq; } /** @@ -776,4 +778,21 @@ export class UserOutlineDO extends DurableObject { "INSERT INTO meta (key, value) VALUES ('seeded', '1') ON CONFLICT(key) DO UPDATE SET value = '1'", ); } + + // --- self-serve account deletion ------------------------------------------- + + /** + * Erase this user's entire outline + kv side-collections — the DO half of + * self-serve account deletion (ADR 0050), called from the delete flow's + * `beforeDelete` hook via the stub. `deleteAll()` removes the DO's whole + * private SQLite database (SQL data AND key-value data) atomically, so it + * drops the schema too; `migrate()` then rebuilds an empty, valid schema so a + * reused instance never queries dropped tables. Idempotent: safe to call on + * an already-empty DO (a retry after a partial failure). Note: 30-day PITR + * still holds an operator-recoverable backup — see ADR 0050. + */ + async purge(): Promise { + await this.ctx.storage.deleteAll(); + this.migrate(); + } }