feat(auth): delete account (#1756)
This commit is contained in:
@@ -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)
|
||||
|
||||
## 快速结论
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Account Deletion
|
||||
|
||||
User-requested account deletion. Auth identity is hard-deleted; business records are soft-deleted (preserved with `deleted_at`) for audit/compliance.
|
||||
|
||||
## 决策摘要
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| `apps/server/src/schemas/accounts.ts` | **不动** | better-auth `auth:generate` 自动产物。修改会被下次生成覆盖 |
|
||||
| Auth 表 (user/session/account/oauth\*/verification) | **hard delete + cascade** | 跟着 user 一起 cascade 干净。无审计价值,留着只是 dangling auth state |
|
||||
| 业务表 (flux\*/stripe\*/character\*/providers/chats) | **soft delete (deleted_at)** | 审计、合规、debug 需要保留"这条记录原属于哪个 user" |
|
||||
| 业务表对 user.id 的 FK | **drop FK constraint,保留裸 userId 列** | better-auth hard-delete user 时不会被 cascade 干掉。跟 `llm_request_log` 现有做法一致 |
|
||||
| llm_request_log | **不参与软删,独立 retention** | 高并发写入,本就无 FK;保留期由独立 retention job 决定(合规) |
|
||||
| 删除流程 | **better-auth 内建邮件确认** | `user.deleteUser.sendDeleteAccountVerification` + token 回调,开箱即用 |
|
||||
| 误删恢复 | **不支持** | 用户认知中"删除即不可逆"。要恢复就重新注册(同 email 没问题,user 行已删,唯一约束释放) |
|
||||
| Stripe 订阅 | **立即 cancel,不退款(v1)** | 简单、对内部记账影响最小。条款需注明。后续可改 |
|
||||
| Flux 余额 | **清零(userFlux.deletedAt)** | 同上。后续可补退款逻辑 |
|
||||
|
||||
## 流程
|
||||
|
||||
```
|
||||
用户在 settings/account 点 Delete
|
||||
↓
|
||||
POST /api/auth/delete-user (Bearer) ← better-auth
|
||||
↓
|
||||
sendDeleteAccountVerification → Resend ← 我们的 EmailService
|
||||
↓ 用户收邮件,点链接
|
||||
GET /api/auth/delete-user/callback?token=... ← better-auth 验 token
|
||||
↓ token 有效
|
||||
beforeDelete(user) ← UserDeletionService.softDeleteAll(userId)
|
||||
├─ stripe (priority 10): stripeService.deleteAllForUser
|
||||
│ → Stripe API cancel + 4 张 stripe_* 表打 deletedAt
|
||||
├─ flux (priority 20): fluxService.deleteAllForUser
|
||||
│ → userFlux 打 deletedAt + redis cache 失效
|
||||
├─ providers (priority 30): providerService.deleteAllForUser
|
||||
│ → userProviderConfigs 打 deletedAt
|
||||
├─ characters (priority 30): characterService.deleteAllForUser
|
||||
│ → character / likes / bookmarks 打 deletedAt
|
||||
└─ chats (priority 30): chatService.deleteAllForUser
|
||||
→ chats / messages 打 deletedAt
|
||||
↓
|
||||
internalAdapter.deleteUser(userId) ← user 行真删
|
||||
↓ Postgres FK cascade
|
||||
session/account/oauth_client/oauth_*_token/oauth_consent ← 真删
|
||||
↓
|
||||
重定向到 callbackURL
|
||||
```
|
||||
|
||||
## 架构:service own 自己的删除语义
|
||||
|
||||
每个业务 service 自己 own `deleteAllForUser(userId)` 方法 —— 删除该 user scope 下所有相关数据的能力跟 service 的其他 CRUD 方法住在一起。`UserDeletionService` 只是个**调度器**:按 priority 串行调用各 service 的方法,throw 中止。
|
||||
|
||||
依赖图:
|
||||
|
||||
```
|
||||
auth ──depends on──► userDeletionService ──depends on──► [stripeService, fluxService, ...]
|
||||
│
|
||||
└─ 内部仅持有 { name, priority, softDelete } 列表,
|
||||
softDelete 是对 service.deleteAllForUser 的 thin wrapper
|
||||
```
|
||||
|
||||
auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。
|
||||
|
||||
```ts
|
||||
// apps/server/src/services/user-deletion/types.ts
|
||||
export interface UserDeletionHandler {
|
||||
name: string
|
||||
/** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */
|
||||
priority: number
|
||||
softDelete: (ctx: UserDeletionContext) => Promise<void>
|
||||
}
|
||||
|
||||
export interface UserDeletionService {
|
||||
register: (handler: UserDeletionHandler) => void
|
||||
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(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 约束已释放)
|
||||
@@ -0,0 +1,131 @@
|
||||
# Verification: account deletion
|
||||
|
||||
Status: **end-to-end verified (2026-04-28)** — UI → email → click → server
|
||||
soft-delete pipeline → success page all confirmed in a live run. DB-row
|
||||
inspection (`select deleted_at` on each business table) and same-email
|
||||
re-registration smoke-test are recommended but not yet captured in this
|
||||
record.
|
||||
Last attempted: 2026-04-28
|
||||
Owner: rbxin2003@gmail.com
|
||||
|
||||
## Live trace (2026-04-28)
|
||||
|
||||
Server log captured during a live deletion of `userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk`:
|
||||
|
||||
```
|
||||
<-- POST /api/auth/delete-user
|
||||
--> POST /api/auth/delete-user 200 4s
|
||||
<-- GET /api/auth/delete-user/callback?token=sb7614...&callbackURL=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fdelete-account
|
||||
[user-deletion] starting user deletion { userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk reason=user-requested handlerCount=5 }
|
||||
[user-deletion] handler completed { handler=stripe userId=... durationMs=1297 }
|
||||
[user-deletion] Flux balance soft-deleted... { userId=... clearedFlux=500 }
|
||||
[user-deletion] handler completed { handler=flux userId=... durationMs=526 }
|
||||
[user-deletion] Provider configs soft-deleted { userId=... count=0 }
|
||||
[user-deletion] handler completed { handler=providers userId=... durationMs=260 }
|
||||
[user-deletion] Characters / likes / bookmarks soft-deleted { userId=... characters=0 likes=0 bookmarks=0 }
|
||||
[user-deletion] handler completed { handler=characters userId=... durationMs=787 }
|
||||
[user-deletion] Chats / messages soft-deleted { userId=... chats=0 messages=0 }
|
||||
[user-deletion] handler completed { handler=chats userId=... durationMs=270 }
|
||||
[user-deletion] user deletion handlers completed { userId=... reason=user-requested }
|
||||
--> GET /api/auth/delete-user/callback?... 302 8s
|
||||
<-- GET /auth/delete-account
|
||||
--> GET /auth/delete-account 200 1ms
|
||||
```
|
||||
|
||||
What this proves:
|
||||
- 5 handlers run in registration order, ascending priority (stripe → flux → providers → characters → chats).
|
||||
- Total handler time ~3.1s (mostly Stripe: 1.3s for the network round-trip).
|
||||
- Verification token consumed exactly once; the callback redirected (302) to the success page.
|
||||
- `clearedFlux=500` confirms the Flux handler picked up the actual balance.
|
||||
- `count=0` for providers / characters / chats reflects the test user not having those records — empty soft-delete is a valid no-op.
|
||||
|
||||
## Known gotcha — UI dist staleness
|
||||
|
||||
`apps/server/public/ui-server-auth/` is a build artifact (Vite `outDir`). New
|
||||
pages added under `apps/ui-server-auth/src/pages/` only show up after
|
||||
running `pnpm -F @proj-airi/ui-server-auth build`. Symptom of forgetting:
|
||||
the success page returns `200` with the SPA HTML but renders blank because
|
||||
vue-router never registered the route. Re-build → fixes.
|
||||
|
||||
## What is verified
|
||||
|
||||
| Layer | Evidence | Date |
|
||||
|---|---|---|
|
||||
| Schema migration generated | `apps/server/drizzle/0009_perpetual_lilandra.sql`: 11 `DROP CONSTRAINT` + 7 `ADD COLUMN deleted_at` (no destructive ALTER beyond FK drop) | 2026-04-28 |
|
||||
| Server typecheck | `pnpm -F @proj-airi/server typecheck` exits clean | 2026-04-28 |
|
||||
| Monorepo typecheck | `pnpm typecheck` exits clean across all packages | 2026-04-28 |
|
||||
| Lint | `pnpm lint` reports 0 errors in deletion-service / handler / UI files | 2026-04-28 |
|
||||
| Deletion service unit tests | `pnpm exec vitest run apps/server/src/services/user-deletion` → `2 files / 14 tests pass` (registry priority order, abort-on-error, serial execution, idempotency, per-service `deleteAllForUser` correctness) | 2026-04-28 |
|
||||
| Architecture refactor | Domain knowledge moved out of `*-deletion-handler.ts` files into each business service's own `deleteAllForUser` method. Registry retained as a thin scheduler. `auth → userDeletionService → 5 business services` (auth and services no longer depend on each other). | 2026-04-28 |
|
||||
| Server full test suite | `pnpm -F @proj-airi/server exec vitest run` → 244/245 pass; the single failure (`origin.test.ts`) is pre-existing on `main` and unrelated | 2026-04-28 |
|
||||
| UI typecheck | `pnpm -F @proj-airi/stage-pages typecheck` and `pnpm -F @proj-airi/ui-server-auth typecheck` both clean | 2026-04-28 |
|
||||
| Live server-side trace | See "Live trace" section above — full pipeline ran against real DB + Stripe sandbox + Resend | 2026-04-28 |
|
||||
|
||||
## What is **not** verified yet (action items)
|
||||
|
||||
### Path A — Migration applies cleanly to live DB
|
||||
**Command (run by user):**
|
||||
```sh
|
||||
pnpm -F @proj-airi/server db:push
|
||||
```
|
||||
**Expected:** Drizzle reports `Changes applied` for 11 FK drops + 7 column additions on the local Postgres pointed to by `DATABASE_URL`.
|
||||
**Risk:** if any business table currently has rows whose `userId` references a now-missing user (orphans from older bugs), DROP CONSTRAINT will succeed (unlike ADD CONSTRAINT). No data loss expected.
|
||||
|
||||
### Path B — Server boots with deletion service wired
|
||||
**Command:**
|
||||
```sh
|
||||
pnpm -F @proj-airi/server dev
|
||||
```
|
||||
**Expected log lines:**
|
||||
- `injeca` resolves `services:userDeletion` without error
|
||||
- `services:auth` resolves successfully (depends on userDeletionService)
|
||||
- `Server started` log line appears
|
||||
**Failure mode:** if the Stripe SDK construction throws at boot, the deletion service crashes the process. Mitigation: `STRIPE_SECRET_KEY` is optional — handler tolerates `null`.
|
||||
|
||||
### Path C — End-to-end deletion flow (UI → email → DB)
|
||||
**Setup:**
|
||||
1. Server up (Path B), UI up (`pnpm -F @proj-airi/stage-web dev` with `VITE_SERVER_URL=http://localhost:3000`)
|
||||
2. `RESEND_API_KEY` valid, use `rbxin2003+delete@outlook.com` (bare address suppressed — see Resend memory)
|
||||
3. Pre-populate the user with non-trivial data so soft-delete has something to mark:
|
||||
- Register user
|
||||
- Have flux balance > 0 (initial grant covers this)
|
||||
- Connect a provider via settings UI
|
||||
- Create one character
|
||||
- (Optional) Set up a Stripe test sub via the billing flow
|
||||
|
||||
**Steps + assertions:**
|
||||
|
||||
| # | Action | Expected | DB / API check |
|
||||
|---|---|---|---|
|
||||
| 1 | Settings → Account → Danger Zone → click "Delete account" | Inline confirm form appears | DOM-only |
|
||||
| 2 | Type wrong email | Confirm button disabled | DOM-only |
|
||||
| 3 | Type correct email + click confirm | Server log: `POST /api/auth/delete-user 200`. UI shows "Check {email} for the deletion link" | `select * from verification where identifier like 'delete-account-%'` returns one row |
|
||||
| 4 | Open Resend inbox, click link in email | Browser navigates to `${API_SERVER_URL}/api/auth/delete-user/callback?token=...` then redirects to `/auth/delete-account` (success page) | server log: `[user-deletion] starting user deletion` → 5x `handler completed` → `user deletion handlers completed` → `internalAdapter.deleteUser` → `302` |
|
||||
| 5 | Verify auth tables are gone (cascade) | `select * from "user" where email='...'` → empty | psql query |
|
||||
| 6 | Verify business tables are soft-deleted (NOT cascade) | `select * from user_flux where user_id=$1` → row with `deleted_at IS NOT NULL` | psql query, $1 = old user.id |
|
||||
| 7 | Same for stripe_customer, stripe_subscription, stripe_checkout_session, stripe_invoice | All `deleted_at IS NOT NULL` | psql query |
|
||||
| 8 | Same for character (creator_id OR owner_id), user_provider_configs (owner_id), user_character_likes/bookmarks (user_id) | All matching rows have `deleted_at IS NOT NULL` | psql query |
|
||||
| 9 | flux_transaction is **untouched** (audit) | `select count(*) from flux_transaction where user_id=$1` → unchanged from before deletion | psql query |
|
||||
| 10 | Stripe API side: active sub canceled | Stripe dashboard or `GET /v1/subscriptions/$sub_id` → `status: "canceled"` | Stripe CLI or dashboard |
|
||||
| 11 | Re-register with same email | Sign-up succeeds (unique constraint released by hard delete of `user` row) | server log: `POST /api/auth/sign-up/email 200`. New user gets a fresh user.id |
|
||||
| 12 | Old soft-deleted business rows do **not** show up for the new user | `select * from user_flux where user_id=$1` (new id) is empty or has fresh row | psql query |
|
||||
|
||||
### Path D — Failure-mode smoke
|
||||
**Setup:** kill Postgres mid-deletion (or simulate by wrapping a handler to throw)
|
||||
**Expected:**
|
||||
- `user-deletion` log: `handler failed; aborting deletion pipeline`
|
||||
- `user` row still present (better-auth never reaches `internalAdapter.deleteUser`)
|
||||
- soft-deleted rows from earlier handlers REMAIN soft-deleted (no rollback) — by design
|
||||
- User can retry the deletion flow; idempotent handlers re-mark already-stamped rows as no-ops
|
||||
|
||||
## Known gaps
|
||||
|
||||
1. **No retry UI:** if Path D triggers, the user sees the API JSON error from `/delete-user/callback` instead of a friendly page. Acceptable for v1 (rare path); future hook can redirect to `/auth/delete-account?error=...`.
|
||||
2. **No admin-triggered deletion:** the `reason: 'admin'` enum exists but no caller. Wire up in admin panel later.
|
||||
3. **No rate limiting on `/api/auth/delete-user`:** uses the global `/api/auth/*` IP rate limit (`AUTH_RATE_LIMIT_MAX`). May want a tighter per-user cap (e.g. 1 attempt per hour) if abuse surfaces.
|
||||
4. **Translations:** EN-only for new i18n keys (`server.auth.deleteAccount.*`, `settings.pages.account.danger.deleteAccount.modal.*`, `.message.emailSent`, `.error.fallback`). Other locales fall back to EN until translated.
|
||||
5. **`flux_transaction` retention:** ledger now contains `user_id` strings that point to deleted users. No retention policy yet — open question for finance/legal.
|
||||
|
||||
## Re-verification cadence
|
||||
|
||||
When the deletion code path is touched (handler logic, schema, better-auth config), re-run Path C from scratch and update "Last attempted" + the table above.
|
||||
@@ -0,0 +1,29 @@
|
||||
ALTER TABLE "characters" DROP CONSTRAINT "characters_creator_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "characters" DROP CONSTRAINT "characters_owner_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "flux_transaction" DROP CONSTRAINT "flux_transaction_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_flux" DROP CONSTRAINT "user_flux_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_provider_configs" DROP CONSTRAINT "user_provider_configs_owner_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stripe_checkout_session" DROP CONSTRAINT "stripe_checkout_session_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stripe_customer" DROP CONSTRAINT "stripe_customer_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stripe_invoice" DROP CONSTRAINT "stripe_invoice_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stripe_subscription" DROP CONSTRAINT "stripe_subscription_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" DROP CONSTRAINT "user_character_bookmarks_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" DROP CONSTRAINT "user_character_likes_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_flux" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_checkout_session" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_customer" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_invoice" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_subscription" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" ADD COLUMN "deleted_at" timestamp;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
||||
"when": 1775032828818,
|
||||
"tag": "0008_gray_xavin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1777370103031,
|
||||
"tag": "0009_perpetual_lilandra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ function createTestDeps() {
|
||||
API_SERVER_URL: 'http://localhost:3000',
|
||||
} as any,
|
||||
otel: null,
|
||||
userDeletionService: {} as any,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+56
-20
@@ -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')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EmailService } from '../services/email'
|
||||
import type { UserDeletionService } from '../services/user-deletion'
|
||||
import type { Database } from './db'
|
||||
import type { Env } from './env'
|
||||
import type { AuthMetrics } from './otel'
|
||||
@@ -333,7 +334,32 @@ function requireEmailService(email: EmailService | undefined): EmailService {
|
||||
return email
|
||||
}
|
||||
|
||||
export function createAuth(db: Database, env: Env, email?: EmailService, metrics?: AuthMetrics | null) {
|
||||
/**
|
||||
* NOTICE:
|
||||
* `userDeletionService` is optional for the same reason `email` is — the
|
||||
* `auth:generate` schema introspection path constructs `createAuth` without
|
||||
* a real DI graph and never exercises `user.deleteUser`. The runtime path
|
||||
* always supplies it from `app.ts`, and the `beforeDelete` callback throws
|
||||
* if it's missing so silent no-ops are impossible.
|
||||
*/
|
||||
function requireUserDeletionService(service: UserDeletionService | undefined): UserDeletionService {
|
||||
if (!service) {
|
||||
throw new ApiError(
|
||||
503,
|
||||
'user-deletion/service_not_configured',
|
||||
'User deletion service not available in this server context.',
|
||||
)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
export function createAuth(
|
||||
db: Database,
|
||||
env: Env,
|
||||
email?: EmailService,
|
||||
metrics?: AuthMetrics | null,
|
||||
userDeletionService?: UserDeletionService,
|
||||
) {
|
||||
return betterAuth({
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
|
||||
@@ -452,6 +478,33 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
|
||||
})
|
||||
},
|
||||
},
|
||||
// NOTICE:
|
||||
// Two-step deletion: POST /api/auth/delete-user with an authenticated
|
||||
// session triggers `sendDeleteAccountVerification`; clicking the link
|
||||
// hits GET /api/auth/delete-user/callback?token=..., which validates
|
||||
// and calls `beforeDelete` BEFORE `internalAdapter.deleteUser`. Throw
|
||||
// from `beforeDelete` to abort: the user row stays put, the
|
||||
// verification token has already been consumed (single-use) so the
|
||||
// user must re-initiate. Soft-delete handlers must be idempotent
|
||||
// because retrying a partial deletion re-runs already-completed
|
||||
// handlers as no-ops.
|
||||
// Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-380
|
||||
// Design: apps/server/docs/ai-context/account-deletion.md
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
async sendDeleteAccountVerification({ user, url }) {
|
||||
await requireEmailService(email).sendDeleteAccountVerification({
|
||||
to: user.email,
|
||||
url,
|
||||
})
|
||||
},
|
||||
async beforeDelete(user) {
|
||||
await requireUserDeletionService(userDeletionService).softDeleteAll({
|
||||
userId: user.id,
|
||||
reason: 'user-requested',
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
session: {
|
||||
|
||||
@@ -99,6 +99,7 @@ function createCheckoutSession(overrides: Partial<StripeCheckoutSession> = {}):
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -123,6 +124,7 @@ function createInvoice(overrides: Partial<StripeInvoice> = {}): StripeInvoice {
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -6,12 +6,16 @@ import { relations } from 'drizzle-orm/relations'
|
||||
import { user } from './accounts'
|
||||
import { character } from './characters'
|
||||
|
||||
// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes
|
||||
// the user row; a cascade would wipe these soft-delete archive rows.
|
||||
// See `apps/server/docs/ai-context/account-deletion.md`.
|
||||
export const characterLikes = pgTable(
|
||||
'user_character_likes',
|
||||
{
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
},
|
||||
table => [
|
||||
primaryKey({ columns: [table.userId, table.characterId] }),
|
||||
@@ -24,9 +28,10 @@ export type NewCharacterLike = InferInsertModel<typeof characterLikes>
|
||||
export const characterBookmarks = pgTable(
|
||||
'user_character_bookmarks',
|
||||
{
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
},
|
||||
table => [
|
||||
primaryKey({ columns: [table.userId, table.characterId] }),
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,6 +47,17 @@ export interface EmailService {
|
||||
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
|
||||
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
|
||||
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
|
||||
/**
|
||||
* Send the irreversible-action confirmation for `user.deleteUser` flow.
|
||||
*
|
||||
* Wired into better-auth's `user.deleteUser.sendDeleteAccountVerification`.
|
||||
* The link expires per `deleteTokenExpiresIn` (default 24h) and is
|
||||
* single-use; clicking it triggers `beforeDelete` → soft-delete handlers →
|
||||
* hard-delete user.
|
||||
*
|
||||
* Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-300.
|
||||
*/
|
||||
sendDeleteAccountVerification: (params: { to: string, url: string }) => Promise<void>
|
||||
}
|
||||
|
||||
interface EmailConfig {
|
||||
@@ -166,6 +177,14 @@ export function createEmailService(config: EmailConfig, logger: Logger = useLogg
|
||||
text: renderChangeEmailText(url, newEmail),
|
||||
})
|
||||
},
|
||||
async sendDeleteAccountVerification({ to, url }) {
|
||||
await send({
|
||||
to,
|
||||
subject: 'Confirm account deletion for Project AIRI',
|
||||
html: renderDeleteAccountHtml(url),
|
||||
text: renderDeleteAccountText(url),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,3 +293,27 @@ function renderChangeEmailText(url: string, newEmail: string): string {
|
||||
footer: 'If you did not request this change, contact support immediately.',
|
||||
})
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// Wording is intentionally short and direct. Account deletion hard-deletes
|
||||
// the auth identity (cascade) and soft-archives business records; the user
|
||||
// cannot recover the account through the UI.
|
||||
// See `apps/server/docs/ai-context/account-deletion.md`.
|
||||
function renderDeleteAccountHtml(url: string): string {
|
||||
return renderActionEmailHtml({
|
||||
heading: 'Confirm account deletion',
|
||||
body: 'Click below to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
|
||||
ctaLabel: 'Delete my account',
|
||||
url,
|
||||
footer: 'Did not request this? Ignore this email and rotate your password.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderDeleteAccountText(url: string): string {
|
||||
return renderActionEmailText({
|
||||
heading: 'Confirm account deletion',
|
||||
body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
|
||||
url,
|
||||
footer: 'Did not request this? Ignore this email and rotate your password.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import type { Database } from '../libs/db'
|
||||
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, isNull, notInArray } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/stripe'
|
||||
|
||||
const logger = useLogger('stripe-service')
|
||||
|
||||
export function createStripeService(db: Database) {
|
||||
// NOTICE:
|
||||
// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose
|
||||
// stripe_* rows persist for billing audit) are invisible to user-facing
|
||||
// API. Webhooks that arrive after deletion still match by stripeCustomerId
|
||||
// and re-upsert into the soft-deleted row — that's by design (the row
|
||||
// remains deletedAt-set, but we capture the late event for accurate audit).
|
||||
// See `apps/server/docs/ai-context/account-deletion.md`.
|
||||
export function createStripeService(db: Database, stripe: Stripe | null) {
|
||||
return {
|
||||
// ---- Customer ----
|
||||
|
||||
@@ -26,11 +35,19 @@ export function createStripeService(db: Database) {
|
||||
|
||||
async getCustomerByUserId(userId: string) {
|
||||
return db.query.stripeCustomer.findFirst({
|
||||
where: eq(schema.stripeCustomer.userId, userId),
|
||||
where: and(
|
||||
eq(schema.stripeCustomer.userId, userId),
|
||||
isNull(schema.stripeCustomer.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async getCustomerByStripeId(stripeCustomerId: string) {
|
||||
// NOTICE: NOT filtering by deletedAt — this lookup is by external
|
||||
// Stripe id and is used by webhook handlers that need to reach
|
||||
// soft-deleted archive rows for late events (cancellation receipts,
|
||||
// final invoices arriving after account deletion). User-facing reads
|
||||
// use getCustomerByUserId which DOES filter.
|
||||
return db.query.stripeCustomer.findFirst({
|
||||
where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId),
|
||||
})
|
||||
@@ -52,7 +69,10 @@ export function createStripeService(db: Database) {
|
||||
|
||||
async getCheckoutSessionsByUserId(userId: string) {
|
||||
return db.query.stripeCheckoutSession.findMany({
|
||||
where: eq(schema.stripeCheckoutSession.userId, userId),
|
||||
where: and(
|
||||
eq(schema.stripeCheckoutSession.userId, userId),
|
||||
isNull(schema.stripeCheckoutSession.deletedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
@@ -76,6 +96,7 @@ export function createStripeService(db: Database) {
|
||||
where: and(
|
||||
eq(schema.stripeSubscription.userId, userId),
|
||||
eq(schema.stripeSubscription.status, 'active'),
|
||||
isNull(schema.stripeSubscription.deletedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
@@ -97,10 +118,94 @@ export function createStripeService(db: Database) {
|
||||
|
||||
async getInvoicesByUserId(userId: string) {
|
||||
return db.query.stripeInvoice.findMany({
|
||||
where: eq(schema.stripeInvoice.userId, userId),
|
||||
where: and(
|
||||
eq(schema.stripeInvoice.userId, userId),
|
||||
isNull(schema.stripeInvoice.deletedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel the user's active Stripe subscription via the API and stamp every
|
||||
* `stripe_*` row with `deletedAt`. Called from the user-deletion pipeline
|
||||
* (priority 10 — runs first because Stripe API cancellation has no
|
||||
* rollback path).
|
||||
*
|
||||
* Idempotent on retry: subsequent calls find no `active` subs to cancel
|
||||
* and the `WHERE deletedAt IS NULL` guard skips already-stamped rows.
|
||||
* Stripe `subscriptions.cancel` itself is also idempotent per spec —
|
||||
* cancelling an already-canceled sub returns 200.
|
||||
*
|
||||
* Cancellation is immediate, no proration, no refund — see
|
||||
* `apps/server/docs/ai-context/account-deletion.md`.
|
||||
*/
|
||||
async deleteAllForUser(userId: string) {
|
||||
// Cancel every subscription that is NOT already in a terminal state.
|
||||
// Stripe's terminal statuses are `canceled` and `incomplete_expired`;
|
||||
// anything else (`active`, `trialing`, `past_due`, `unpaid`,
|
||||
// `incomplete`, `paused`) can still bill or transition into billing,
|
||||
// so leaving them uncancelled would charge a deleted account.
|
||||
// Stripe `subscriptions.cancel` is idempotent per spec — safe to
|
||||
// call on any non-terminal status.
|
||||
const cancellableSubs = await db.query.stripeSubscription.findMany({
|
||||
where: and(
|
||||
eq(schema.stripeSubscription.userId, userId),
|
||||
notInArray(schema.stripeSubscription.status, ['canceled', 'incomplete_expired']),
|
||||
isNull(schema.stripeSubscription.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (stripe && cancellableSubs.length > 0) {
|
||||
for (const sub of cancellableSubs) {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(sub.stripeSubscriptionId, {
|
||||
prorate: false,
|
||||
})
|
||||
logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription')
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).error('Failed to cancel Stripe subscription')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!stripe && cancellableSubs.length > 0) {
|
||||
logger.withFields({ userId, cancellableSubCount: cancellableSubs.length }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.update(schema.stripeSubscription)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeSubscription.userId, userId),
|
||||
isNull(schema.stripeSubscription.deletedAt),
|
||||
))
|
||||
|
||||
await db.update(schema.stripeCheckoutSession)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeCheckoutSession.userId, userId),
|
||||
isNull(schema.stripeCheckoutSession.deletedAt),
|
||||
))
|
||||
|
||||
await db.update(schema.stripeInvoice)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeInvoice.userId, userId),
|
||||
isNull(schema.stripeInvoice.deletedAt),
|
||||
))
|
||||
|
||||
await db.update(schema.stripeCustomer)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeCustomer.userId, userId),
|
||||
isNull(schema.stripeCustomer.deletedAt),
|
||||
))
|
||||
|
||||
logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('stripeService', () => {
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
stripeService = createStripeService(db)
|
||||
stripeService = createStripeService(db, null)
|
||||
|
||||
// Clean all stripe tables between tests
|
||||
await db.delete(schema.stripeInvoice)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { UserDeletionHandler } from './types'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createUserDeletionService } from './index'
|
||||
|
||||
function makeHandler(name: string, priority: number, body?: () => Promise<void> | void): UserDeletionHandler {
|
||||
return {
|
||||
name,
|
||||
priority,
|
||||
softDelete: vi.fn(async () => {
|
||||
await body?.()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('createUserDeletionService', () => {
|
||||
describe('register', () => {
|
||||
it('rejects duplicate handler names', () => {
|
||||
const service = createUserDeletionService()
|
||||
service.register(makeHandler('flux', 20))
|
||||
|
||||
expect(() => service.register(makeHandler('flux', 30))).toThrow(/Duplicate user-deletion handler name: flux/)
|
||||
})
|
||||
|
||||
it('keeps handlers in ascending priority regardless of registration order', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const calls: string[] = []
|
||||
service.register(makeHandler('characters', 30, () => {
|
||||
calls.push('characters')
|
||||
}))
|
||||
service.register(makeHandler('stripe', 10, () => {
|
||||
calls.push('stripe')
|
||||
}))
|
||||
service.register(makeHandler('flux', 20, () => {
|
||||
calls.push('flux')
|
||||
}))
|
||||
|
||||
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
|
||||
|
||||
// @example
|
||||
// register order: characters(30) -> stripe(10) -> flux(20)
|
||||
// execution order: stripe(10) -> flux(20) -> characters(30)
|
||||
expect(calls).toEqual(['stripe', 'flux', 'characters'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('softDeleteAll', () => {
|
||||
it('passes the user id and reason to every handler', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const a = makeHandler('a', 10)
|
||||
const b = makeHandler('b', 20)
|
||||
service.register(a)
|
||||
service.register(b)
|
||||
|
||||
await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' })
|
||||
|
||||
expect(a.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
|
||||
expect(b.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
|
||||
})
|
||||
|
||||
it('aborts on first handler error and skips later handlers', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const earlyOk = makeHandler('a', 10)
|
||||
const failing = makeHandler('b', 20, () => {
|
||||
throw new Error('stripe API down')
|
||||
})
|
||||
const lateNeverRuns = makeHandler('c', 30)
|
||||
|
||||
service.register(earlyOk)
|
||||
service.register(failing)
|
||||
service.register(lateNeverRuns)
|
||||
|
||||
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }))
|
||||
.rejects
|
||||
.toThrow('stripe API down')
|
||||
|
||||
expect(earlyOk.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(failing.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(lateNeverRuns.softDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs handlers serially (next starts only after previous resolves)', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const order: string[] = []
|
||||
|
||||
service.register({
|
||||
name: 'slow',
|
||||
priority: 10,
|
||||
softDelete: async () => {
|
||||
order.push('slow:start')
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
order.push('slow:end')
|
||||
},
|
||||
})
|
||||
service.register({
|
||||
name: 'fast',
|
||||
priority: 20,
|
||||
softDelete: async () => {
|
||||
order.push('fast:start')
|
||||
order.push('fast:end')
|
||||
},
|
||||
})
|
||||
|
||||
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
|
||||
|
||||
// @example
|
||||
// serial execution: slow:start -> slow:end -> fast:start -> fast:end
|
||||
// (NOT slow:start -> fast:start -> slow:end -> fast:end which would
|
||||
// indicate parallelism)
|
||||
expect(order).toEqual(['slow:start', 'slow:end', 'fast:start', 'fast:end'])
|
||||
})
|
||||
|
||||
it('runs no handlers gracefully when registry is empty', async () => {
|
||||
const service = createUserDeletionService()
|
||||
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
|
||||
|
||||
/**
|
||||
* Build an empty deletion-service registry.
|
||||
*
|
||||
* Use when:
|
||||
* - Composing the server in `app.ts` — wire one instance and `register()`
|
||||
* each business handler at composition time.
|
||||
*
|
||||
* Returns:
|
||||
* - A registry whose `softDeleteAll` walks handlers in ascending `priority`
|
||||
* and aborts on the first throw. Successful handlers are NOT rolled back —
|
||||
* each handler's writes must be idempotent.
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* better-auth `/delete-user/callback`
|
||||
* -> `user.deleteUser.beforeDelete` (libs/auth.ts)
|
||||
* -> {@link UserDeletionService.softDeleteAll}
|
||||
* -> handler.softDelete (per registered module)
|
||||
*
|
||||
* Failure model: a thrown error from any handler aborts before
|
||||
* `internalAdapter.deleteUser`, leaving the user row intact. The next retry
|
||||
* is expected to re-run already-completed handlers as no-ops.
|
||||
*/
|
||||
export function createUserDeletionService(): UserDeletionService {
|
||||
const handlers: UserDeletionHandler[] = []
|
||||
const names = new Set<string>()
|
||||
|
||||
const logger = useLogger('user-deletion').useGlobalConfig()
|
||||
|
||||
return {
|
||||
register(handler) {
|
||||
if (names.has(handler.name))
|
||||
throw new Error(`Duplicate user-deletion handler name: ${handler.name}`)
|
||||
|
||||
names.add(handler.name)
|
||||
handlers.push(handler)
|
||||
// Resort on every insert so post-boot registrations stay ordered.
|
||||
handlers.sort((a, b) => a.priority - b.priority)
|
||||
},
|
||||
|
||||
async softDeleteAll({ userId, reason }) {
|
||||
const ctx = {
|
||||
userId,
|
||||
reason: reason as UserDeletionReason,
|
||||
logger,
|
||||
}
|
||||
|
||||
logger.withFields({ userId, reason, handlerCount: handlers.length }).log('starting user deletion')
|
||||
|
||||
for (const handler of handlers) {
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
await handler.softDelete(ctx)
|
||||
logger
|
||||
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
|
||||
.log('handler completed')
|
||||
}
|
||||
catch (err) {
|
||||
logger
|
||||
.withError(err)
|
||||
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
|
||||
.error('handler failed; aborting deletion pipeline')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
logger.withFields({ userId, reason }).log('user deletion handlers completed')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createCharacterService } from '../characters'
|
||||
import { createChatService } from '../chats'
|
||||
import { createFluxService } from '../flux'
|
||||
import { createProviderService } from '../providers'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
function fakeRedis() {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (k: string) => map.get(k) ?? null),
|
||||
set: vi.fn(async (k: string, v: string) => {
|
||||
map.set(k, v)
|
||||
return 'OK'
|
||||
}),
|
||||
del: vi.fn(async (k: string) => {
|
||||
const had = map.has(k)
|
||||
map.delete(k)
|
||||
return had ? 1 : 0
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
function fakeConfigKV() {
|
||||
return {
|
||||
get: vi.fn(async () => undefined),
|
||||
getOrThrow: vi.fn(async () => 0),
|
||||
set: vi.fn(async () => {}),
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('fluxService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('marks userFlux.deletedAt and invalidates Redis cache', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-flux-1', name: 'A', email: 'a@example.com' })
|
||||
await db.insert(schema.userFlux).values({ userId: 'u-flux-1', flux: 100 })
|
||||
|
||||
const redis = fakeRedis()
|
||||
const service = createFluxService(db, redis, fakeConfigKV())
|
||||
await service.deleteAllForUser('u-flux-1')
|
||||
|
||||
const row = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-1') })
|
||||
expect(row?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(redis.del).toHaveBeenCalledTimes(1)
|
||||
expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1'))
|
||||
})
|
||||
|
||||
it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-flux-2', name: 'B', email: 'b@example.com' })
|
||||
await db.insert(schema.userFlux).values({ userId: 'u-flux-2', flux: 50 })
|
||||
|
||||
const redis = fakeRedis()
|
||||
const service = createFluxService(db, redis, fakeConfigKV())
|
||||
|
||||
await service.deleteAllForUser('u-flux-2')
|
||||
const firstStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
|
||||
|
||||
// Second invocation: WHERE deletedAt IS NULL filters out the
|
||||
// already-stamped row, so deletedAt does not change.
|
||||
await service.deleteAllForUser('u-flux-2')
|
||||
const secondStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
|
||||
|
||||
expect(secondStamp).toEqual(firstStamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('marks every userProviderConfigs row owned by the user', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' })
|
||||
await db.insert(schema.userProviderConfigs).values([
|
||||
{ ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' },
|
||||
{ ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' },
|
||||
])
|
||||
|
||||
const service = createProviderService(db)
|
||||
await service.deleteAllForUser('u-prov-1')
|
||||
|
||||
const rows = await db.query.userProviderConfigs.findMany({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-1') })
|
||||
expect(rows).toHaveLength(2)
|
||||
rows.forEach(r => expect(r.deletedAt).toBeInstanceOf(Date))
|
||||
})
|
||||
|
||||
it('does not touch other users rows', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-prov-other', name: 'O', email: 'o@example.com' })
|
||||
await db.insert(schema.userProviderConfigs).values({ ownerId: 'u-prov-other', definitionId: 'openai', name: 'kept' })
|
||||
|
||||
const service = createProviderService(db)
|
||||
await service.deleteAllForUser('u-prov-1')
|
||||
|
||||
const otherRow = await db.query.userProviderConfigs.findFirst({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-other') })
|
||||
expect(otherRow?.deletedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('characterService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('soft-deletes characters where the user is owner OR creator', async () => {
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-char-1', name: 'C1', email: 'c1@example.com' },
|
||||
{ id: 'u-char-2', name: 'C2', email: 'c2@example.com' },
|
||||
])
|
||||
await db.insert(schema.character).values([
|
||||
{ id: 'char-owner', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-1', characterId: 'cid-1' },
|
||||
{ id: 'char-creator', version: '1', coverUrl: '', creatorId: 'u-char-1', ownerId: 'u-char-2', characterId: 'cid-2' },
|
||||
{ id: 'char-other', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-2', characterId: 'cid-3' },
|
||||
])
|
||||
|
||||
const service = createCharacterService(db)
|
||||
await service.deleteAllForUser('u-char-1')
|
||||
|
||||
const owner = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-owner') })
|
||||
const creator = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-creator') })
|
||||
const other = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-other') })
|
||||
|
||||
expect(owner?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(creator?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(other?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('soft-deletes the user likes and bookmarks', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' })
|
||||
await db.insert(schema.character).values({
|
||||
id: 'char-z',
|
||||
version: '1',
|
||||
coverUrl: '',
|
||||
creatorId: 'u-char-3',
|
||||
ownerId: 'u-char-3',
|
||||
characterId: 'cid-z',
|
||||
})
|
||||
await db.insert(schema.characterLikes).values({ userId: 'u-char-3', characterId: 'char-z' })
|
||||
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-3', characterId: 'char-z' })
|
||||
|
||||
const service = createCharacterService(db)
|
||||
await service.deleteAllForUser('u-char-3')
|
||||
|
||||
const like = await db.query.characterLikes.findFirst({ where: eq(schema.characterLikes.userId, 'u-char-3') })
|
||||
const bookmark = await db.query.characterBookmarks.findFirst({ where: eq(schema.characterBookmarks.userId, 'u-char-3') })
|
||||
|
||||
expect(like?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(bookmark?.deletedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('soft-deletes chats the user is a member of', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-chat-1', name: 'C', email: 'chat@example.com' })
|
||||
await db.insert(schema.chats).values([
|
||||
{ id: 'chat-mine', type: 'private', title: 'mine' },
|
||||
{ id: 'chat-other', type: 'private', title: 'other' },
|
||||
])
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'chat-mine', memberType: 'user', userId: 'u-chat-1' })
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-chat-1')
|
||||
|
||||
const mine = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-mine') })
|
||||
const other = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-other') })
|
||||
|
||||
expect(mine?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(other?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('drops chat_members for shared (group/channel) chats but keeps the chat alive', async () => {
|
||||
// Two users in a shared group chat. When user A is deleted, the chat
|
||||
// row must survive for user B; only A's chat_members row goes.
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-grp-a', name: 'A', email: 'grpa@example.com' },
|
||||
{ id: 'u-grp-b', name: 'B', email: 'grpb@example.com' },
|
||||
])
|
||||
await db.insert(schema.chats).values({ id: 'chat-grp', type: 'group', title: 'team' })
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-a' },
|
||||
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-b' },
|
||||
])
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-grp-a')
|
||||
|
||||
const chatRow = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-grp') })
|
||||
expect(chatRow?.deletedAt).toBeNull() // chat survives
|
||||
|
||||
const remainingMembers = await db.query.chatMembers.findMany({ where: eq(schema.chatMembers.chatId, 'chat-grp') })
|
||||
expect(remainingMembers).toHaveLength(1)
|
||||
expect(remainingMembers[0]?.userId).toBe('u-grp-b')
|
||||
})
|
||||
|
||||
it('preserves the user messages inside group chats so other members keep conversation context', async () => {
|
||||
// Anonymization-by-design: in a group chat, user A's messages must NOT
|
||||
// be soft-deleted on account deletion — that would corrupt B's history.
|
||||
// The senderId stays as the (now-orphan) user.id string; the UI renders
|
||||
// it as "Deleted User" once it cannot resolve the id to a real user.
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-anon-a', name: 'A', email: 'anona@example.com' },
|
||||
{ id: 'u-anon-b', name: 'B', email: 'anonb@example.com' },
|
||||
])
|
||||
await db.insert(schema.chats).values({ id: 'chat-anon-grp', type: 'group', title: 'team' })
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-a' },
|
||||
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-b' },
|
||||
])
|
||||
await db.insert(schema.messages).values([
|
||||
{ id: 'm-a-1', chatId: 'chat-anon-grp', senderId: 'u-anon-a', role: 'user', content: 'hi from A', mediaIds: [], stickerIds: [] },
|
||||
{ id: 'm-b-1', chatId: 'chat-anon-grp', senderId: 'u-anon-b', role: 'user', content: 'hi from B', mediaIds: [], stickerIds: [] },
|
||||
])
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-anon-a')
|
||||
|
||||
// A's message stays alive; senderId still points at the now-orphan user.id string.
|
||||
const aMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-a-1') })
|
||||
expect(aMsg?.deletedAt).toBeNull()
|
||||
expect(aMsg?.senderId).toBe('u-anon-a')
|
||||
expect(aMsg?.content).toBe('hi from A')
|
||||
|
||||
// B's message obviously untouched.
|
||||
const bMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-b-1') })
|
||||
expect(bMsg?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('soft-deletes messages the user sent in private/bot chats', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-chat-2', name: 'M', email: 'msg@example.com' })
|
||||
await db.insert(schema.chats).values({ id: 'chat-msg', type: 'private', title: 't' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'chat-msg', memberType: 'user', userId: 'u-chat-2' })
|
||||
await db.insert(schema.messages).values([
|
||||
{
|
||||
id: 'msg-mine',
|
||||
chatId: 'chat-msg',
|
||||
senderId: 'u-chat-2',
|
||||
role: 'user',
|
||||
content: 'hi',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
},
|
||||
{
|
||||
id: 'msg-other',
|
||||
chatId: 'chat-msg',
|
||||
senderId: 'someone-else',
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
},
|
||||
])
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-chat-2')
|
||||
|
||||
const mine = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-mine') })
|
||||
const other = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-other') })
|
||||
|
||||
expect(mine?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(other?.deletedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Logger } from '@guiiai/logg'
|
||||
|
||||
/**
|
||||
* Reason a user deletion is being requested. Recorded in logs and surfaced
|
||||
* to handlers so they can branch (e.g. compliance erase vs. user-initiated).
|
||||
*
|
||||
* - `user-requested`: triggered by the user via better-auth `/delete-user/callback`.
|
||||
* - `admin`: triggered by an admin tool (not yet implemented).
|
||||
* - `compliance`: triggered by automated GDPR / data-retention workflow (not yet implemented).
|
||||
*/
|
||||
export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance'
|
||||
|
||||
/**
|
||||
* Context passed to every {@link UserDeletionHandler} invocation.
|
||||
*
|
||||
* Use when:
|
||||
* - Implementing a new business handler — read `userId` to scope your soft-delete writes.
|
||||
* - Logging within a handler — use the provided `logger` so entries share the deletion correlation context.
|
||||
*/
|
||||
export interface UserDeletionContext {
|
||||
/** The user being deleted. Handlers MUST scope their writes to this id. */
|
||||
userId: string
|
||||
/** Why the deletion was triggered. */
|
||||
reason: UserDeletionReason
|
||||
/** Pre-scoped logger for handler diagnostics. */
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* A registered participant in the account-deletion pipeline.
|
||||
*
|
||||
* Each business module that owns user-scoped tables registers one of these
|
||||
* with the {@link UserDeletionService}. Handlers run sequentially in
|
||||
* ascending `priority` order; a thrown error aborts the whole pipeline so
|
||||
* better-auth's hard-delete of the user row never runs (the user is left
|
||||
* intact and the operation can be retried idempotently).
|
||||
*
|
||||
* @example
|
||||
* createUserDeletionService().register({
|
||||
* name: 'flux',
|
||||
* priority: 20,
|
||||
* async softDelete({ userId }) {
|
||||
* await db.update(userFlux).set({ deletedAt: new Date() }).where(eq(userFlux.userId, userId))
|
||||
* },
|
||||
* })
|
||||
*/
|
||||
export interface UserDeletionHandler {
|
||||
/** Stable identifier used for logs, metrics, and duplicate-registration checks. */
|
||||
name: string
|
||||
/**
|
||||
* Lower runs first. Conventions:
|
||||
* - 10: external side-effects without rollback (Stripe API cancel)
|
||||
* - 20: financial / cache state (Flux balance, Redis invalidation)
|
||||
* - 30: pure DB soft-delete (providers, characters, chats)
|
||||
*
|
||||
* @default 30
|
||||
*/
|
||||
priority: number
|
||||
/**
|
||||
* Mark business records as deleted. MUST be idempotent — the deletion
|
||||
* pipeline retries by re-issuing the entire request, and Stripe / Postgres
|
||||
* already deduplicate on subsequent calls. Throw to abort the pipeline.
|
||||
*/
|
||||
softDelete: (ctx: UserDeletionContext) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator for account deletion across business modules.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring better-auth's `user.deleteUser.beforeDelete` hook in `libs/auth.ts`.
|
||||
* - Implementing an admin-triggered deletion path (future).
|
||||
*
|
||||
* Expects:
|
||||
* - All handlers are registered at app-composition time before the first
|
||||
* request hits `beforeDelete`. Late registration is allowed but discouraged.
|
||||
*/
|
||||
export interface UserDeletionService {
|
||||
/**
|
||||
* Register a handler. Throws if `handler.name` is already registered —
|
||||
* names must be unique so logs and metrics can attribute work cleanly.
|
||||
*/
|
||||
register: (handler: UserDeletionHandler) => void
|
||||
/**
|
||||
* Run every registered handler in priority order. Returns when all
|
||||
* handlers complete, or throws the first handler error and stops.
|
||||
*/
|
||||
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
// NOTICE:
|
||||
// This page is a SUCCESS landing — better-auth's `/api/auth/delete-user/callback`
|
||||
// performs the actual deletion server-side, then redirects here via the
|
||||
// `callbackURL` we passed to `authClient.deleteUser`. By the time the user
|
||||
// sees this page their session is already revoked and the user row is gone.
|
||||
// Source: node_modules/better-auth/dist/api/routes/update-user.mjs L380.
|
||||
//
|
||||
// No "back to home" button: this page lives on the API-server origin and has
|
||||
// no reliable way to point at the calling app (stage-web / stage-tamagotchi /
|
||||
// stage-pocket) — the API server doesn't know where the product UI is
|
||||
// deployed. Asking the user to close the tab is the simplest correct thing.
|
||||
//
|
||||
// Failure case (e.g. token expired, or `beforeDelete` handler threw): the
|
||||
// server returns a JSON error response from `/delete-user/callback` instead
|
||||
// of redirecting, so the user does not land here. Surfacing that gracefully
|
||||
// is a follow-up — for v1 we accept the raw API JSON in the error path.
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
// Optional ?error param if a future server hook redirects failed deletions
|
||||
// here with an explanatory string. Today this is always undefined.
|
||||
const errorMessage = computed(() => {
|
||||
const value = route.query.error
|
||||
return typeof value === 'string' ? value : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{
|
||||
errorMessage
|
||||
? t('server.auth.deleteAccount.title.failed')
|
||||
: t('server.auth.deleteAccount.title.success')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div :class="['max-w-sm flex flex-col items-center gap-4 text-center text-sm']">
|
||||
<p
|
||||
v-if="!errorMessage"
|
||||
:class="['text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.deleteAccount.message.success') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
:class="['text-red-500']"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!errorMessage"
|
||||
:class="['text-xs text-neutral-400 dark:text-neutral-500']"
|
||||
>
|
||||
{{ t('server.auth.deleteAccount.action.closeTab') }}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
@@ -93,6 +93,14 @@ forgotPassword:
|
||||
sent: If {email} matches an account, a reset link is on the way.
|
||||
error:
|
||||
fallback: We could not send the reset email.
|
||||
deleteAccount:
|
||||
title:
|
||||
success: Account deleted
|
||||
failed: Could not delete account
|
||||
message:
|
||||
success: Your account has been deleted.
|
||||
action:
|
||||
closeTab: You can close this tab.
|
||||
resetPassword:
|
||||
title:
|
||||
default: Set a new password
|
||||
@@ -132,7 +140,7 @@ profile:
|
||||
placeholder: How others see you
|
||||
avatar:
|
||||
altText: Profile avatar
|
||||
gravatarNotice: Showing your Gravatar avatar — based on a hash of your email.
|
||||
gravatarNotice: Avatar from Gravatar.
|
||||
gravatarLink: Manage on gravatar.com
|
||||
password:
|
||||
currentLabel: Current password
|
||||
|
||||
@@ -169,7 +169,7 @@ pages:
|
||||
label: Display name
|
||||
placeholder: How others see you
|
||||
avatar:
|
||||
gravatarNotice: Showing your Gravatar avatar — based on a hash of your email.
|
||||
gravatarNotice: Avatar from Gravatar.
|
||||
gravatarLink: Manage on gravatar.com
|
||||
action:
|
||||
save: Save changes
|
||||
@@ -229,9 +229,21 @@ pages:
|
||||
description: Irreversible account actions live here.
|
||||
deleteAccount:
|
||||
title: Delete account
|
||||
description: Permanently remove your account, sessions, and personal data.
|
||||
action: Delete
|
||||
notAvailable: Coming soon — contact support to delete your account today.
|
||||
description: Permanently delete your account and personal data.
|
||||
action: Delete account
|
||||
modal:
|
||||
title: Delete account?
|
||||
warning: This cannot be undone. Active subscription will be canceled, Flux balance cleared.
|
||||
confirmEmail:
|
||||
label: Type your email to confirm
|
||||
placeholder: ''
|
||||
mismatch: Email does not match.
|
||||
confirm: Send confirmation email
|
||||
cancel: Cancel
|
||||
message:
|
||||
emailSent: Check {email} for the deletion link.
|
||||
error:
|
||||
fallback: Could not start deletion. Try again later.
|
||||
card:
|
||||
activate: Activate
|
||||
active: Active
|
||||
|
||||
@@ -68,6 +68,14 @@ signIn:
|
||||
terms: 服务条款
|
||||
and: 和
|
||||
privacy: 隐私政策
|
||||
deleteAccount:
|
||||
title:
|
||||
success: 账号已删除
|
||||
failed: 无法删除账号
|
||||
message:
|
||||
success: 你的账号已被删除。
|
||||
action:
|
||||
closeTab: 你可以关闭此页面。
|
||||
profile:
|
||||
title: 账号资料
|
||||
description: 管理你的头像、显示名、密码和已绑定的社交账号。
|
||||
@@ -88,8 +96,8 @@ profile:
|
||||
placeholder: 别人看到的名字
|
||||
avatar:
|
||||
altText: 用户头像
|
||||
gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)。
|
||||
gravatarLink: 前往 gravatar.com 管理
|
||||
gravatarNotice: 头像来自 Gravatar。
|
||||
gravatarLink: 在 gravatar.com 管理
|
||||
password:
|
||||
currentLabel: 当前密码
|
||||
currentPlaceholder: 输入当前密码
|
||||
|
||||
@@ -160,8 +160,8 @@ pages:
|
||||
label: 显示名
|
||||
placeholder: 别人看到的名字
|
||||
avatar:
|
||||
gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)。
|
||||
gravatarLink: 前往 gravatar.com 管理
|
||||
gravatarNotice: 头像来自 Gravatar。
|
||||
gravatarLink: 在 gravatar.com 管理
|
||||
action:
|
||||
save: 保存修改
|
||||
message:
|
||||
@@ -220,9 +220,21 @@ pages:
|
||||
description: 这里的操作不可撤销,请谨慎使用。
|
||||
deleteAccount:
|
||||
title: 删除账号
|
||||
description: 永久删除你的账号、会话与个人数据。
|
||||
action: 删除
|
||||
notAvailable: 即将上线——如需立即删除账号请联系支持。
|
||||
description: 永久删除你的账号与个人数据。
|
||||
action: 删除账号
|
||||
modal:
|
||||
title: 确认删除账号?
|
||||
warning: 此操作不可撤销。订阅将被取消,Flux 余额将清空。
|
||||
confirmEmail:
|
||||
label: 输入邮箱以确认
|
||||
placeholder: ''
|
||||
mismatch: 邮箱不匹配。
|
||||
confirm: 发送确认邮件
|
||||
cancel: 取消
|
||||
message:
|
||||
emailSent: 删除链接已发送至 {email}。
|
||||
error:
|
||||
fallback: 无法发起删除,请稍后重试。
|
||||
card:
|
||||
activate: 激活
|
||||
active: 已激活
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
@@ -297,6 +298,80 @@ async function handleSendSetPasswordLink() {
|
||||
setPasswordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delete account ----
|
||||
//
|
||||
// Two-step flow:
|
||||
// (1) Click "Delete account" -> open a reka-ui Dialog with a focus-trap and
|
||||
// overlay so the destructive action is unambiguously modal. Inside the
|
||||
// dialog the user retypes their email; we only enable the submit button
|
||||
// when the entered value matches `userEmail` exactly. This is the same
|
||||
// irreversible-action pattern GitHub / Linear use.
|
||||
// (2) On confirm, call `authClient.deleteUser({ callbackURL })`. better-auth
|
||||
// emails a single-use link; clicking it runs the soft-delete handlers
|
||||
// server-side, hard-deletes the auth tables, then redirects the browser
|
||||
// to `callbackURL`. We point the callback at ui-server-auth's success
|
||||
// page on the API server origin — stage-web and stage-tamagotchi do not
|
||||
// own a dedicated post-delete route, and ui-server-auth is reachable
|
||||
// from every embedding app.
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteSent = shallowRef(false)
|
||||
const deleteForm = reactive({ confirmEmail: '' })
|
||||
const deleteLoading = shallowRef(false)
|
||||
const deleteError = shallowRef<string | null>(null)
|
||||
|
||||
const deleteEmailMatches = computed(() => {
|
||||
const target = userEmail.value?.trim().toLowerCase() ?? ''
|
||||
return target.length > 0 && deleteForm.confirmEmail.trim().toLowerCase() === target
|
||||
})
|
||||
|
||||
function openDeleteDialog() {
|
||||
deleteForm.confirmEmail = ''
|
||||
deleteError.value = null
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
// Reset transient form state whenever the dialog closes (cancel button, ESC,
|
||||
// overlay click). We deliberately keep `deleteSent` outside this reset so the
|
||||
// success message under the Danger Zone stays visible after the dialog is
|
||||
// dismissed by the user.
|
||||
watch(deleteDialogOpen, (open) => {
|
||||
if (!open) {
|
||||
deleteForm.confirmEmail = ''
|
||||
deleteError.value = null
|
||||
}
|
||||
})
|
||||
|
||||
async function handleConfirmDelete(event: Event) {
|
||||
event.preventDefault()
|
||||
if (deleteLoading.value || !deleteEmailMatches.value)
|
||||
return
|
||||
|
||||
deleteError.value = null
|
||||
deleteLoading.value = true
|
||||
|
||||
try {
|
||||
// The success page lives on the API-server origin (shared
|
||||
// ui-server-auth bundle). It tells the user the deletion completed
|
||||
// and asks them to close the tab — there is no "back to home"
|
||||
// because the API server has no reliable way to know which calling
|
||||
// app origin (stage-web / stage-tamagotchi / stage-pocket) the
|
||||
// request came from.
|
||||
const callbackURL = new URL('/auth/delete-account', SERVER_URL).toString()
|
||||
const { error } = await authClient.deleteUser({ callbackURL })
|
||||
if (error)
|
||||
throw new Error(error.message ?? 'deleteUser failed')
|
||||
|
||||
deleteSent.value = true
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
catch (error) {
|
||||
deleteError.value = errorMessageFrom(error) ?? t('settings.pages.account.danger.deleteAccount.error.fallback')
|
||||
}
|
||||
finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -702,10 +777,6 @@ async function handleSendSetPasswordLink() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- TODO: Wire up delete-account once server enables
|
||||
user.deleteUser in better-auth config. The endpoint sends a
|
||||
confirmation email and revokes all sessions, so the UX needs
|
||||
a confirmation modal + post-delete redirect. -->
|
||||
<div :class="['flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3']">
|
||||
<div :class="['flex flex-col gap-0.5 min-w-0']">
|
||||
<span :class="['text-sm font-medium']">
|
||||
@@ -718,14 +789,89 @@ async function handleSendSetPasswordLink() {
|
||||
<div :class="['flex-shrink-0']">
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled
|
||||
:title="t('settings.pages.account.danger.deleteAccount.notAvailable')"
|
||||
:label="t('settings.pages.account.danger.deleteAccount.action')"
|
||||
@click="openDeleteDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="deleteSent"
|
||||
:class="['text-sm text-green-600 dark:text-green-400 max-w-md']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ t('settings.pages.account.danger.deleteAccount.message.emailSent', { email: userEmail }) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Delete-account confirmation dialog. Uses reka-ui's Dialog so we
|
||||
get focus-trap, ESC-to-close, and overlay-click-to-close for
|
||||
free — destructive actions warrant a real modal, not an inline
|
||||
reveal. The email retype is the standard high-friction guard
|
||||
for irreversible account actions (GitHub / Linear use the same
|
||||
pattern). -->
|
||||
<DialogRoot v-model:open="deleteDialogOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
:class="[
|
||||
'fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm',
|
||||
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
|
||||
]"
|
||||
/>
|
||||
<DialogContent
|
||||
:class="[
|
||||
'fixed left-1/2 top-1/2 z-9999 -translate-x-1/2 -translate-y-1/2',
|
||||
'max-h-[90dvh] w-[92dvw] max-w-md overflow-y-auto',
|
||||
'rounded-2xl bg-white dark:bg-neutral-900',
|
||||
'p-6 shadow-xl outline-none',
|
||||
'data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow',
|
||||
]"
|
||||
>
|
||||
<DialogTitle :class="['text-lg font-semibold text-red-600 dark:text-red-400 mb-2']">
|
||||
{{ t('settings.pages.account.danger.deleteAccount.modal.title') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription :class="['text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-line mb-4']">
|
||||
{{ t('settings.pages.account.danger.deleteAccount.modal.warning') }}
|
||||
</DialogDescription>
|
||||
|
||||
<form :class="['flex flex-col gap-3']" @submit="handleConfirmDelete">
|
||||
<FieldInput
|
||||
v-model="deleteForm.confirmEmail"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
:label="t('settings.pages.account.danger.deleteAccount.modal.confirmEmail.label')"
|
||||
:placeholder="userEmail ?? t('settings.pages.account.danger.deleteAccount.modal.confirmEmail.placeholder')"
|
||||
/>
|
||||
<div
|
||||
v-if="deleteError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
<div :class="['flex justify-end gap-2 pt-1']">
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="deleteLoading"
|
||||
:label="t('settings.pages.account.danger.deleteAccount.modal.cancel')"
|
||||
/>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
:loading="deleteLoading"
|
||||
:disabled="!deleteEmailMatches"
|
||||
:label="t('settings.pages.account.danger.deleteAccount.modal.confirm')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Sign out at the page foot — mobile-only fallback because the
|
||||
sidebar (which owns logout on desktop) is hidden on small
|
||||
viewports. Kept outside the Danger Zone because logging out is
|
||||
|
||||
Reference in New Issue
Block a user