# A03 账号生命周期后端限定复审

复审日期：2026-10-03。结论：**本报告审查的第一段版本未发现残留的实证 P1/P2。** 两个 bulk P2 已通过独立前后复现闭环；单条快照失败另外完成两组实际补证，确认身份删除未执行。后续新增的在途写入 P2 不属于本子 agent 的通过结论；根代理已另行完成 7 组实际独立复验，见[追加报告](dramivio-account-session-write-fence.md)。

本次范围仅为 `短剧库/services/platform/src/` 下的 `auth.mjs`、`runtime.mjs`、`state.mjs`、`platform.mjs`，以及相关 BetterAuth 1.7.7 安装源和生命周期测试。业务文件由作者修改；复审者仅保存快照、读代码、编写并运行隔离复现。未审核并行 RN 新页面，未操作线上账号、发送真实邮件、安装依赖或进行原生编译、发布。邮箱更换与账号删除的完整产品流程不在本段通过结论内。

## 验证来源

| 来源 | 内容 | 结果 |
| --- | --- | --- |
| 作者验证，原候选 | 生命周期测试 | 12/12，通过；这一结果没有覆盖超过 100 条会话和 SDK 批量快照读失败 |
| 作者验证，最终冻结版本 | `tests/lifecycle.test.mjs` | 17/17，通过，7.179596 秒，进程退出码 0 |
| 作者验证，最终冻结版本 | 全平台测试，18 个顶层组 | 127/127，通过，12.952760 秒，进程退出码 0 |
| 作者验证，最终冻结版本 | 四个业务文件及新增测试的语法检查 | 全部退出码 0 |
| 本复审者独立验证，原候选 | 3 组真实 PostgreSQL + 官方 HTTP + 连接池 max=1 复现 | 复现下述两个 P2，脚本退出码 0 |
| 本复审者独立验证，最终冻结版本 | 同样 3 组检查，验证修复后的结果 | 3/3 闭环通过，脚本退出码 0 |
| 本复审者独立验证，最终冻结版本 | 2 组单条快照故障，各包含原 cookie 确认与显式重试 | 2/2 通过，脚本退出码 0 |

作者测试数量与耗时来自作者提供的完成结果，本复审者未重跑其全平台测试。独立复现另行构造场景，仅复用项目的隔离 PostgreSQL 启动工具；未调用作者的生命周期测试模块。每次运行使用新建的 helper-owned Docker PostgreSQL，结束后关闭 HTTP、连接池并移除该容器。

独立证据位于 `/Users/alienmu/Documents/Codex/2026-10-02/bang/work/account-lifecycle-review/`：

- `reproduce.mjs`：独立复现及修复断言。
- 根目录四个 `.mjs`：原 12 项候选快照；`fixed/` 中四个 `.mjs`：最终修复快照。只改模块导入与迁移文件引用的绝对路径以加载原安装包，业务逻辑原样保留。
- `observed.jsonl`：原候选的 3 组观测结果。
- `observed-fixed.jsonl`：修复版本的 3 组观测结果。
- `observed-single.jsonl`：2 组单条故障及各自重试，共 4 行观测结果。

复现先通过官方 OTP HTTP 在隔离环境注册、登录，再通过实际框架 internalAdapter 创建总计 101 条真实会话。测试批准仅由受信域 fixture 对实际 BA session ID 构造 `reviewed_installation`，不代表存在生产 App 安装证明发行器。输出只记录计数与状态，不输出 OTP、cookie 或会话 token。

## 已关闭的发现

**P2：超过 100 条会话时，官方批量撤销覆盖不完整。**

BetterAuth bulk hook 快照 未指定 limit；core adapter 默认 100。Kysely deleteMany 删除全部匹配行。`revokeOtherSessions` 的 internal listSessions 也使用这一默认限制。

独立复现用官方列表确定遗漏行，为该真实会话建立批准和 pending reservation，再请求批量撤销。结果如下：

| 官方请求 | 原候选 HTTP/身份结果 | 原候选批准与预留 | 修复版本结果 |
| --- | --- | --- | --- |
| `revoke-sessions`，101 条会话 | 200，`status:true`；身份会话剩 0 | 遗漏批准未撤销，预留仍 pending | 列表返回 101；身份剩 0；批准已撤销，预留 released |
| `revoke-other-sessions`，101 条会话 | 200，`status:true`；身份会话剩 2，应仅保留当前 1 条 | 遗漏批准未撤销，预留仍 pending | 身份仅剩当前 1 条；批准已撤销，预留 released |

修复采用 官方 defaultFindManyLimit 配置 `Number.MAX_SAFE_INTEGER`，避免另设任意固定采样上限。作者另外验证了 105 条 Others 和补充 105 条 All 场景。独立前后观测已确认关闭。

**P2：SDK 批量快照读失败被吞掉，可能跳过 before callback 后继续删除身份会话。**

with-hooks.mjs 对快照 `findMany` 的异常空 catch，随后仍执行 `deleteMany`。原 runtime 的批准 lookup 故障测试不能证明这一框架层故障安全。

独立 fixture 对 SDK 的实际批量快照 SELECT 注入一次错误，并断言命中次数为 1。原候选仍返回 200/`status:true`，身份行全部删除，但批准未撤销、reservation 仍 pending。最终版本同一注入下身份行删除，批准已撤销、reservation 为 released。

修复在 官方 bulk 请求 before 中先通过官方 `getSession()` 获取当前 owner/session；runtime 从现有批准表与官方 session 表 JOIN 取得实际集合并先撤销 E00 域批准。Others 排除当前会话并匹配有效会话；All 覆盖该 owner。owner 来源不读取客户端 body。单条删除 hook 继续保留。独立故障观测已确认关闭。

## 单条快照故障的范围纠正

单删快照也有空 catch，但当前 PostgreSQL、无 secondaryStorage 路径的 实际 delete 条件 要求 `entityToDelete` 存在。快照失败会令该值为 null，故官方身份行也不删除；不能将 bulk 的“身份删除、域漏撤”结果套到 single。

独立注入实际 token/limit=1 的 SDK 快照 SELECT 各一次，并分别验证当前会话 sign-out 与同 owner 的 target revoke-session：

| 请求与故障 | HTTP 返回 | 目标身份与原 cookie | 批准与预留 | 显式重试后 |
| --- | --- | --- | --- | --- |
| sign-out，快照读取失败 | 200，`success:true` | 目标 BA 行仍 1；原 cookie GET 200 且账号有效 | 批准未撤，预留 pending | BA 行 0；批准已撤；预留 released |
| revoke-session，快照读取失败 | 200，`status:true` | 目标 BA 行仍 1；原 target cookie GET 200 且账号有效 | 批准未撤，预留 pending | BA 行 0；批准已撤；预留 released |

两组均断言错误注入次数为 1。它们确认 HTTP 200 仍不能证明删除完成，同时原 single hook 没有造成跨域失配；未要求新增 single before 或业务重复读取。App 必须根据后续官方 get/list 的实际目标状态确认结果；本报告未代替根代理的 RN 审核。

## 冻结代码核对

- runtime.mjs：单删仅查已有批准，无批准的 Web 会话跳过；有批准时校验 owner，并等待 E00 撤销。普通查询释放连接后才进入域事务。四条普通 sign-out/revoke 路径没有 BA 外层事务；作者及独立 max=1 运行未出现嵌套连接死锁。这一结论不扩展到任意自行包裹的框架内部事务调用。
- auth.mjs：`session.create.before` 使用实际 session.userId；`session.update.before` 使用框架当前 session 上下文。SDK get-session 在续期前设置该上下文，字段与时序对应。
- auth.mjs：身份读写前直接调用官方 route，强制 `disableCookieCache` 和 `disableRefresh`，再检查终态；get-session after 再拒绝迟到结果。直接函数调用明确 跳过全局 hooks，因此不会递归。显式清单覆盖 get-session、update-user、update-session、change-email 和两个 email-OTP change 路径；不能将该清单描述为自动覆盖所有官方路由。
- state.mjs：保持原 catalog/history 表锁在前，再创建或锁现有 E00 owner 行，验证 kind、terminal、merged 状态，然后锁 consumer_state_owner。following、begin、report、history delete 共用这一入口。缺行先 insert-on-conflict，避免空 SELECT 无行锁的竞态；缺 E00 表抛错，不降级为活跃账号。没有新增 store/hash。作者实际等锁后终态用例验证 report 返回 403，sequence/position 不落新值。
- platform.mjs：个人命令在身份解析后检查终态；错误映射 兼容 APIError.statusCode，个人 API 保留 403，内部身份桥接的 account_terminal 转为 200/`user:null`。

## 必须保留的框架语义与范围

官方 sign-out 吞掉会话删除异常，清客户端 cookie 并继续返回成功。因此 sign-out 200 不能证明服务端会话已撤销；现作者故障测试已明确这一点。运行时撤销 callback 抛错时，revokeSession/revokeSessions 返回 500，revokeOtherSessions 的错误直接传播；SDK 自己吞掉的单条快照错误则呈现上表的 200/未执行。未发现 get-session after 抛 APIError 被误包装成 HTTP 200 的问题。

E00 撤销事务与官方身份删除不是一个跨框架原子事务。域撤销先完成、后续身份删除失败，或批量操作中途失败时，可能已经撤销部分批准；这种顺序只收紧许可，不能宣称全有或全无，也不能依据名义响应盲目确认所有目标。新 bulk before 关闭的是已复现的默认截断和快照故障结果。

全量会话列表现在会读取该用户实际集合，本次未做大规模会话资源压力测试。max=1 证据覆盖普通官方操作，不保证任意新增外层事务的资源行为。复现资源均属于隔离 fixture，结束后清理。

本结论不证明生产安装 attestation/reviewed-installation 的真实发行器、离线许可、原生缓存立即撤销、配额策略或管理员流程；未启用账号删除。RN 页面、退出后的本机清理与未知结果确认由根代理另行审核，不属于此后端报告的通过范围。
