From 6f0b7e0b9b5fa2d6e43afed5ca8f98a312556dfe Mon Sep 17 00:00:00 2001
From: RainbowBird
Date: Tue, 28 Apr 2026 20:53:00 +0800
Subject: [PATCH] feat(auth): delete account (#1756)
---
apps/server/docs/ai-context/README.md | 4 +
.../docs/ai-context/account-deletion.md | 184 ++
.../verifications/account-deletion.md | 131 +
.../drizzle/0009_perpetual_lilandra.sql | 29 +
apps/server/drizzle/meta/0009_snapshot.json | 2755 +++++++++++++++++
apps/server/drizzle/meta/_journal.json | 9 +-
apps/server/src/app.test.ts | 1 +
apps/server/src/app.ts | 76 +-
apps/server/src/libs/auth.ts | 55 +-
apps/server/src/routes/stripe/route.test.ts | 2 +
apps/server/src/schemas/characters.ts | 8 +-
apps/server/src/schemas/flux-transaction.ts | 7 +-
apps/server/src/schemas/flux.ts | 8 +-
apps/server/src/schemas/providers.ts | 5 +-
apps/server/src/schemas/stripe.ts | 17 +-
apps/server/src/schemas/user-character.ts | 9 +-
apps/server/src/services/characters.ts | 52 +-
apps/server/src/services/chats.ts | 105 +
apps/server/src/services/email.ts | 43 +
apps/server/src/services/flux.ts | 51 +-
apps/server/src/services/providers.ts | 21 +
apps/server/src/services/stripe.ts | 115 +-
apps/server/src/services/tests/stripe.test.ts | 2 +-
.../src/services/user-deletion/index.test.ts | 121 +
.../src/services/user-deletion/index.ts | 77 +
.../user-deletion/service-deletion.test.ts | 282 ++
.../src/services/user-deletion/types.ts | 89 +
.../src/pages/delete-account.vue | 74 +
packages/i18n/src/locales/en/server/auth.yaml | 10 +-
packages/i18n/src/locales/en/settings.yaml | 20 +-
.../i18n/src/locales/zh-Hans/server/auth.yaml | 12 +-
.../i18n/src/locales/zh-Hans/settings.yaml | 22 +-
.../account/account-settings-page.vue | 158 +-
33 files changed, 4489 insertions(+), 65 deletions(-)
create mode 100644 apps/server/docs/ai-context/account-deletion.md
create mode 100644 apps/server/docs/ai-context/verifications/account-deletion.md
create mode 100644 apps/server/drizzle/0009_perpetual_lilandra.sql
create mode 100644 apps/server/drizzle/meta/0009_snapshot.json
create mode 100644 apps/server/src/services/user-deletion/index.test.ts
create mode 100644 apps/server/src/services/user-deletion/index.ts
create mode 100644 apps/server/src/services/user-deletion/service-deletion.test.ts
create mode 100644 apps/server/src/services/user-deletion/types.ts
create mode 100644 apps/ui-server-auth/src/pages/delete-account.vue
diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md
index 50c2fe271..7ea2668a4 100644
--- a/apps/server/docs/ai-context/README.md
+++ b/apps/server/docs/ai-context/README.md
@@ -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 verified(schema/typecheck/units)和 what's pending(live DB + Resend + Stripe trace)
## 快速结论
diff --git a/apps/server/docs/ai-context/account-deletion.md b/apps/server/docs/ai-context/account-deletion.md
new file mode 100644
index 000000000..5de506de8
--- /dev/null
+++ b/apps/server/docs/ai-context/account-deletion.md
@@ -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
+}
+
+export interface UserDeletionService {
+ register: (handler: UserDeletionHandler) => void
+ softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise
+}
+```
+
+装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(idempotent:Stripe sub 已 cancel 的再 cancel 是 no-op;deletedAt 已设置的再 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 返回 200(idempotent 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 sub(mock 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 约束已释放)
diff --git a/apps/server/docs/ai-context/verifications/account-deletion.md b/apps/server/docs/ai-context/verifications/account-deletion.md
new file mode 100644
index 000000000..3c55394eb
--- /dev/null
+++ b/apps/server/docs/ai-context/verifications/account-deletion.md
@@ -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.
diff --git a/apps/server/drizzle/0009_perpetual_lilandra.sql b/apps/server/drizzle/0009_perpetual_lilandra.sql
new file mode 100644
index 000000000..af90041b3
--- /dev/null
+++ b/apps/server/drizzle/0009_perpetual_lilandra.sql
@@ -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;
\ No newline at end of file
diff --git a/apps/server/drizzle/meta/0009_snapshot.json b/apps/server/drizzle/meta/0009_snapshot.json
new file mode 100644
index 000000000..fa1f6d95a
--- /dev/null
+++ b/apps/server/drizzle/meta/0009_snapshot.json
@@ -0,0 +1,2755 @@
+{
+ "id": "86b6aa8d-03e2-40f3-8331-582d0bd3c399",
+ "prevId": "e77aa17f-3071-42f9-948b-8b9ac9d0b457",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.jwks": {
+ "name": "jwks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key": {
+ "name": "private_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_access_token": {
+ "name": "oauth_access_token",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_id": {
+ "name": "refresh_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_access_token_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_access_token_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "oauth_client",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "client_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_session_id_session_id_fk": {
+ "name": "oauth_access_token_session_id_session_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "session",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_user_id_user_id_fk": {
+ "name": "oauth_access_token_user_id_user_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": {
+ "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "oauth_refresh_token",
+ "columnsFrom": [
+ "refresh_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_access_token_token_unique": {
+ "name": "oauth_access_token_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client": {
+ "name": "oauth_client",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled": {
+ "name": "disabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "skip_consent": {
+ "name": "skip_consent",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enable_end_session": {
+ "name": "enable_end_session",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uri": {
+ "name": "uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contacts": {
+ "name": "contacts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tos": {
+ "name": "tos",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_id": {
+ "name": "software_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_version": {
+ "name": "software_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_statement": {
+ "name": "software_statement",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "post_logout_redirect_uris": {
+ "name": "post_logout_redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_endpoint_auth_method": {
+ "name": "token_endpoint_auth_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grant_types": {
+ "name": "grant_types",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_types": {
+ "name": "response_types",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "require_pkce": {
+ "name": "require_pkce",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_client_user_id_user_id_fk": {
+ "name": "oauth_client_user_id_user_id_fk",
+ "tableFrom": "oauth_client",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_client_client_id_unique": {
+ "name": "oauth_client_client_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "client_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_consent": {
+ "name": "oauth_consent",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_consent_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_consent_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_consent",
+ "tableTo": "oauth_client",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "client_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_consent_user_id_user_id_fk": {
+ "name": "oauth_consent_user_id_user_id_fk",
+ "tableFrom": "oauth_consent",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_refresh_token": {
+ "name": "oauth_refresh_token",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_time": {
+ "name": "auth_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_refresh_token_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "oauth_client",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "client_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_refresh_token_session_id_session_id_fk": {
+ "name": "oauth_refresh_token_session_id_session_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "session",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "oauth_refresh_token_user_id_user_id_fk": {
+ "name": "oauth_refresh_token_user_id_user_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.avatar_model": {
+ "name": "avatar_model",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "avatar_model_character_id_characters_id_fk": {
+ "name": "avatar_model_character_id_characters_id_fk",
+ "tableFrom": "avatar_model",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.characters": {
+ "name": "characters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creator_role": {
+ "name": "creator_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_credit": {
+ "name": "price_credit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "likes_count": {
+ "name": "likes_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "bookmarks_count": {
+ "name": "bookmarks_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "interactions_count": {
+ "name": "interactions_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "forks_count": {
+ "name": "forks_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.character_capabilities": {
+ "name": "character_capabilities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "character_capabilities_character_id_characters_id_fk": {
+ "name": "character_capabilities_character_id_characters_id_fk",
+ "tableFrom": "character_capabilities",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.character_covers": {
+ "name": "character_covers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "foreground_url": {
+ "name": "foreground_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "background_url": {
+ "name": "background_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "character_covers_character_id_characters_id_fk": {
+ "name": "character_covers_character_id_characters_id_fk",
+ "tableFrom": "character_covers",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.character_i18n": {
+ "name": "character_i18n",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tagline": {
+ "name": "tagline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "character_i18n_character_id_characters_id_fk": {
+ "name": "character_i18n_character_id_characters_id_fk",
+ "tableFrom": "character_i18n",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.character_prompts": {
+ "name": "character_prompts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "character_prompts_character_id_characters_id_fk": {
+ "name": "character_prompts_character_id_characters_id_fk",
+ "tableFrom": "character_prompts",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_members": {
+ "name": "chat_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "member_type": {
+ "name": "member_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "chat_members_chat_id_chats_id_fk": {
+ "name": "chat_members_chat_id_chats_id_fk",
+ "tableFrom": "chat_members",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chats": {
+ "name": "chats",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.messages": {
+ "name": "messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sender_id": {
+ "name": "sender_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seq": {
+ "name": "seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_ids": {
+ "name": "media_ids",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sticker_ids": {
+ "name": "sticker_ids",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reply_message_id": {
+ "name": "reply_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "forward_from_message_id": {
+ "name": "forward_from_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "messages_chat_id_chats_id_fk": {
+ "name": "messages_chat_id_chats_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sticker_packs": {
+ "name": "sticker_packs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.stickers": {
+ "name": "stickers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.flux_transaction": {
+ "name": "flux_transaction",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "balance_before": {
+ "name": "balance_before",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "balance_after": {
+ "name": "balance_after",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "flux_tx_user_id_idx": {
+ "name": "flux_tx_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "flux_tx_created_at_idx": {
+ "name": "flux_tx_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "flux_tx_user_request_uniq": {
+ "name": "flux_tx_user_request_uniq",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "request_id IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_flux": {
+ "name": "user_flux",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "flux": {
+ "name": "flux",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.llm_request_log": {
+ "name": "llm_request_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "flux_consumed": {
+ "name": "flux_consumed",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt_tokens": {
+ "name": "prompt_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completion_tokens": {
+ "name": "completion_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.system_provider_configs": {
+ "name": "system_provider_configs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "validated": {
+ "name": "validated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "validation_bypassed": {
+ "name": "validation_bypassed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_provider_configs": {
+ "name": "user_provider_configs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "validated": {
+ "name": "validated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "validation_bypassed": {
+ "name": "validation_bypassed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.stripe_checkout_session": {
+ "name": "stripe_checkout_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_session_id": {
+ "name": "stripe_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amount_total": {
+ "name": "amount_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "success_url": {
+ "name": "success_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_url": {
+ "name": "cancel_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_payment_intent_id": {
+ "name": "stripe_payment_intent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "flux_credited": {
+ "name": "flux_credited",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "stripe_checkout_session_stripe_session_id_unique": {
+ "name": "stripe_checkout_session_stripe_session_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "stripe_session_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.stripe_customer": {
+ "name": "stripe_customer",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "stripe_customer_stripe_customer_id_unique": {
+ "name": "stripe_customer_stripe_customer_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "stripe_customer_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.stripe_invoice": {
+ "name": "stripe_invoice",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_invoice_id": {
+ "name": "stripe_invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amount_due": {
+ "name": "amount_due",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amount_paid": {
+ "name": "amount_paid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "invoice_url": {
+ "name": "invoice_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "invoice_pdf": {
+ "name": "invoice_pdf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_start": {
+ "name": "period_start",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_end": {
+ "name": "period_end",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "flux_credited": {
+ "name": "flux_credited",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "stripe_invoice_stripe_invoice_id_unique": {
+ "name": "stripe_invoice_stripe_invoice_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "stripe_invoice_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.stripe_subscription": {
+ "name": "stripe_subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_price_id": {
+ "name": "stripe_price_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "current_period_start": {
+ "name": "current_period_start",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_period_end": {
+ "name": "current_period_end",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_at_period_end": {
+ "name": "cancel_at_period_end",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canceled_at": {
+ "name": "canceled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "stripe_subscription_stripe_subscription_id_unique": {
+ "name": "stripe_subscription_stripe_subscription_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "stripe_subscription_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_character_bookmarks": {
+ "name": "user_character_bookmarks",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_character_bookmarks_character_id_characters_id_fk": {
+ "name": "user_character_bookmarks_character_id_characters_id_fk",
+ "tableFrom": "user_character_bookmarks",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_character_bookmarks_user_id_character_id_pk": {
+ "name": "user_character_bookmarks_user_id_character_id_pk",
+ "columns": [
+ "user_id",
+ "character_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_character_likes": {
+ "name": "user_character_likes",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_character_likes_character_id_characters_id_fk": {
+ "name": "user_character_likes_character_id_characters_id_fk",
+ "tableFrom": "user_character_likes",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_character_likes_user_id_character_id_pk": {
+ "name": "user_character_likes_user_id_character_id_pk",
+ "columns": [
+ "user_id",
+ "character_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json
index b49c6b333..699acbe0d 100644
--- a/apps/server/drizzle/meta/_journal.json
+++ b/apps/server/drizzle/meta/_journal.json
@@ -64,6 +64,13 @@
"when": 1775032828818,
"tag": "0008_gray_xavin",
"breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "7",
+ "when": 1777370103031,
+ "tag": "0009_perpetual_lilandra",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts
index 9fb42ba56..936e91034 100644
--- a/apps/server/src/app.test.ts
+++ b/apps/server/src/app.test.ts
@@ -65,6 +65,7 @@ function createTestDeps() {
API_SERVER_URL: 'http://localhost:3000',
} as any,
otel: null,
+ userDeletionService: {} as any,
}
return {
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 822b7d640..5b3d15483 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -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')
diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts
index 6478377fa..fe2d00d8f 100644
--- a/apps/server/src/libs/auth.ts
+++ b/apps/server/src/libs/auth.ts
@@ -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: {
diff --git a/apps/server/src/routes/stripe/route.test.ts b/apps/server/src/routes/stripe/route.test.ts
index 99f3b80f3..bb344c467 100644
--- a/apps/server/src/routes/stripe/route.test.ts
+++ b/apps/server/src/routes/stripe/route.test.ts
@@ -99,6 +99,7 @@ function createCheckoutSession(overrides: Partial = {}):
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
+ deletedAt: null,
...overrides,
}
}
@@ -123,6 +124,7 @@ function createInvoice(overrides: Partial = {}): StripeInvoice {
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
+ deletedAt: null,
...overrides,
}
}
diff --git a/apps/server/src/schemas/characters.ts b/apps/server/src/schemas/characters.ts
index a7482ea3a..bcf9f6682 100644
--- a/apps/server/src/schemas/characters.ts
+++ b/apps/server/src/schemas/characters.ts
@@ -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'),
diff --git a/apps/server/src/schemas/flux-transaction.ts b/apps/server/src/schemas/flux-transaction.ts
index 4bfca849e..c7f388f93 100644
--- a/apps/server/src/schemas/flux-transaction.ts
+++ b/apps/server/src/schemas/flux-transaction.ts
@@ -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(),
diff --git a/apps/server/src/schemas/flux.ts b/apps/server/src/schemas/flux.ts
index d4b8fff5b..1f233580b 100644
--- a/apps/server/src/schemas/flux.ts
+++ b/apps/server/src/schemas/flux.ts
@@ -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'),
})
diff --git a/apps/server/src/schemas/providers.ts b/apps/server/src/schemas/providers.ts
index c8d73df7f..5c045100d 100644
--- a/apps/server/src/schemas/providers.ts
+++ b/apps/server/src/schemas/providers.ts
@@ -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({}),
diff --git a/apps/server/src/schemas/stripe.ts b/apps/server/src/schemas/stripe.ts
index 2b7193703..2612ce100 100644
--- a/apps/server/src/schemas/stripe.ts
+++ b/apps/server/src/schemas/stripe.ts
@@ -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 ----------
diff --git a/apps/server/src/schemas/user-character.ts b/apps/server/src/schemas/user-character.ts
index 3c5a7d62c..e7b882943 100644
--- a/apps/server/src/schemas/user-character.ts
+++ b/apps/server/src/schemas/user-character.ts
@@ -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
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] }),
diff --git a/apps/server/src/services/characters.ts b/apps/server/src/services/characters.ts
index 8aace4b1b..bdc7603a3 100644
--- a/apps/server/src/services/characters.ts
+++ b/apps/server/src/services/characters.ts
@@ -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')
+ },
}
}
diff --git a/apps/server/src/services/chats.ts b/apps/server/src/services/chats.ts
index 5c3f5c3af..c53b71fef 100644
--- a/apps/server/src/services/chats.ts
+++ b/apps/server/src/services/chats.ts
@@ -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)
diff --git a/apps/server/src/services/email.ts b/apps/server/src/services/email.ts
index 38a94c994..2fd6988e2 100644
--- a/apps/server/src/services/email.ts
+++ b/apps/server/src/services/email.ts
@@ -47,6 +47,17 @@ export interface EmailService {
sendPasswordReset: (params: { to: string, url: string }) => Promise
sendMagicLink: (params: { to: string, url: string }) => Promise
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise
+ /**
+ * 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
}
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.',
+ })
+}
diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/flux.ts
index b9674ee4a..f535a16c0 100644
--- a/apps/server/src/services/flux.ts
+++ b/apps/server/src/services/flux.ts
@@ -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')
+ },
}
}
diff --git a/apps/server/src/services/providers.ts b/apps/server/src/services/providers.ts
index 7bb20292f..0ee1d6a56 100644
--- a/apps/server/src/services/providers.ts
+++ b/apps/server/src/services/providers.ts
@@ -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')
+ },
}
}
diff --git a/apps/server/src/services/stripe.ts b/apps/server/src/services/stripe.ts
index 70cc20ea2..e3a35bf3e 100644
--- a/apps/server/src/services/stripe.ts
+++ b/apps/server/src/services/stripe.ts
@@ -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')
+ },
}
}
diff --git a/apps/server/src/services/tests/stripe.test.ts b/apps/server/src/services/tests/stripe.test.ts
index 2c7da9b28..d1dd9b931 100644
--- a/apps/server/src/services/tests/stripe.test.ts
+++ b/apps/server/src/services/tests/stripe.test.ts
@@ -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)
diff --git a/apps/server/src/services/user-deletion/index.test.ts b/apps/server/src/services/user-deletion/index.test.ts
new file mode 100644
index 000000000..8e3801460
--- /dev/null
+++ b/apps/server/src/services/user-deletion/index.test.ts
@@ -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): 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()
+ })
+ })
+})
diff --git a/apps/server/src/services/user-deletion/index.ts b/apps/server/src/services/user-deletion/index.ts
new file mode 100644
index 000000000..4a367092e
--- /dev/null
+++ b/apps/server/src/services/user-deletion/index.ts
@@ -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()
+
+ 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')
+ },
+ }
+}
diff --git a/apps/server/src/services/user-deletion/service-deletion.test.ts b/apps/server/src/services/user-deletion/service-deletion.test.ts
new file mode 100644
index 000000000..e88866d17
--- /dev/null
+++ b/apps/server/src/services/user-deletion/service-deletion.test.ts
@@ -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()
+ 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()
+ })
+})
diff --git a/apps/server/src/services/user-deletion/types.ts b/apps/server/src/services/user-deletion/types.ts
new file mode 100644
index 000000000..080c30b30
--- /dev/null
+++ b/apps/server/src/services/user-deletion/types.ts
@@ -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
+}
+
+/**
+ * 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
+}
diff --git a/apps/ui-server-auth/src/pages/delete-account.vue b/apps/ui-server-auth/src/pages/delete-account.vue
new file mode 100644
index 000000000..c004c1664
--- /dev/null
+++ b/apps/ui-server-auth/src/pages/delete-account.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+ {{
+ errorMessage
+ ? t('server.auth.deleteAccount.title.failed')
+ : t('server.auth.deleteAccount.title.success')
+ }}
+
+
+
+
+ {{ t('server.auth.deleteAccount.message.success') }}
+
+
+ {{ errorMessage }}
+
+
+
+ {{ t('server.auth.deleteAccount.action.closeTab') }}
+
+
+
+
+
+
+meta:
+ layout: plain
+
diff --git a/packages/i18n/src/locales/en/server/auth.yaml b/packages/i18n/src/locales/en/server/auth.yaml
index 63b53c8fa..5a161a22e 100644
--- a/packages/i18n/src/locales/en/server/auth.yaml
+++ b/packages/i18n/src/locales/en/server/auth.yaml
@@ -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
diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml
index 864e9b5e8..59c448592 100644
--- a/packages/i18n/src/locales/en/settings.yaml
+++ b/packages/i18n/src/locales/en/settings.yaml
@@ -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
diff --git a/packages/i18n/src/locales/zh-Hans/server/auth.yaml b/packages/i18n/src/locales/zh-Hans/server/auth.yaml
index 6a1dc1917..4515f8ed8 100644
--- a/packages/i18n/src/locales/zh-Hans/server/auth.yaml
+++ b/packages/i18n/src/locales/zh-Hans/server/auth.yaml
@@ -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: 输入当前密码
diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml
index a2c97f938..62fcf5fdb 100644
--- a/packages/i18n/src/locales/zh-Hans/settings.yaml
+++ b/packages/i18n/src/locales/zh-Hans/settings.yaml
@@ -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: 已激活
diff --git a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue
index db271f9a2..f235d4d85 100644
--- a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue
+++ b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue
@@ -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(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
+ }
+}
@@ -702,10 +777,6 @@ async function handleSendSetPasswordLink() {
-
@@ -718,14 +789,89 @@ async function handleSendSetPasswordLink() {
+
+
+ {{ t('settings.pages.account.danger.deleteAccount.message.emailSent', { email: userEmail }) }}
+
+
+
+
+
+
+
+ {{ t('settings.pages.account.danger.deleteAccount.modal.title') }}
+
+
+ {{ t('settings.pages.account.danger.deleteAccount.modal.warning') }}
+
+
+
+
+
+
+