apps/server/docs/ai-context/verifications/account-deletion.md
Status: end-to-end verified (2026-04-28) — UI → email → click → server
soft-delete pipeline → success page all confirmed in a live run. DB-row
inspection (select deleted_at on each business table) and same-email
re-registration smoke-test are recommended but not yet captured in this
record.
Last attempted: 2026-04-28
Owner: [email protected]
Server log captured during a live deletion of userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk:
<-- POST /api/auth/delete-user
--> POST /api/auth/delete-user 200 4s
<-- GET /api/auth/delete-user/callback?token=sb7614...&callbackURL=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fdelete-account
[user-deletion] starting user deletion { userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk reason=user-requested handlerCount=5 }
[user-deletion] handler completed { handler=stripe userId=... durationMs=1297 }
[user-deletion] Flux balance soft-deleted... { userId=... clearedFlux=500 }
[user-deletion] handler completed { handler=flux userId=... durationMs=526 }
[user-deletion] Provider configs soft-deleted { userId=... count=0 }
[user-deletion] handler completed { handler=providers userId=... durationMs=260 }
[user-deletion] Characters / likes / bookmarks soft-deleted { userId=... characters=0 likes=0 bookmarks=0 }
[user-deletion] handler completed { handler=characters userId=... durationMs=787 }
[user-deletion] Chats / messages soft-deleted { userId=... chats=0 messages=0 }
[user-deletion] handler completed { handler=chats userId=... durationMs=270 }
[user-deletion] user deletion handlers completed { userId=... reason=user-requested }
--> GET /api/auth/delete-user/callback?... 302 8s
<-- GET /auth/delete-account
--> GET /auth/delete-account 200 1ms
What this proves:
clearedFlux=500 confirms the Flux handler picked up the actual balance.count=0 for providers / characters / chats reflects the test user not having those records — empty soft-delete is a valid no-op.apps/server/public/ui-server-auth/ is a build artifact (Vite outDir). New
pages added under apps/ui-server-auth/src/pages/ only show up after
running pnpm -F @proj-airi/ui-server-auth build. Symptom of forgetting:
the success page returns 200 with the SPA HTML but renders blank because
vue-router never registered the route. Re-build → fixes.
| Layer | Evidence | Date |
|---|---|---|
| Schema migration generated | apps/server/drizzle/0009_perpetual_lilandra.sql: 11 DROP CONSTRAINT + 7 ADD COLUMN deleted_at (no destructive ALTER beyond FK drop) | 2026-04-28 |
| Server typecheck | pnpm -F @proj-airi/server typecheck exits clean | 2026-04-28 |
| Monorepo typecheck | pnpm typecheck exits clean across all packages | 2026-04-28 |
| Lint | pnpm lint reports 0 errors in deletion-service / handler / UI files | 2026-04-28 |
| Deletion service unit tests | pnpm exec vitest run apps/server/src/services/user-deletion → 2 files / 14 tests pass (registry priority order, abort-on-error, serial execution, idempotency, per-service deleteAllForUser correctness) | 2026-04-28 |
| Architecture refactor | Domain knowledge moved out of *-deletion-handler.ts files into each business service's own deleteAllForUser method. Registry retained as a thin scheduler. auth → userDeletionService → 5 business services (auth and services no longer depend on each other). | 2026-04-28 |
| Server full test suite | pnpm -F @proj-airi/server exec vitest run → 244/245 pass; the single failure (origin.test.ts) is pre-existing on main and unrelated | 2026-04-28 |
| UI typecheck | pnpm -F @proj-airi/stage-pages typecheck and pnpm -F @proj-airi/ui-server-auth typecheck both clean | 2026-04-28 |
| Live server-side trace | See "Live trace" section above — full pipeline ran against real DB + Stripe sandbox + Resend | 2026-04-28 |
Command (run by user):
pnpm -F @proj-airi/server db:push
Expected: Drizzle reports Changes applied for 11 FK drops + 7 column additions on the local Postgres pointed to by DATABASE_URL.
Risk: if any business table currently has rows whose userId references a now-missing user (orphans from older bugs), DROP CONSTRAINT will succeed (unlike ADD CONSTRAINT). No data loss expected.
Command:
pnpm -F @proj-airi/server dev
Expected log lines:
injeca resolves services:userDeletion without errorservices:auth resolves successfully (depends on userDeletionService)Server started log line appears
Failure mode: if the Stripe SDK construction throws at boot, the deletion service crashes the process. Mitigation: STRIPE_SECRET_KEY is optional — handler tolerates null.Setup:
pnpm -F @proj-airi/stage-web dev with VITE_SERVER_URL=http://localhost:3000)RESEND_API_KEY valid, use [email protected] (bare address suppressed — see Resend memory)Steps + assertions:
| # | Action | Expected | DB / API check |
|---|---|---|---|
| 1 | Settings → Account → Danger Zone → click "Delete account" | Inline confirm form appears | DOM-only |
| 2 | Type wrong email | Confirm button disabled | DOM-only |
| 3 | Type correct email + click confirm | Server log: POST /api/auth/delete-user 200. UI shows "Check {email} for the deletion link" | select * from verification where identifier like 'delete-account-%' returns one row |
| 4 | Open Resend inbox, click link in email | Browser navigates to ${API_SERVER_URL}/api/auth/delete-user/callback?token=... then redirects to /auth/delete-account (success page) | server log: [user-deletion] starting user deletion → 5x handler completed → user deletion handlers completed → internalAdapter.deleteUser → 302 |
| 5 | Verify auth tables are gone (cascade) | select * from "user" where email='...' → empty | psql query |
| 6 | Verify business tables are soft-deleted (NOT cascade) | select * from user_flux where user_id=$1 → row with deleted_at IS NOT NULL | psql query, $1 = old user.id |
| 7 | Same for stripe_customer, stripe_subscription, stripe_checkout_session, stripe_invoice | All deleted_at IS NOT NULL | psql query |
| 8 | Same for character (creator_id OR owner_id), user_provider_configs (owner_id), user_character_likes/bookmarks (user_id) | All matching rows have deleted_at IS NOT NULL | psql query |
| 9 | flux_transaction is untouched (audit) | select count(*) from flux_transaction where user_id=$1 → unchanged from before deletion | psql query |
| 10 | Stripe API side: active sub canceled | Stripe dashboard or GET /v1/subscriptions/$sub_id → status: "canceled" | Stripe CLI or dashboard |
| 11 | Re-register with same email | Sign-up succeeds (unique constraint released by hard delete of user row) | server log: POST /api/auth/sign-up/email 200. New user gets a fresh user.id |
| 12 | Old soft-deleted business rows do not show up for the new user | select * from user_flux where user_id=$1 (new id) is empty or has fresh row | psql query |
Setup: kill Postgres mid-deletion (or simulate by wrapping a handler to throw) Expected:
user-deletion log: handler failed; aborting deletion pipelineuser row still present (better-auth never reaches internalAdapter.deleteUser)/delete-user/callback instead of a friendly page. Acceptable for v1 (rare path); future hook can redirect to /auth/delete-account?error=....reason: 'admin' enum exists but no caller. Wire up in admin panel later./api/auth/delete-user: uses the global /api/auth/* IP rate limit (AUTH_RATE_LIMIT_MAX). May want a tighter per-user cap (e.g. 1 attempt per hour) if abuse surfaces.server.auth.deleteAccount.*, settings.pages.account.danger.deleteAccount.modal.*, .message.emailSent, .error.fallback). Other locales fall back to EN until translated.flux_transaction retention: ledger now contains user_id strings that point to deleted users. No retention policy yet — open question for finance/legal.When the deletion code path is touched (handler logic, schema, better-auth config), re-run Path C from scratch and update "Last attempted" + the table above.