feat(auth): delete account (#1756)

This commit is contained in:
RainbowBird
2026-04-28 20:53:00 +08:00
committed by GitHub
parent 0af7d294a8
commit 6f0b7e0b9b
33 changed files with 4489 additions and 65 deletions
+4
View File
@@ -31,8 +31,12 @@
- 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录
- `email-auth-resend.md`
- Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项
- `account-deletion.md`
- 账号注销架构:auth 表 hard delete + 业务表软删,handler 协议、各业务行为、failure 模型
- `verifications/email-auth.md`
- 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据
- `verifications/account-deletion.md`
- 账号注销端到端验证:what's verifiedschema/typecheck/units)和 what's pendinglive DB + Resend + Stripe trace
## 快速结论
@@ -0,0 +1,184 @@
# Account Deletion
User-requested account deletion. Auth identity is hard-deleted; business records are soft-deleted (preserved with `deleted_at`) for audit/compliance.
## 决策摘要
| 决策点 | 选择 | 理由 |
|---|---|---|
| `apps/server/src/schemas/accounts.ts` | **不动** | better-auth `auth:generate` 自动产物。修改会被下次生成覆盖 |
| Auth 表 (user/session/account/oauth\*/verification) | **hard delete + cascade** | 跟着 user 一起 cascade 干净。无审计价值,留着只是 dangling auth state |
| 业务表 (flux\*/stripe\*/character\*/providers/chats) | **soft delete (deleted_at)** | 审计、合规、debug 需要保留"这条记录原属于哪个 user" |
| 业务表对 user.id 的 FK | **drop FK constraint,保留裸 userId 列** | better-auth hard-delete user 时不会被 cascade 干掉。跟 `llm_request_log` 现有做法一致 |
| llm_request_log | **不参与软删,独立 retention** | 高并发写入,本就无 FK;保留期由独立 retention job 决定(合规) |
| 删除流程 | **better-auth 内建邮件确认** | `user.deleteUser.sendDeleteAccountVerification` + token 回调,开箱即用 |
| 误删恢复 | **不支持** | 用户认知中"删除即不可逆"。要恢复就重新注册(同 email 没问题,user 行已删,唯一约束释放) |
| Stripe 订阅 | **立即 cancel,不退款(v1** | 简单、对内部记账影响最小。条款需注明。后续可改 |
| Flux 余额 | **清零(userFlux.deletedAt** | 同上。后续可补退款逻辑 |
## 流程
```
用户在 settings/account 点 Delete
POST /api/auth/delete-user (Bearer) ← better-auth
sendDeleteAccountVerification → Resend ← 我们的 EmailService
↓ 用户收邮件,点链接
GET /api/auth/delete-user/callback?token=... ← better-auth 验 token
↓ token 有效
beforeDelete(user) ← UserDeletionService.softDeleteAll(userId)
├─ stripe (priority 10): stripeService.deleteAllForUser
│ → Stripe API cancel + 4 张 stripe_* 表打 deletedAt
├─ flux (priority 20): fluxService.deleteAllForUser
│ → userFlux 打 deletedAt + redis cache 失效
├─ providers (priority 30): providerService.deleteAllForUser
│ → userProviderConfigs 打 deletedAt
├─ characters (priority 30): characterService.deleteAllForUser
│ → character / likes / bookmarks 打 deletedAt
└─ chats (priority 30): chatService.deleteAllForUser
→ chats / messages 打 deletedAt
internalAdapter.deleteUser(userId) ← user 行真删
↓ Postgres FK cascade
session/account/oauth_client/oauth_*_token/oauth_consent ← 真删
重定向到 callbackURL
```
## 架构:service own 自己的删除语义
每个业务 service 自己 own `deleteAllForUser(userId)` 方法 —— 删除该 user scope 下所有相关数据的能力跟 service 的其他 CRUD 方法住在一起。`UserDeletionService` 只是个**调度器**:按 priority 串行调用各 service 的方法,throw 中止。
依赖图:
```
auth ──depends on──► userDeletionService ──depends on──► [stripeService, fluxService, ...]
└─ 内部仅持有 { name, priority, softDelete } 列表,
softDelete 是对 service.deleteAllForUser 的 thin wrapper
```
auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。
```ts
// apps/server/src/services/user-deletion/types.ts
export interface UserDeletionHandler {
name: string
/** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */
priority: number
softDelete: (ctx: UserDeletionContext) => Promise<void>
}
export interface UserDeletionService {
register: (handler: UserDeletionHandler) => void
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
}
```
装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(idempotentStripe sub 已 cancel 的再 cancel 是 no-opdeletedAt 已设置的再 update 是 no-op)。
## 加新业务模块的步骤
1. 在该 service 加 `async deleteAllForUser(userId: string)` 方法
2.`app.ts``userDeletionService` build 里加一行 `service.register({...})`
3. 完成
不需要:写新文件、改 service 接口、改 auth.ts、改 types.ts。
## 各业务 service 的 deleteAllForUser
| Service | priority | 内容 | 依赖 |
|---|---|---|---|
| **stripeService** | 10 | (1) 查 stripeSubscription where userId=? and status=active(2) Stripe API `subscriptions.cancel(id, { prorate: false })`(3) 4 张 `stripe_*` 表 update deletedAt=now() | DB, Stripe SDK (optional) |
| **fluxService** | 20 | (1) `update userFlux set deletedAt=now() where userId=?`(2) `redis del flux:balance:{userId}`(3) **不动** flux_transaction(账本审计) | DB, Redis |
| **providerService** | 30 | `update userProviderConfigs set deletedAt=now() where ownerId=?` | DB |
| **characterService** | 30 | (1) `character set deletedAt=now() where ownerId=? or creatorId=?`(2) `characterLikes/Bookmarks set deletedAt=now() where userId=?` | DB |
| **chatService** | 30 | 按 `chat.type` 分支:① `private`/`bot` 整 chat soft-delete + 该 user 发的 message soft-delete;② `group`/`channel` 只硬删该 user 的 `chat_members` 行,**user 发的 message 保留**给其他 member 维持对话上下文(sender 通过"user 行 hard-delete + senderId bare text 无 FK"自然匿名化,UI 拿 senderId lookup 不到 user 时渲染为 "Deleted User" | DB |
| llm_request_log | 不参与 | 独立 retention job 处理 | — |
## 业务查询的软删过滤
**所有读业务表的查询都必须加 `isNull(deletedAt)` 过滤**,否则被删用户的数据还能被列出来 / 关联出来。重点扫描:
- `apps/server/src/services/flux.ts` — getBalance / readBalance
- `apps/server/src/services/characters.ts` — listCharacters
- `apps/server/src/services/providers.ts` — listProviderConfigs
- `apps/server/src/services/chats.ts` — listChats / listMessages
- `apps/server/src/services/billing/billing-service.ts` — invoice / sub 查询
写完后用 `pnpm typecheck` + grep `from(flux|character|chats|providers|stripe)` 兜底。
## Failure 模型
| 阶段失败 | 行为 | 后果 |
|---|---|---|
| sendDeleteAccountVerification | better-auth 抛 500 | 用户重试 |
| token 验失败/过期 | better-auth 返 404 | 用户重新发起 |
| Stripe handler 抛错 | 整个 beforeDelete 中止 → user 不删 | DB 状态保持原样,Stripe sub 状态可能已 cancel(罕见),下次重试 idempotent |
| Flux/其他 handler 抛错 | 同上中止 → user 不删 | 已经 cancel 的 Stripe sub 不会回滚(Stripe API 不支持 un-cancel),用户得重新订阅。**记录到 deletion_failure_log**telemetry / sentry alert |
| user 真删后 afterDelete 抛错 | user 已删,session 已 revoke,已无法回滚 | 仅 log,不影响用户体验 |
**没有补偿事务**。Multi-step soft-delete 失败的处置策略是:失败即中止,依赖 idempotency 让重试干净。
## Idempotency
- better-auth 的 verification token 一次性消费(`deleteVerificationByIdentifier`),点链接两次第二次会 404
- handler 全部用 `update where deletedAt is null` 守卫,重跑无副作用
- Stripe `subscriptions.cancel` 对已 cancel 的 sub 返回 200idempotent by spec
## 群聊匿名化("Deleted User"
群聊场景下 `messages.senderId` 故意是 **bare `text` 列没有 FK**,所以:
- better-auth hard-delete `user` 行后,`messages.senderId='abc123'` 字符串还在,但 `select * from "user" where id='abc123'` 空集
- name / email / avatar 全部跟 user 行一起没了
- senderId 还能 group by(同一 user 发的 message 仍可识别为同一来源),但**反查不到任何 PII**
- UI 路径:渲染 message sender 时 user lookup miss → 显示 "Deleted User" / "[已注销]"
**chatService.deleteAllForUser 不需要主动改 senderId**schema "bare text + 无 FK + auth user 行 hard-delete" 这三件事联合产出匿名化效果。
## 第三方 OAuth provider 端
better-auth `internalAdapter.deleteAccounts` 删本地 `account` 表(user 跟 google/github 登录方式的关联),oauth_* 表通过 FK cascade 删干净。**第三方 OAuth provider 那边的 grant 不主动撤销** —— 跟 Stripe / Slack / Discord 等业界默认一致。User 真要彻底清,应该去 OAuth provider 自己的 dashboard(如 google.com/security)撤。
如果未来出现严格 GDPR 需求,可以加 best-effort 调 Google `/o/oauth2/revoke?token=...` —— 但需要保留 refresh token,且 endpoint 本身就是 best-effort。
## 不做项 (v1)
- ❌ 软删 → hard delete reaper job(业务表保留无限期,等首次清理需求驱动;llm_request_log 已有独立 retention
- ❌ 误删恢复(用户认知中删除即终态;UI 必须文案警示)
- ❌ Stripe / Flux 退款(条款里写明,后续按需补)
- ❌ 删除事件外发 Webhook / Slack 通知(用 telemetry 替代)
- ❌ Admin 手动触发 delete(后续 admin panel 任务)
- ❌ 主动撤销第三方 OAuth provider 端的 grant(业界默认不做,user 自助撤)
## 相关代码索引
- 业务表 schema: `apps/server/src/schemas/{flux,flux-transaction,stripe,characters,user-character,providers,chats}.ts`
- Auth schema (不改): `apps/server/src/schemas/accounts.ts`
- Auth 配置: `apps/server/src/libs/auth.ts` (extend with `user.deleteUser`)
- Email service: `apps/server/src/services/email.ts` (extend interface + Resend impl)
- Deletion scheduler: `apps/server/src/services/user-deletion/` (registry only, no domain logic)
- 各 service 自己的 `deleteAllForUser`: `apps/server/src/services/{characters,chats,flux,providers,stripe}.ts`
- UI - settings page: `packages/stage-pages/src/pages/settings/account/account-settings-page.vue` (line ~430 TODO)
- UI - confirmation page (新): `apps/ui-server-auth/src/pages/delete-account.vue`
- i18n: `packages/i18n/src/locales/{en,zh}/settings/account.yaml`
## Verification
实测路径见 `docs/ai/context/verifications/account-deletion.md`(待补)。
最小路径:
1. 注册 user A
2. 创建一个 character,给 5 flux,订阅 active submock Stripe
3. UI 点 Delete → 收邮件 → 点链接
4. 验证:
- `select * from "user" where email='A'`
- `select * from session where user_id='A'`
- `select * from user_flux where user_id='A'` deleted_at 非空
- `select * from character where owner_id='A'` deleted_at 非空
- `select * from stripe_subscription where user_id='A'` deleted_at 非空,Stripe API 端 sub status=canceled
- `select * from flux_transaction where user_id='A'` 仍存在(账本审计)
5. 重新用 email A 注册成功(unique 约束已释放)
@@ -0,0 +1,131 @@
# Verification: account deletion
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: rbxin2003@gmail.com
## Live trace (2026-04-28)
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:
- 5 handlers run in registration order, ascending priority (stripe → flux → providers → characters → chats).
- Total handler time ~3.1s (mostly Stripe: 1.3s for the network round-trip).
- Verification token consumed exactly once; the callback redirected (302) to the success page.
- `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.
## Known gotcha — UI dist staleness
`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.
## What is verified
| 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 |
## What is **not** verified yet (action items)
### Path A — Migration applies cleanly to live DB
**Command (run by user):**
```sh
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.
### Path B — Server boots with deletion service wired
**Command:**
```sh
pnpm -F @proj-airi/server dev
```
**Expected log lines:**
- `injeca` resolves `services:userDeletion` without error
- `services: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`.
### Path C — End-to-end deletion flow (UI → email → DB)
**Setup:**
1. Server up (Path B), UI up (`pnpm -F @proj-airi/stage-web dev` with `VITE_SERVER_URL=http://localhost:3000`)
2. `RESEND_API_KEY` valid, use `rbxin2003+delete@outlook.com` (bare address suppressed — see Resend memory)
3. Pre-populate the user with non-trivial data so soft-delete has something to mark:
- Register user
- Have flux balance > 0 (initial grant covers this)
- Connect a provider via settings UI
- Create one character
- (Optional) Set up a Stripe test sub via the billing flow
**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 |
### Path D — Failure-mode smoke
**Setup:** kill Postgres mid-deletion (or simulate by wrapping a handler to throw)
**Expected:**
- `user-deletion` log: `handler failed; aborting deletion pipeline`
- `user` row still present (better-auth never reaches `internalAdapter.deleteUser`)
- soft-deleted rows from earlier handlers REMAIN soft-deleted (no rollback) — by design
- User can retry the deletion flow; idempotent handlers re-mark already-stamped rows as no-ops
## Known gaps
1. **No retry UI:** if Path D triggers, the user sees the API JSON error from `/delete-user/callback` instead of a friendly page. Acceptable for v1 (rare path); future hook can redirect to `/auth/delete-account?error=...`.
2. **No admin-triggered deletion:** the `reason: 'admin'` enum exists but no caller. Wire up in admin panel later.
3. **No rate limiting on `/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.
4. **Translations:** EN-only for new i18n keys (`server.auth.deleteAccount.*`, `settings.pages.account.danger.deleteAccount.modal.*`, `.message.emailSent`, `.error.fallback`). Other locales fall back to EN until translated.
5. **`flux_transaction` retention:** ledger now contains `user_id` strings that point to deleted users. No retention policy yet — open question for finance/legal.
## Re-verification cadence
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.
@@ -0,0 +1,29 @@
ALTER TABLE "characters" DROP CONSTRAINT "characters_creator_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "characters" DROP CONSTRAINT "characters_owner_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "flux_transaction" DROP CONSTRAINT "flux_transaction_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "user_flux" DROP CONSTRAINT "user_flux_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "user_provider_configs" DROP CONSTRAINT "user_provider_configs_owner_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "stripe_checkout_session" DROP CONSTRAINT "stripe_checkout_session_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "stripe_customer" DROP CONSTRAINT "stripe_customer_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "stripe_invoice" DROP CONSTRAINT "stripe_invoice_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "stripe_subscription" DROP CONSTRAINT "stripe_subscription_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "user_character_bookmarks" DROP CONSTRAINT "user_character_bookmarks_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "user_character_likes" DROP CONSTRAINT "user_character_likes_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "user_flux" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "stripe_checkout_session" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "stripe_customer" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "stripe_invoice" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "stripe_subscription" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "user_character_bookmarks" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "user_character_likes" ADD COLUMN "deleted_at" timestamp;
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -64,6 +64,13 @@
"when": 1775032828818,
"tag": "0008_gray_xavin",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1777370103031,
"tag": "0009_perpetual_lilandra",
"breakpoints": true
}
]
}
}
+1
View File
@@ -65,6 +65,7 @@ function createTestDeps() {
API_SERVER_URL: 'http://localhost:3000',
} as any,
otel: null,
userDeletionService: {} as any,
}
return {
+56 -20
View File
@@ -15,10 +15,13 @@ import type { FluxService } from './services/flux'
import type { FluxTransactionService } from './services/flux-transaction'
import type { ProviderService } from './services/providers'
import type { StripeService } from './services/stripe'
import type { UserDeletionService } from './services/user-deletion'
import type { HonoEnv } from './types/hono'
import process from 'node:process'
import Stripe from 'stripe'
import { initLogger, LoggerFormat, LoggerLevel, setGlobalHookPostLog, useLogger } from '@guiiai/logg'
import { serve } from '@hono/node-server'
import { createNodeWebSocket } from '@hono/node-ws'
@@ -57,6 +60,7 @@ import { createFluxTransactionService } from './services/flux-transaction'
import { createProviderService } from './services/providers'
import { createRequestLogService } from './services/request-log'
import { createStripeService } from './services/stripe'
import { createUserDeletionService } from './services/user-deletion'
import { ApiError, createInternalError, createUnauthorizedError } from './utils/error'
import { getTrustedOrigin } from './utils/origin'
@@ -76,6 +80,7 @@ interface AppDeps {
redis: Redis
env: Env
otel: OtelInstance | null
userDeletionService: UserDeletionService
}
export async function buildApp(deps: AppDeps) {
@@ -325,24 +330,6 @@ export async function createApp() {
}),
})
const auth = injeca.provide('services:auth', {
dependsOn: { db, env: parsedEnv, otel, email: emailService },
build: async ({ dependsOn }) => {
// Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied
await seedTrustedClients(dependsOn.db, dependsOn.env)
const trustedClients = getTrustedClientSeedSummaries(dependsOn.env)
logger.withField('apiServerUrl', dependsOn.env.API_SERVER_URL).log('OIDC startup configuration')
for (const client of trustedClients) {
logger.withFields({
clientId: client.clientId,
clientName: client.name,
redirectUris: client.redirectUris.join(', '),
}).log('OIDC trusted client ready')
}
return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth)
},
})
const characterService = injeca.provide('services:characters', {
dependsOn: { db, otel },
build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement),
@@ -359,8 +346,14 @@ export async function createApp() {
})
const stripeService = injeca.provide('services:stripe', {
dependsOn: { db },
build: ({ dependsOn }) => createStripeService(dependsOn.db),
dependsOn: { db, env: parsedEnv },
build: ({ dependsOn }) => {
// Stripe SDK is optional — when STRIPE_SECRET_KEY is unset (dev/CI)
// billing routes degrade gracefully and the user-deletion pipeline
// skips the API cancel call.
const stripe = dependsOn.env.STRIPE_SECRET_KEY ? new Stripe(dependsOn.env.STRIPE_SECRET_KEY) : null
return createStripeService(dependsOn.db, stripe)
},
})
const fluxTransactionService = injeca.provide('services:fluxTransaction', {
@@ -373,6 +366,47 @@ export async function createApp() {
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV),
})
// NOTICE:
// The deletion service is a thin scheduler that delegates to each business
// service's own `deleteAllForUser` method. Adding a new business module:
// 1. give it a `deleteAllForUser(userId)` method
// 2. add one `service.register(...)` line below
// Domain knowledge stays inside each service instead of being copied into
// a parallel handler file. See `apps/server/docs/ai-context/account-deletion.md`.
const userDeletionService = injeca.provide('services:userDeletion', {
dependsOn: { stripeService, fluxService, providerService, characterService, chatService },
build: ({ dependsOn }) => {
const service = createUserDeletionService()
// priority: 10 = external side-effects (Stripe API cancel — unrollable),
// 20 = financial / cache state (Flux balance + Redis),
// 30 = pure DB soft-delete (no external touch).
service.register({ name: 'stripe', priority: 10, softDelete: ({ userId }) => dependsOn.stripeService.deleteAllForUser(userId) })
service.register({ name: 'flux', priority: 20, softDelete: ({ userId }) => dependsOn.fluxService.deleteAllForUser(userId) })
service.register({ name: 'providers', priority: 30, softDelete: ({ userId }) => dependsOn.providerService.deleteAllForUser(userId) })
service.register({ name: 'characters', priority: 30, softDelete: ({ userId }) => dependsOn.characterService.deleteAllForUser(userId) })
service.register({ name: 'chats', priority: 30, softDelete: ({ userId }) => dependsOn.chatService.deleteAllForUser(userId) })
return service
},
})
const auth = injeca.provide('services:auth', {
dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService },
build: async ({ dependsOn }) => {
// Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied
await seedTrustedClients(dependsOn.db, dependsOn.env)
const trustedClients = getTrustedClientSeedSummaries(dependsOn.env)
logger.withField('apiServerUrl', dependsOn.env.API_SERVER_URL).log('OIDC startup configuration')
for (const client of trustedClients) {
logger.withFields({
clientId: client.clientId,
clientName: client.name,
redirectUris: client.redirectUris.join(', '),
}).log('OIDC trusted client ready')
}
return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService)
},
})
const requestLogService = injeca.provide('services:requestLog', {
dependsOn: { db },
build: ({ dependsOn }) => createRequestLogService(dependsOn.db),
@@ -419,6 +453,7 @@ export async function createApp() {
redis,
env: parsedEnv,
otel,
userDeletionService,
})
const { app, injectWebSocket } = await buildApp({
auth: resolved.auth,
@@ -436,6 +471,7 @@ export async function createApp() {
redis: resolved.redis,
env: resolved.env,
otel: resolved.otel,
userDeletionService: resolved.userDeletionService,
})
logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started')
+54 -1
View File
@@ -1,4 +1,5 @@
import type { EmailService } from '../services/email'
import type { UserDeletionService } from '../services/user-deletion'
import type { Database } from './db'
import type { Env } from './env'
import type { AuthMetrics } from './otel'
@@ -333,7 +334,32 @@ function requireEmailService(email: EmailService | undefined): EmailService {
return email
}
export function createAuth(db: Database, env: Env, email?: EmailService, metrics?: AuthMetrics | null) {
/**
* NOTICE:
* `userDeletionService` is optional for the same reason `email` is the
* `auth:generate` schema introspection path constructs `createAuth` without
* a real DI graph and never exercises `user.deleteUser`. The runtime path
* always supplies it from `app.ts`, and the `beforeDelete` callback throws
* if it's missing so silent no-ops are impossible.
*/
function requireUserDeletionService(service: UserDeletionService | undefined): UserDeletionService {
if (!service) {
throw new ApiError(
503,
'user-deletion/service_not_configured',
'User deletion service not available in this server context.',
)
}
return service
}
export function createAuth(
db: Database,
env: Env,
email?: EmailService,
metrics?: AuthMetrics | null,
userDeletionService?: UserDeletionService,
) {
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
@@ -452,6 +478,33 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
})
},
},
// NOTICE:
// Two-step deletion: POST /api/auth/delete-user with an authenticated
// session triggers `sendDeleteAccountVerification`; clicking the link
// hits GET /api/auth/delete-user/callback?token=..., which validates
// and calls `beforeDelete` BEFORE `internalAdapter.deleteUser`. Throw
// from `beforeDelete` to abort: the user row stays put, the
// verification token has already been consumed (single-use) so the
// user must re-initiate. Soft-delete handlers must be idempotent
// because retrying a partial deletion re-runs already-completed
// handlers as no-ops.
// Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-380
// Design: apps/server/docs/ai-context/account-deletion.md
deleteUser: {
enabled: true,
async sendDeleteAccountVerification({ user, url }) {
await requireEmailService(email).sendDeleteAccountVerification({
to: user.email,
url,
})
},
async beforeDelete(user) {
await requireUserDeletionService(userDeletionService).softDeleteAll({
userId: user.id,
reason: 'user-requested',
})
},
},
},
session: {
@@ -99,6 +99,7 @@ function createCheckoutSession(overrides: Partial<StripeCheckoutSession> = {}):
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
}
}
@@ -123,6 +124,7 @@ function createInvoice(overrides: Partial<StripeInvoice> = {}): StripeInvoice {
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
}
}
+6 -2
View File
@@ -19,8 +19,12 @@ export const character = pgTable(
// TODO: json patch?
creatorId: text('creator_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
// NOTICE: bare creatorId / ownerId is intentional — no FK to user.id.
// better-auth hard-deletes the user row; a cascade would wipe these
// soft-delete archive rows.
// See `apps/server/docs/ai-context/account-deletion.md`.
creatorId: text('creator_id').notNull(),
ownerId: text('owner_id').notNull(),
characterId: text('character_id').notNull(),
avatarUrl: text('avatar_url'),
creatorRole: text('creator_role'),
+5 -2
View File
@@ -2,11 +2,14 @@ import { sql } from 'drizzle-orm'
import { bigint, index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
import { user } from './accounts'
// NOTICE: ledger is permanent — bare userId (no FK) and no `deletedAt` column,
// both intentional. Entries must outlive the user row, and better-auth's
// hard-delete of user.id must not cascade-wipe the ledger.
// See `apps/server/docs/ai-context/account-deletion.md`.
export const fluxTransaction = pgTable('flux_transaction', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
type: text('type').notNull(), // 'credit' | 'debit' | 'initial'
amount: bigint('amount', { mode: 'number' }).notNull(), // always positive
balanceBefore: bigint('balance_before', { mode: 'number' }).notNull(),
+5 -3
View File
@@ -1,10 +1,12 @@
import { bigint, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { user } from './accounts'
// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows.
// See `apps/server/docs/ai-context/account-deletion.md`.
export const userFlux = pgTable('user_flux', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').primaryKey(),
flux: bigint('flux', { mode: 'number' }).notNull().default(0),
stripeCustomerId: text('stripe_customer_id'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
+4 -1
View File
@@ -6,11 +6,14 @@ import { boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
import { user } from './accounts'
// NOTICE: bare ownerId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows.
// See `apps/server/docs/ai-context/account-deletion.md`.
export const userProviderConfigs = pgTable(
'user_provider_configs',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ownerId: text('owner_id').notNull(),
definitionId: text('definition_id').notNull(),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
+13 -4
View File
@@ -6,17 +6,23 @@ import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
import { user } from './accounts'
// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows kept for
// audit / billing review.
// See `apps/server/docs/ai-context/account-deletion.md`.
/**
* Stripe customers linked to our users.
*/
export const stripeCustomer = pgTable('stripe_customer', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
stripeCustomerId: text('stripe_customer_id').notNull().unique(),
email: text('email'),
name: text('name'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
/**
@@ -24,7 +30,7 @@ export const stripeCustomer = pgTable('stripe_customer', {
*/
export const stripeCheckoutSession = pgTable('stripe_checkout_session', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
stripeSessionId: text('stripe_session_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup'
@@ -41,6 +47,7 @@ export const stripeCheckoutSession = pgTable('stripe_checkout_session', {
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
/**
@@ -48,7 +55,7 @@ export const stripeCheckoutSession = pgTable('stripe_checkout_session', {
*/
export const stripeSubscription = pgTable('stripe_subscription', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id').notNull(),
stripePriceId: text('stripe_price_id'),
@@ -61,6 +68,7 @@ export const stripeSubscription = pgTable('stripe_subscription', {
metadata: text('metadata'), // JSON stringified
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
/**
@@ -68,7 +76,7 @@ export const stripeSubscription = pgTable('stripe_subscription', {
*/
export const stripeInvoice = pgTable('stripe_invoice', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
stripeInvoiceId: text('stripe_invoice_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
@@ -85,6 +93,7 @@ export const stripeInvoice = pgTable('stripe_invoice', {
metadata: text('metadata'), // JSON stringified
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
// ---------- Relations ----------
+7 -2
View File
@@ -6,12 +6,16 @@ import { relations } from 'drizzle-orm/relations'
import { user } from './accounts'
import { character } from './characters'
// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows.
// See `apps/server/docs/ai-context/account-deletion.md`.
export const characterLikes = pgTable(
'user_character_likes',
{
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
table => [
primaryKey({ columns: [table.userId, table.characterId] }),
@@ -24,9 +28,10 @@ export type NewCharacterLike = InferInsertModel<typeof characterLikes>
export const characterBookmarks = pgTable(
'user_character_bookmarks',
{
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
table => [
primaryKey({ columns: [table.userId, table.characterId] }),
+51 -1
View File
@@ -2,7 +2,7 @@ import type { Database } from '../libs/db'
import type { EngagementMetrics } from '../libs/otel'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { and, eq, isNull, or, sql } from 'drizzle-orm'
import * as schema from '../schemas/characters'
import * as userCharacterSchema from '../schemas/user-character'
@@ -219,6 +219,56 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics
}
return result
},
/**
* Soft-delete every character owned or created by the user, plus their
* likes and bookmarks. Called from the user-deletion pipeline.
*
* Marks `creatorId === userId` rows too fork attribution is tied to the
* creator's identity, so removing the creator soft-archives the lineage
* even if the current owner is someone else.
*
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
const charRows = await db.update(schema.character)
.set({ deletedAt: now, updatedAt: now })
.where(and(
or(
eq(schema.character.ownerId, userId),
eq(schema.character.creatorId, userId),
),
isNull(schema.character.deletedAt),
))
.returning({ id: schema.character.id })
const likeRows = await db.update(userCharacterSchema.characterLikes)
.set({ deletedAt: now })
.where(and(
eq(userCharacterSchema.characterLikes.userId, userId),
isNull(userCharacterSchema.characterLikes.deletedAt),
))
.returning({ characterId: userCharacterSchema.characterLikes.characterId })
const bookmarkRows = await db.update(userCharacterSchema.characterBookmarks)
.set({ deletedAt: now })
.where(and(
eq(userCharacterSchema.characterBookmarks.userId, userId),
isNull(userCharacterSchema.characterBookmarks.deletedAt),
))
.returning({ characterId: userCharacterSchema.characterBookmarks.characterId })
logger
.withFields({
userId,
characters: charRows.length,
likes: likeRows.length,
bookmarks: bookmarkRows.length,
})
.log('Characters / likes / bookmarks soft-deleted for user')
},
}
}
+105
View File
@@ -305,6 +305,111 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
return { seq: result.seq, fromSeq: result.fromSeq, toSeq: result.toSeq }
},
/**
* Soft-delete the user's footprint in chats. Per-chat strategy depends
* on `chat.type`:
*
* - `private` / `bot` (1-on-1, user IS the chat): soft-delete the chat
* row + the user's messages. Nothing else can read those messages
* (chat is gone), so soft-deleting them is just keeping audit consistent.
*
* - `group` / `channel` (shared): drop only this user's `chat_members`
* row; the chat + other members survive. The user's messages are
* **kept intact** deleting them would corrupt the conversation
* context for remaining members ("B replied to nothing"). Sender
* anonymization is automatic: `messages.senderId` is bare text with
* no FK, so after better-auth hard-deletes the user row, the senderId
* string still groups the user's messages together but cannot be
* joined to any PII (name / email are gone with the user row). The
* UI is expected to render `senderId` whose user lookup misses as
* "Deleted User".
*
* `chat_members` rows for shared chats are **hard-deleted** because the
* table was designed without a `deletedAt` column; auditing who was in
* which chat is preserved through `messages.senderId` for the messages
* the user actually authored.
*
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows on
* retry; re-deleting an already-removed `chat_members` row is a no-op.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
// Join chat_members → chats so we can branch by chat.type without a
// second round-trip per row.
const memberChats = await db
.select({ chatId: schema.chatMembers.chatId, chatType: schema.chats.type })
.from(schema.chatMembers)
.innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id))
.where(eq(schema.chatMembers.userId, userId))
const soloChatIds = memberChats
.filter(r => r.chatType === 'private' || r.chatType === 'bot')
.map(r => r.chatId)
const sharedChatIds = memberChats
.filter(r => r.chatType === 'group' || r.chatType === 'channel')
.map(r => r.chatId)
let soloChatCount = 0
let droppedMemberships = 0
let soloMessageCount = 0
let preservedSharedMessages = 0
if (soloChatIds.length > 0) {
const updatedChats = await db.update(schema.chats)
.set({ deletedAt: now, updatedAt: now })
.where(and(
inArray(schema.chats.id, soloChatIds),
isNull(schema.chats.deletedAt),
))
.returning({ id: schema.chats.id })
soloChatCount = updatedChats.length
// Soft-delete user-authored messages in solo chats only. The chat
// itself is gone, so this is purely audit/consistency hygiene.
const updatedMessages = await db.update(schema.messages)
.set({ deletedAt: now, updatedAt: now })
.where(and(
inArray(schema.messages.chatId, soloChatIds),
eq(schema.messages.senderId, userId),
isNull(schema.messages.deletedAt),
))
.returning({ id: schema.messages.id })
soloMessageCount = updatedMessages.length
}
if (sharedChatIds.length > 0) {
const dropped = await db.delete(schema.chatMembers)
.where(and(
inArray(schema.chatMembers.chatId, sharedChatIds),
eq(schema.chatMembers.userId, userId),
))
.returning({ id: schema.chatMembers.id })
droppedMemberships = dropped.length
// Count (do not mutate) the user's messages in shared chats to make
// the preservation visible in logs. These rows stay live so other
// members keep their conversation context; sender anonymizes itself
// once better-auth hard-deletes the user row.
const kept = await db.select({ id: schema.messages.id })
.from(schema.messages)
.where(and(
inArray(schema.messages.chatId, sharedChatIds),
eq(schema.messages.senderId, userId),
isNull(schema.messages.deletedAt),
))
preservedSharedMessages = kept.length
}
logger.withFields({
userId,
soloChats: soloChatCount,
sharedChatMembershipsDropped: droppedMemberships,
soloMessages: soloMessageCount,
preservedSharedMessages,
}).log('Chats footprint processed for user (solo soft-deleted, shared anonymized)')
},
async pullMessages(userId: string, chatId: string, afterSeq: number, limit?: number) {
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
+43
View File
@@ -47,6 +47,17 @@ export interface EmailService {
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
/**
* Send the irreversible-action confirmation for `user.deleteUser` flow.
*
* Wired into better-auth's `user.deleteUser.sendDeleteAccountVerification`.
* The link expires per `deleteTokenExpiresIn` (default 24h) and is
* single-use; clicking it triggers `beforeDelete` soft-delete handlers
* hard-delete user.
*
* Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-300.
*/
sendDeleteAccountVerification: (params: { to: string, url: string }) => Promise<void>
}
interface EmailConfig {
@@ -166,6 +177,14 @@ export function createEmailService(config: EmailConfig, logger: Logger = useLogg
text: renderChangeEmailText(url, newEmail),
})
},
async sendDeleteAccountVerification({ to, url }) {
await send({
to,
subject: 'Confirm account deletion for Project AIRI',
html: renderDeleteAccountHtml(url),
text: renderDeleteAccountText(url),
})
},
}
}
@@ -274,3 +293,27 @@ function renderChangeEmailText(url: string, newEmail: string): string {
footer: 'If you did not request this change, contact support immediately.',
})
}
// NOTICE:
// Wording is intentionally short and direct. Account deletion hard-deletes
// the auth identity (cascade) and soft-archives business records; the user
// cannot recover the account through the UI.
// See `apps/server/docs/ai-context/account-deletion.md`.
function renderDeleteAccountHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Confirm account deletion',
body: 'Click below to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
ctaLabel: 'Delete my account',
url,
footer: 'Did not request this? Ignore this email and rotate your password.',
})
}
function renderDeleteAccountText(url: string): string {
return renderActionEmailText({
heading: 'Confirm account deletion',
body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
url,
footer: 'Did not request this? Ignore this email and rotate your password.',
})
}
+47 -4
View File
@@ -4,7 +4,7 @@ import type { Database } from '../libs/db'
import type { ConfigKVService } from './config-kv'
import { useLogger } from '@guiiai/logg'
import { eq } from 'drizzle-orm'
import { and, eq, isNull } from 'drizzle-orm'
import { userFluxRedisKey } from '../utils/redis-keys'
@@ -13,6 +13,11 @@ import * as fluxTxSchema from '../schemas/flux-transaction'
const logger = useLogger('flux-service')
// NOTICE:
// All read paths here treat soft-deleted rows (`deletedAt IS NOT NULL`) as
// invisible. After account deletion the auth tables hard-delete the user
// so this filter is mostly defense-in-depth against routes that bypass
// `sessionMiddleware`. See `apps/server/docs/ai-context/account-deletion.md`.
export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) {
return {
async getFlux(userId: string) {
@@ -24,7 +29,10 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
// 2. Cache miss — load from DB
let record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, userId),
where: and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
),
})
if (!record) {
@@ -52,7 +60,10 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
// Re-read to handle race condition (another request may have initialized first)
record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, userId),
where: and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
),
})
if (!record) {
@@ -74,11 +85,43 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
stripeCustomerId,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.where(and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
))
.returning()
return updated
},
/**
* Soft-delete the user's flux balance and drop the cached value from
* Redis. Does NOT touch `flux_transaction` that ledger is preserved
* across user deletion for billing audit (and the table has no
* `deletedAt` column by design).
*
* Idempotent: `WHERE deletedAt IS NULL` skips an already-stamped row,
* `redis.del` is a no-op when the key is absent.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
const result = await db.update(schema.userFlux)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
))
.returning({ flux: schema.userFlux.flux })
// Drop the cached balance so any in-flight read does not see a
// ghost balance for the soft-deleted user.
await redis.del(userFluxRedisKey(userId))
logger
.withFields({ userId, clearedFlux: result[0]?.flux ?? 0 })
.log('Flux balance soft-deleted and cache invalidated')
},
}
}
+21
View File
@@ -168,6 +168,27 @@ export function createProviderService(db: Database) {
logger.withFields({ id }).log('Deleted system provider config')
return result
},
/**
* Soft-delete every `user_provider_configs` row owned by the user.
* Called from the user-deletion pipeline. System configs are not
* touched (they are not user-scoped).
*
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
const result = await db.update(schema.userProviderConfigs)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.userProviderConfigs.ownerId, userId),
isNull(schema.userProviderConfigs.deletedAt),
))
.returning({ id: schema.userProviderConfigs.id })
logger.withFields({ userId, count: result.length }).log('Provider configs soft-deleted for user')
},
}
}
+110 -5
View File
@@ -1,14 +1,23 @@
import type Stripe from 'stripe'
import type { Database } from '../libs/db'
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe'
import { useLogger } from '@guiiai/logg'
import { and, eq } from 'drizzle-orm'
import { and, eq, isNull, notInArray } from 'drizzle-orm'
import * as schema from '../schemas/stripe'
const logger = useLogger('stripe-service')
export function createStripeService(db: Database) {
// NOTICE:
// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose
// stripe_* rows persist for billing audit) are invisible to user-facing
// API. Webhooks that arrive after deletion still match by stripeCustomerId
// and re-upsert into the soft-deleted row — that's by design (the row
// remains deletedAt-set, but we capture the late event for accurate audit).
// See `apps/server/docs/ai-context/account-deletion.md`.
export function createStripeService(db: Database, stripe: Stripe | null) {
return {
// ---- Customer ----
@@ -26,11 +35,19 @@ export function createStripeService(db: Database) {
async getCustomerByUserId(userId: string) {
return db.query.stripeCustomer.findFirst({
where: eq(schema.stripeCustomer.userId, userId),
where: and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
),
})
},
async getCustomerByStripeId(stripeCustomerId: string) {
// NOTICE: NOT filtering by deletedAt — this lookup is by external
// Stripe id and is used by webhook handlers that need to reach
// soft-deleted archive rows for late events (cancellation receipts,
// final invoices arriving after account deletion). User-facing reads
// use getCustomerByUserId which DOES filter.
return db.query.stripeCustomer.findFirst({
where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId),
})
@@ -52,7 +69,10 @@ export function createStripeService(db: Database) {
async getCheckoutSessionsByUserId(userId: string) {
return db.query.stripeCheckoutSession.findMany({
where: eq(schema.stripeCheckoutSession.userId, userId),
where: and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
@@ -76,6 +96,7 @@ export function createStripeService(db: Database) {
where: and(
eq(schema.stripeSubscription.userId, userId),
eq(schema.stripeSubscription.status, 'active'),
isNull(schema.stripeSubscription.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
@@ -97,10 +118,94 @@ export function createStripeService(db: Database) {
async getInvoicesByUserId(userId: string) {
return db.query.stripeInvoice.findMany({
where: eq(schema.stripeInvoice.userId, userId),
where: and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
/**
* Cancel the user's active Stripe subscription via the API and stamp every
* `stripe_*` row with `deletedAt`. Called from the user-deletion pipeline
* (priority 10 runs first because Stripe API cancellation has no
* rollback path).
*
* Idempotent on retry: subsequent calls find no `active` subs to cancel
* and the `WHERE deletedAt IS NULL` guard skips already-stamped rows.
* Stripe `subscriptions.cancel` itself is also idempotent per spec
* cancelling an already-canceled sub returns 200.
*
* Cancellation is immediate, no proration, no refund see
* `apps/server/docs/ai-context/account-deletion.md`.
*/
async deleteAllForUser(userId: string) {
// Cancel every subscription that is NOT already in a terminal state.
// Stripe's terminal statuses are `canceled` and `incomplete_expired`;
// anything else (`active`, `trialing`, `past_due`, `unpaid`,
// `incomplete`, `paused`) can still bill or transition into billing,
// so leaving them uncancelled would charge a deleted account.
// Stripe `subscriptions.cancel` is idempotent per spec — safe to
// call on any non-terminal status.
const cancellableSubs = await db.query.stripeSubscription.findMany({
where: and(
eq(schema.stripeSubscription.userId, userId),
notInArray(schema.stripeSubscription.status, ['canceled', 'incomplete_expired']),
isNull(schema.stripeSubscription.deletedAt),
),
})
if (stripe && cancellableSubs.length > 0) {
for (const sub of cancellableSubs) {
try {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId, {
prorate: false,
})
logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription')
}
catch (err) {
logger.withError(err).withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).error('Failed to cancel Stripe subscription')
throw err
}
}
}
else if (!stripe && cancellableSubs.length > 0) {
logger.withFields({ userId, cancellableSubCount: cancellableSubs.length }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted')
}
const now = new Date()
await db.update(schema.stripeSubscription)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeSubscription.userId, userId),
isNull(schema.stripeSubscription.deletedAt),
))
await db.update(schema.stripeCheckoutSession)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
))
await db.update(schema.stripeInvoice)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
))
await db.update(schema.stripeCustomer)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
))
logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user')
},
}
}
@@ -22,7 +22,7 @@ describe('stripeService', () => {
})
beforeEach(async () => {
stripeService = createStripeService(db)
stripeService = createStripeService(db, null)
// Clean all stripe tables between tests
await db.delete(schema.stripeInvoice)
@@ -0,0 +1,121 @@
import type { UserDeletionHandler } from './types'
import { describe, expect, it, vi } from 'vitest'
import { createUserDeletionService } from './index'
function makeHandler(name: string, priority: number, body?: () => Promise<void> | void): UserDeletionHandler {
return {
name,
priority,
softDelete: vi.fn(async () => {
await body?.()
}),
}
}
describe('createUserDeletionService', () => {
describe('register', () => {
it('rejects duplicate handler names', () => {
const service = createUserDeletionService()
service.register(makeHandler('flux', 20))
expect(() => service.register(makeHandler('flux', 30))).toThrow(/Duplicate user-deletion handler name: flux/)
})
it('keeps handlers in ascending priority regardless of registration order', async () => {
const service = createUserDeletionService()
const calls: string[] = []
service.register(makeHandler('characters', 30, () => {
calls.push('characters')
}))
service.register(makeHandler('stripe', 10, () => {
calls.push('stripe')
}))
service.register(makeHandler('flux', 20, () => {
calls.push('flux')
}))
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// register order: characters(30) -> stripe(10) -> flux(20)
// execution order: stripe(10) -> flux(20) -> characters(30)
expect(calls).toEqual(['stripe', 'flux', 'characters'])
})
})
describe('softDeleteAll', () => {
it('passes the user id and reason to every handler', async () => {
const service = createUserDeletionService()
const a = makeHandler('a', 10)
const b = makeHandler('b', 20)
service.register(a)
service.register(b)
await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' })
expect(a.softDelete).toHaveBeenCalledTimes(1)
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
expect(b.softDelete).toHaveBeenCalledTimes(1)
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
})
it('aborts on first handler error and skips later handlers', async () => {
const service = createUserDeletionService()
const earlyOk = makeHandler('a', 10)
const failing = makeHandler('b', 20, () => {
throw new Error('stripe API down')
})
const lateNeverRuns = makeHandler('c', 30)
service.register(earlyOk)
service.register(failing)
service.register(lateNeverRuns)
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }))
.rejects
.toThrow('stripe API down')
expect(earlyOk.softDelete).toHaveBeenCalledTimes(1)
expect(failing.softDelete).toHaveBeenCalledTimes(1)
expect(lateNeverRuns.softDelete).not.toHaveBeenCalled()
})
it('runs handlers serially (next starts only after previous resolves)', async () => {
const service = createUserDeletionService()
const order: string[] = []
service.register({
name: 'slow',
priority: 10,
softDelete: async () => {
order.push('slow:start')
await new Promise(r => setTimeout(r, 5))
order.push('slow:end')
},
})
service.register({
name: 'fast',
priority: 20,
softDelete: async () => {
order.push('fast:start')
order.push('fast:end')
},
})
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// serial execution: slow:start -> slow:end -> fast:start -> fast:end
// (NOT slow:start -> fast:start -> slow:end -> fast:end which would
// indicate parallelism)
expect(order).toEqual(['slow:start', 'slow:end', 'fast:start', 'fast:end'])
})
it('runs no handlers gracefully when registry is empty', async () => {
const service = createUserDeletionService()
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,77 @@
import type { UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
import { useLogger } from '@guiiai/logg'
export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
/**
* Build an empty deletion-service registry.
*
* Use when:
* - Composing the server in `app.ts` wire one instance and `register()`
* each business handler at composition time.
*
* Returns:
* - A registry whose `softDeleteAll` walks handlers in ascending `priority`
* and aborts on the first throw. Successful handlers are NOT rolled back
* each handler's writes must be idempotent.
*
* Call stack:
*
* better-auth `/delete-user/callback`
* -> `user.deleteUser.beforeDelete` (libs/auth.ts)
* -> {@link UserDeletionService.softDeleteAll}
* -> handler.softDelete (per registered module)
*
* Failure model: a thrown error from any handler aborts before
* `internalAdapter.deleteUser`, leaving the user row intact. The next retry
* is expected to re-run already-completed handlers as no-ops.
*/
export function createUserDeletionService(): UserDeletionService {
const handlers: UserDeletionHandler[] = []
const names = new Set<string>()
const logger = useLogger('user-deletion').useGlobalConfig()
return {
register(handler) {
if (names.has(handler.name))
throw new Error(`Duplicate user-deletion handler name: ${handler.name}`)
names.add(handler.name)
handlers.push(handler)
// Resort on every insert so post-boot registrations stay ordered.
handlers.sort((a, b) => a.priority - b.priority)
},
async softDeleteAll({ userId, reason }) {
const ctx = {
userId,
reason: reason as UserDeletionReason,
logger,
}
logger.withFields({ userId, reason, handlerCount: handlers.length }).log('starting user deletion')
for (const handler of handlers) {
const startedAt = Date.now()
try {
await handler.softDelete(ctx)
logger
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
.log('handler completed')
}
catch (err) {
logger
.withError(err)
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
.error('handler failed; aborting deletion pipeline')
throw err
}
}
logger.withFields({ userId, reason }).log('user deletion handlers completed')
},
}
}
@@ -0,0 +1,282 @@
import type { Database } from '../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createCharacterService } from '../characters'
import { createChatService } from '../chats'
import { createFluxService } from '../flux'
import { createProviderService } from '../providers'
import * as schema from '../../schemas'
function fakeRedis() {
const map = new Map<string, string>()
return {
get: vi.fn(async (k: string) => map.get(k) ?? null),
set: vi.fn(async (k: string, v: string) => {
map.set(k, v)
return 'OK'
}),
del: vi.fn(async (k: string) => {
const had = map.has(k)
map.delete(k)
return had ? 1 : 0
}),
} as any
}
function fakeConfigKV() {
return {
get: vi.fn(async () => undefined),
getOrThrow: vi.fn(async () => 0),
set: vi.fn(async () => {}),
} as any
}
describe('fluxService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('marks userFlux.deletedAt and invalidates Redis cache', async () => {
await db.insert(schema.user).values({ id: 'u-flux-1', name: 'A', email: 'a@example.com' })
await db.insert(schema.userFlux).values({ userId: 'u-flux-1', flux: 100 })
const redis = fakeRedis()
const service = createFluxService(db, redis, fakeConfigKV())
await service.deleteAllForUser('u-flux-1')
const row = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-1') })
expect(row?.deletedAt).toBeInstanceOf(Date)
expect(redis.del).toHaveBeenCalledTimes(1)
expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1'))
})
it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => {
await db.insert(schema.user).values({ id: 'u-flux-2', name: 'B', email: 'b@example.com' })
await db.insert(schema.userFlux).values({ userId: 'u-flux-2', flux: 50 })
const redis = fakeRedis()
const service = createFluxService(db, redis, fakeConfigKV())
await service.deleteAllForUser('u-flux-2')
const firstStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
// Second invocation: WHERE deletedAt IS NULL filters out the
// already-stamped row, so deletedAt does not change.
await service.deleteAllForUser('u-flux-2')
const secondStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
expect(secondStamp).toEqual(firstStamp)
})
})
describe('providerService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('marks every userProviderConfigs row owned by the user', async () => {
await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' })
await db.insert(schema.userProviderConfigs).values([
{ ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' },
{ ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' },
])
const service = createProviderService(db)
await service.deleteAllForUser('u-prov-1')
const rows = await db.query.userProviderConfigs.findMany({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-1') })
expect(rows).toHaveLength(2)
rows.forEach(r => expect(r.deletedAt).toBeInstanceOf(Date))
})
it('does not touch other users rows', async () => {
await db.insert(schema.user).values({ id: 'u-prov-other', name: 'O', email: 'o@example.com' })
await db.insert(schema.userProviderConfigs).values({ ownerId: 'u-prov-other', definitionId: 'openai', name: 'kept' })
const service = createProviderService(db)
await service.deleteAllForUser('u-prov-1')
const otherRow = await db.query.userProviderConfigs.findFirst({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-other') })
expect(otherRow?.deletedAt).toBeNull()
})
})
describe('characterService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('soft-deletes characters where the user is owner OR creator', async () => {
await db.insert(schema.user).values([
{ id: 'u-char-1', name: 'C1', email: 'c1@example.com' },
{ id: 'u-char-2', name: 'C2', email: 'c2@example.com' },
])
await db.insert(schema.character).values([
{ id: 'char-owner', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-1', characterId: 'cid-1' },
{ id: 'char-creator', version: '1', coverUrl: '', creatorId: 'u-char-1', ownerId: 'u-char-2', characterId: 'cid-2' },
{ id: 'char-other', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-2', characterId: 'cid-3' },
])
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-1')
const owner = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-owner') })
const creator = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-creator') })
const other = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-other') })
expect(owner?.deletedAt).toBeInstanceOf(Date)
expect(creator?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
it('soft-deletes the user likes and bookmarks', async () => {
await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' })
await db.insert(schema.character).values({
id: 'char-z',
version: '1',
coverUrl: '',
creatorId: 'u-char-3',
ownerId: 'u-char-3',
characterId: 'cid-z',
})
await db.insert(schema.characterLikes).values({ userId: 'u-char-3', characterId: 'char-z' })
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-3', characterId: 'char-z' })
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-3')
const like = await db.query.characterLikes.findFirst({ where: eq(schema.characterLikes.userId, 'u-char-3') })
const bookmark = await db.query.characterBookmarks.findFirst({ where: eq(schema.characterBookmarks.userId, 'u-char-3') })
expect(like?.deletedAt).toBeInstanceOf(Date)
expect(bookmark?.deletedAt).toBeInstanceOf(Date)
})
})
describe('chatService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('soft-deletes chats the user is a member of', async () => {
await db.insert(schema.user).values({ id: 'u-chat-1', name: 'C', email: 'chat@example.com' })
await db.insert(schema.chats).values([
{ id: 'chat-mine', type: 'private', title: 'mine' },
{ id: 'chat-other', type: 'private', title: 'other' },
])
await db.insert(schema.chatMembers).values({ chatId: 'chat-mine', memberType: 'user', userId: 'u-chat-1' })
const service = createChatService(db)
await service.deleteAllForUser('u-chat-1')
const mine = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-mine') })
const other = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-other') })
expect(mine?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
it('drops chat_members for shared (group/channel) chats but keeps the chat alive', async () => {
// Two users in a shared group chat. When user A is deleted, the chat
// row must survive for user B; only A's chat_members row goes.
await db.insert(schema.user).values([
{ id: 'u-grp-a', name: 'A', email: 'grpa@example.com' },
{ id: 'u-grp-b', name: 'B', email: 'grpb@example.com' },
])
await db.insert(schema.chats).values({ id: 'chat-grp', type: 'group', title: 'team' })
await db.insert(schema.chatMembers).values([
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-a' },
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-b' },
])
const service = createChatService(db)
await service.deleteAllForUser('u-grp-a')
const chatRow = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-grp') })
expect(chatRow?.deletedAt).toBeNull() // chat survives
const remainingMembers = await db.query.chatMembers.findMany({ where: eq(schema.chatMembers.chatId, 'chat-grp') })
expect(remainingMembers).toHaveLength(1)
expect(remainingMembers[0]?.userId).toBe('u-grp-b')
})
it('preserves the user messages inside group chats so other members keep conversation context', async () => {
// Anonymization-by-design: in a group chat, user A's messages must NOT
// be soft-deleted on account deletion — that would corrupt B's history.
// The senderId stays as the (now-orphan) user.id string; the UI renders
// it as "Deleted User" once it cannot resolve the id to a real user.
await db.insert(schema.user).values([
{ id: 'u-anon-a', name: 'A', email: 'anona@example.com' },
{ id: 'u-anon-b', name: 'B', email: 'anonb@example.com' },
])
await db.insert(schema.chats).values({ id: 'chat-anon-grp', type: 'group', title: 'team' })
await db.insert(schema.chatMembers).values([
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-a' },
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-b' },
])
await db.insert(schema.messages).values([
{ id: 'm-a-1', chatId: 'chat-anon-grp', senderId: 'u-anon-a', role: 'user', content: 'hi from A', mediaIds: [], stickerIds: [] },
{ id: 'm-b-1', chatId: 'chat-anon-grp', senderId: 'u-anon-b', role: 'user', content: 'hi from B', mediaIds: [], stickerIds: [] },
])
const service = createChatService(db)
await service.deleteAllForUser('u-anon-a')
// A's message stays alive; senderId still points at the now-orphan user.id string.
const aMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-a-1') })
expect(aMsg?.deletedAt).toBeNull()
expect(aMsg?.senderId).toBe('u-anon-a')
expect(aMsg?.content).toBe('hi from A')
// B's message obviously untouched.
const bMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-b-1') })
expect(bMsg?.deletedAt).toBeNull()
})
it('soft-deletes messages the user sent in private/bot chats', async () => {
await db.insert(schema.user).values({ id: 'u-chat-2', name: 'M', email: 'msg@example.com' })
await db.insert(schema.chats).values({ id: 'chat-msg', type: 'private', title: 't' })
await db.insert(schema.chatMembers).values({ chatId: 'chat-msg', memberType: 'user', userId: 'u-chat-2' })
await db.insert(schema.messages).values([
{
id: 'msg-mine',
chatId: 'chat-msg',
senderId: 'u-chat-2',
role: 'user',
content: 'hi',
mediaIds: [],
stickerIds: [],
},
{
id: 'msg-other',
chatId: 'chat-msg',
senderId: 'someone-else',
role: 'assistant',
content: 'hello',
mediaIds: [],
stickerIds: [],
},
])
const service = createChatService(db)
await service.deleteAllForUser('u-chat-2')
const mine = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-mine') })
const other = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-other') })
expect(mine?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
})
@@ -0,0 +1,89 @@
import type { Logger } from '@guiiai/logg'
/**
* Reason a user deletion is being requested. Recorded in logs and surfaced
* to handlers so they can branch (e.g. compliance erase vs. user-initiated).
*
* - `user-requested`: triggered by the user via better-auth `/delete-user/callback`.
* - `admin`: triggered by an admin tool (not yet implemented).
* - `compliance`: triggered by automated GDPR / data-retention workflow (not yet implemented).
*/
export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance'
/**
* Context passed to every {@link UserDeletionHandler} invocation.
*
* Use when:
* - Implementing a new business handler read `userId` to scope your soft-delete writes.
* - Logging within a handler use the provided `logger` so entries share the deletion correlation context.
*/
export interface UserDeletionContext {
/** The user being deleted. Handlers MUST scope their writes to this id. */
userId: string
/** Why the deletion was triggered. */
reason: UserDeletionReason
/** Pre-scoped logger for handler diagnostics. */
logger: Logger
}
/**
* A registered participant in the account-deletion pipeline.
*
* Each business module that owns user-scoped tables registers one of these
* with the {@link UserDeletionService}. Handlers run sequentially in
* ascending `priority` order; a thrown error aborts the whole pipeline so
* better-auth's hard-delete of the user row never runs (the user is left
* intact and the operation can be retried idempotently).
*
* @example
* createUserDeletionService().register({
* name: 'flux',
* priority: 20,
* async softDelete({ userId }) {
* await db.update(userFlux).set({ deletedAt: new Date() }).where(eq(userFlux.userId, userId))
* },
* })
*/
export interface UserDeletionHandler {
/** Stable identifier used for logs, metrics, and duplicate-registration checks. */
name: string
/**
* Lower runs first. Conventions:
* - 10: external side-effects without rollback (Stripe API cancel)
* - 20: financial / cache state (Flux balance, Redis invalidation)
* - 30: pure DB soft-delete (providers, characters, chats)
*
* @default 30
*/
priority: number
/**
* Mark business records as deleted. MUST be idempotent the deletion
* pipeline retries by re-issuing the entire request, and Stripe / Postgres
* already deduplicate on subsequent calls. Throw to abort the pipeline.
*/
softDelete: (ctx: UserDeletionContext) => Promise<void>
}
/**
* Coordinator for account deletion across business modules.
*
* Use when:
* - Wiring better-auth's `user.deleteUser.beforeDelete` hook in `libs/auth.ts`.
* - Implementing an admin-triggered deletion path (future).
*
* Expects:
* - All handlers are registered at app-composition time before the first
* request hits `beforeDelete`. Late registration is allowed but discouraged.
*/
export interface UserDeletionService {
/**
* Register a handler. Throws if `handler.name` is already registered
* names must be unique so logs and metrics can attribute work cleanly.
*/
register: (handler: UserDeletionHandler) => void
/**
* Run every registered handler in priority order. Returns when all
* handlers complete, or throws the first handler error and stops.
*/
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
// NOTICE:
// This page is a SUCCESS landing better-auth's `/api/auth/delete-user/callback`
// performs the actual deletion server-side, then redirects here via the
// `callbackURL` we passed to `authClient.deleteUser`. By the time the user
// sees this page their session is already revoked and the user row is gone.
// Source: node_modules/better-auth/dist/api/routes/update-user.mjs L380.
//
// No "back to home" button: this page lives on the API-server origin and has
// no reliable way to point at the calling app (stage-web / stage-tamagotchi /
// stage-pocket) the API server doesn't know where the product UI is
// deployed. Asking the user to close the tab is the simplest correct thing.
//
// Failure case (e.g. token expired, or `beforeDelete` handler threw): the
// server returns a JSON error response from `/delete-user/callback` instead
// of redirecting, so the user does not land here. Surfacing that gracefully
// is a follow-up for v1 we accept the raw API JSON in the error path.
const { t } = useI18n()
const route = useRoute()
// Optional ?error param if a future server hook redirects failed deletions
// here with an explanatory string. Today this is always undefined.
const errorMessage = computed(() => {
const value = route.query.error
return typeof value === 'string' ? value : null
})
</script>
<template>
<main
:class="[
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
]"
>
<div :class="['mb-6 text-2xl font-bold']">
{{
errorMessage
? t('server.auth.deleteAccount.title.failed')
: t('server.auth.deleteAccount.title.success')
}}
</div>
<div :class="['max-w-sm flex flex-col items-center gap-4 text-center text-sm']">
<p
v-if="!errorMessage"
:class="['text-neutral-600 dark:text-neutral-300']"
>
{{ t('server.auth.deleteAccount.message.success') }}
</p>
<p
v-else
:class="['text-red-500']"
>
{{ errorMessage }}
</p>
<p
v-if="!errorMessage"
:class="['text-xs text-neutral-400 dark:text-neutral-500']"
>
{{ t('server.auth.deleteAccount.action.closeTab') }}
</p>
</div>
</main>
</template>
<route lang="yaml">
meta:
layout: plain
</route>
@@ -93,6 +93,14 @@ forgotPassword:
sent: If {email} matches an account, a reset link is on the way.
error:
fallback: We could not send the reset email.
deleteAccount:
title:
success: Account deleted
failed: Could not delete account
message:
success: Your account has been deleted.
action:
closeTab: You can close this tab.
resetPassword:
title:
default: Set a new password
@@ -132,7 +140,7 @@ profile:
placeholder: How others see you
avatar:
altText: Profile avatar
gravatarNotice: Showing your Gravatar avatar — based on a hash of your email.
gravatarNotice: Avatar from Gravatar.
gravatarLink: Manage on gravatar.com
password:
currentLabel: Current password
+16 -4
View File
@@ -169,7 +169,7 @@ pages:
label: Display name
placeholder: How others see you
avatar:
gravatarNotice: Showing your Gravatar avatar — based on a hash of your email.
gravatarNotice: Avatar from Gravatar.
gravatarLink: Manage on gravatar.com
action:
save: Save changes
@@ -229,9 +229,21 @@ pages:
description: Irreversible account actions live here.
deleteAccount:
title: Delete account
description: Permanently remove your account, sessions, and personal data.
action: Delete
notAvailable: Coming soon — contact support to delete your account today.
description: Permanently delete your account and personal data.
action: Delete account
modal:
title: Delete account?
warning: This cannot be undone. Active subscription will be canceled, Flux balance cleared.
confirmEmail:
label: Type your email to confirm
placeholder: ''
mismatch: Email does not match.
confirm: Send confirmation email
cancel: Cancel
message:
emailSent: Check {email} for the deletion link.
error:
fallback: Could not start deletion. Try again later.
card:
activate: Activate
active: Active
@@ -68,6 +68,14 @@ signIn:
terms: 服务条款
and:
privacy: 隐私政策
deleteAccount:
title:
success: 账号已删除
failed: 无法删除账号
message:
success: 你的账号已被删除。
action:
closeTab: 你可以关闭此页面。
profile:
title: 账号资料
description: 管理你的头像、显示名、密码和已绑定的社交账号。
@@ -88,8 +96,8 @@ profile:
placeholder: 别人看到的名字
avatar:
altText: 用户头像
gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)
gravatarLink: 前往 gravatar.com 管理
gravatarNotice: 头像来自 Gravatar。
gravatarLink: gravatar.com 管理
password:
currentLabel: 当前密码
currentPlaceholder: 输入当前密码
@@ -160,8 +160,8 @@ pages:
label: 显示名
placeholder: 别人看到的名字
avatar:
gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)
gravatarLink: 前往 gravatar.com 管理
gravatarNotice: 头像来自 Gravatar。
gravatarLink: gravatar.com 管理
action:
save: 保存修改
message:
@@ -220,9 +220,21 @@ pages:
description: 这里的操作不可撤销,请谨慎使用。
deleteAccount:
title: 删除账号
description: 永久删除你的账号、会话与个人数据。
action: 删除
notAvailable: 即将上线——如需立即删除账号请联系支持。
description: 永久删除你的账号与个人数据。
action: 删除账号
modal:
title: 确认删除账号?
warning: 此操作不可撤销。订阅将被取消,Flux 余额将清空。
confirmEmail:
label: 输入邮箱以确认
placeholder: ''
mismatch: 邮箱不匹配。
confirm: 发送确认邮件
cancel: 取消
message:
emailSent: 删除链接已发送至 {email}。
error:
fallback: 无法发起删除,请稍后重试。
card:
activate: 激活
active: 已激活
@@ -7,6 +7,7 @@ import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Button, FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
@@ -297,6 +298,80 @@ async function handleSendSetPasswordLink() {
setPasswordLoading.value = false
}
}
// ---- Delete account ----
//
// Two-step flow:
// (1) Click "Delete account" -> open a reka-ui Dialog with a focus-trap and
// overlay so the destructive action is unambiguously modal. Inside the
// dialog the user retypes their email; we only enable the submit button
// when the entered value matches `userEmail` exactly. This is the same
// irreversible-action pattern GitHub / Linear use.
// (2) On confirm, call `authClient.deleteUser({ callbackURL })`. better-auth
// emails a single-use link; clicking it runs the soft-delete handlers
// server-side, hard-deletes the auth tables, then redirects the browser
// to `callbackURL`. We point the callback at ui-server-auth's success
// page on the API server origin stage-web and stage-tamagotchi do not
// own a dedicated post-delete route, and ui-server-auth is reachable
// from every embedding app.
const deleteDialogOpen = ref(false)
const deleteSent = shallowRef(false)
const deleteForm = reactive({ confirmEmail: '' })
const deleteLoading = shallowRef(false)
const deleteError = shallowRef<string | null>(null)
const deleteEmailMatches = computed(() => {
const target = userEmail.value?.trim().toLowerCase() ?? ''
return target.length > 0 && deleteForm.confirmEmail.trim().toLowerCase() === target
})
function openDeleteDialog() {
deleteForm.confirmEmail = ''
deleteError.value = null
deleteDialogOpen.value = true
}
// Reset transient form state whenever the dialog closes (cancel button, ESC,
// overlay click). We deliberately keep `deleteSent` outside this reset so the
// success message under the Danger Zone stays visible after the dialog is
// dismissed by the user.
watch(deleteDialogOpen, (open) => {
if (!open) {
deleteForm.confirmEmail = ''
deleteError.value = null
}
})
async function handleConfirmDelete(event: Event) {
event.preventDefault()
if (deleteLoading.value || !deleteEmailMatches.value)
return
deleteError.value = null
deleteLoading.value = true
try {
// The success page lives on the API-server origin (shared
// ui-server-auth bundle). It tells the user the deletion completed
// and asks them to close the tab there is no "back to home"
// because the API server has no reliable way to know which calling
// app origin (stage-web / stage-tamagotchi / stage-pocket) the
// request came from.
const callbackURL = new URL('/auth/delete-account', SERVER_URL).toString()
const { error } = await authClient.deleteUser({ callbackURL })
if (error)
throw new Error(error.message ?? 'deleteUser failed')
deleteSent.value = true
deleteDialogOpen.value = false
}
catch (error) {
deleteError.value = errorMessageFrom(error) ?? t('settings.pages.account.danger.deleteAccount.error.fallback')
}
finally {
deleteLoading.value = false
}
}
</script>
<template>
@@ -702,10 +777,6 @@ async function handleSendSetPasswordLink() {
</p>
</header>
<!-- TODO: Wire up delete-account once server enables
user.deleteUser in better-auth config. The endpoint sends a
confirmation email and revokes all sessions, so the UX needs
a confirmation modal + post-delete redirect. -->
<div :class="['flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3']">
<div :class="['flex flex-col gap-0.5 min-w-0']">
<span :class="['text-sm font-medium']">
@@ -718,14 +789,89 @@ async function handleSendSetPasswordLink() {
<div :class="['flex-shrink-0']">
<Button
variant="danger"
disabled
:title="t('settings.pages.account.danger.deleteAccount.notAvailable')"
:label="t('settings.pages.account.danger.deleteAccount.action')"
@click="openDeleteDialog"
/>
</div>
</div>
<p
v-if="deleteSent"
:class="['text-sm text-green-600 dark:text-green-400 max-w-md']"
aria-live="polite"
>
{{ t('settings.pages.account.danger.deleteAccount.message.emailSent', { email: userEmail }) }}
</p>
</section>
<!-- Delete-account confirmation dialog. Uses reka-ui's Dialog so we
get focus-trap, ESC-to-close, and overlay-click-to-close for
free destructive actions warrant a real modal, not an inline
reveal. The email retype is the standard high-friction guard
for irreversible account actions (GitHub / Linear use the same
pattern). -->
<DialogRoot v-model:open="deleteDialogOpen">
<DialogPortal>
<DialogOverlay
:class="[
'fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm',
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
]"
/>
<DialogContent
:class="[
'fixed left-1/2 top-1/2 z-9999 -translate-x-1/2 -translate-y-1/2',
'max-h-[90dvh] w-[92dvw] max-w-md overflow-y-auto',
'rounded-2xl bg-white dark:bg-neutral-900',
'p-6 shadow-xl outline-none',
'data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow',
]"
>
<DialogTitle :class="['text-lg font-semibold text-red-600 dark:text-red-400 mb-2']">
{{ t('settings.pages.account.danger.deleteAccount.modal.title') }}
</DialogTitle>
<DialogDescription :class="['text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-line mb-4']">
{{ t('settings.pages.account.danger.deleteAccount.modal.warning') }}
</DialogDescription>
<form :class="['flex flex-col gap-3']" @submit="handleConfirmDelete">
<FieldInput
v-model="deleteForm.confirmEmail"
type="email"
autocomplete="off"
:label="t('settings.pages.account.danger.deleteAccount.modal.confirmEmail.label')"
:placeholder="userEmail ?? t('settings.pages.account.danger.deleteAccount.modal.confirmEmail.placeholder')"
/>
<div
v-if="deleteError"
:class="['text-sm text-red-500']"
role="alert"
aria-live="polite"
>
{{ deleteError }}
</div>
<div :class="['flex justify-end gap-2 pt-1']">
<DialogClose as-child>
<Button
type="button"
variant="secondary"
:disabled="deleteLoading"
:label="t('settings.pages.account.danger.deleteAccount.modal.cancel')"
/>
</DialogClose>
<Button
type="submit"
variant="danger"
:loading="deleteLoading"
:disabled="!deleteEmailMatches"
:label="t('settings.pages.account.danger.deleteAccount.modal.confirm')"
/>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
<!-- Sign out at the page foot mobile-only fallback because the
sidebar (which owns logout on desktop) is hidden on small
viewports. Kept outside the Danger Zone because logging out is