diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca9394f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +本文件是代理在本仓库工作的常驻入口。这里只保留每次任务都应知道的规则;具体机制按任务阅读 [`docs/`](docs/README.md) 中的专题文档。代码和测试是当前行为的最终事实来源。 + +## 沟通语言 + +所有对话回复必须使用简体中文。代码、命令、路径、标识符、API 路径、配置项、日志原文、英文专有名词与缩写保持原样。 + +## 项目概览 + +Codex Helper 是单容器部署的 Codex 账户用量仪表盘,通过 Codex app-server 读取 ChatGPT/Codex 账户、限额和 Token 历史,并通过 Telegram 与 SMTP 发送重置提醒。 + +- `backend/`:Go 1.26、`net/http` 与 SQLite 后端;[`backend/CONTRACT.md`](backend/CONTRACT.md) 是 API 路径、状态码、响应结构和兼容文案的契约。 +- `frontend/`:React 19、TypeScript、Vite、Recharts 前端;生产构建嵌入 Go 二进制。 +- `Dockerfile`:依次构建前端、Go 后端和固定版本 Codex CLI,最终以 UID `10001` 非 root 用户运行。 +- `docker-compose.yml`:对外映射端口并将全部运行数据保存到 `codex-helper-data` 卷的 `/data`。 + +## 开始工作前 + +1. 先阅读改动相关的代码、调用方和测试,不要仅凭文档推断行为。 +2. 从 [`docs/README.md`](docs/README.md) 选择对应专题;修改 API 时同时核对 [`backend/CONTRACT.md`](backend/CONTRACT.md)。 +3. 修改鉴权、SQLite、账号删除、Codex 进程、凭据、提醒、备份或部署时,先读 [`docs/reference/engineering-invariants.md`](docs/reference/engineering-invariants.md)。 +4. 执行代码审查时,必须遵循 [`docs/reference/code-review-rules.md`](docs/reference/code-review-rules.md)。 + +## 环境与执行政策 + +宿主机只保证 Docker 和 Docker Compose。依赖安装、Go/Node 命令、构建、测试、格式化、类型检查和安全扫描均在容器内执行;不得在宿主机安装或直接运行 `go`、`node`、`npm` 等工具,也不得修改宿主机工具链或 shell 配置。 + +后端基线为 `golang:1.26.0-bookworm`,前端为 `node:24.19.0-bookworm-slim`,E2E 使用与 `@playwright/test` 版本匹配的官方 Playwright 镜像。完整命令见 [`docs/guides/development-and-validation.md`](docs/guides/development-and-validation.md)。纯文档改动无需运行应用构建。 + +## 验证与审查 + +- 行为修改必须验证成功路径、关键边界和失败路径;修复缺陷时优先添加能复现原问题的回归测试。 +- 后端至少运行相关 Go 测试以及 `gofmt` 检查、`go build ./...` 和 `go vet ./...`;SQLite 迁移、并发或备份改动必须运行相关集成测试。 +- 前端至少运行相关 Vitest 和 `npm run build`;布局、路由、账号设置或响应式交互改动运行相关 Playwright 测试。 +- Dockerfile、Compose、持久化或运行用户改动必须验证镜像或 Compose 配置,并核对升级数据路径。 +- 代码审查只报告可复现、由当前改动引入或暴露的问题,按 P0–P3 排序;完整流程见 [`docs/reference/code-review-rules.md`](docs/reference/code-review-rules.md)。 + +## 必须保持的约束 + +- HTTP 业务接口保持在 `/api/v1/`;未初始化状态只开放 status、setup 和 login,其他接口必须经过 session 与非只读请求来源校验。 +- `backend/internal/store/store.go` 中的 schema 和兼容迁移是 SQLite 结构的事实来源;启动迁移必须保留旧库数据并保持幂等。 +- 账号 ID `1` 的 Codex 凭据固定保留在 `/data/codex`;其他账号使用 `/data/accounts//codex`,升级时不得搬迁旧路径。 +- 每个账号拥有独立 app-server 进程和 `CODEX_HOME`;启动、初始化、同步和停止必须维持现有串行化与并发保护。 +- 删除账号会删除数据库历史和对应凭据目录,是不可恢复操作;前后端必须保持明确确认与精确目标。 +- session cookie 只保存随机 token,数据库只存 SHA-256 摘要;密码继续使用 argon2id,SMTP 密码和 Telegram Token 继续由 `/data/secret.key` 加密。 +- 设置接口不得返回 SMTP 密码或 Telegram Token 明文;秘密不得进入 Git、日志、前端状态快照或 Docker 构建上下文。 +- 限额百分比、窗口和 Token 摘要以 app-server 返回值为准;optional、`null`、多 bucket 和未知套餐必须安全降级。 +- 提醒以稳定 dedupe key 去重,失败只在计划时间后六小时窗口内重试;提前、计划后和异常提前重置语义不得混淆。 +- SQLite 下载仅是包含已提交 WAL 数据的一致性数据库快照,不包含 `secret.key` 或 Codex 凭据;完整恢复必须备份整个 `/data`。 +- 前端路由和隐藏控件只负责交互,安全边界必须由后端强制执行;账号邮箱在界面中继续掩码显示。 + +## 文档维护 + +- 后端运行、认证、Codex 集成、数据和通知机制写入 `docs/backend/`;前端实现写入 `docs/frontend/`。 +- 可执行的开发与部署步骤写入 `docs/guides/`;工程不变量和代码审查规则写入 `docs/reference/`。 +- 用户功能和部署概览继续维护在根 `README.md`;端点级 API 契约维护在 `backend/CONTRACT.md`,专题文档通过链接引用。 +- 行为变更必须同步更新相关文档;文档与实现冲突时先以代码和测试为准,再修正文档。 diff --git a/README.md b/README.md index 0acdfd1..0ae9c49 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ docker compose restart codex-helper 前端与后端分别位于 `frontend/` 和 `backend/`。 +维护项目或使用编码代理前,请先阅读 [AGENTS.md](AGENTS.md) 和[维护者文档中心](docs/README.md);修改 HTTP 接口时同时核对[后端 API 契约](backend/CONTRACT.md)。维护者的可复现验证基线统一使用 Docker,下面的宿主机命令仅用于人工本地开发。 + 启动前端开发服务器: ```bash diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md new file mode 100644 index 0000000..0cd50e3 --- /dev/null +++ b/backend/CONTRACT.md @@ -0,0 +1,105 @@ +# Codex Helper 后端 API 契约 + +本文件记录当前 HTTP 兼容边界。路径、方法、鉴权、状态码、响应字段和用户可见错误文案发生变化时,必须同步修改本文件。代码和测试是运行行为的最终事实来源。 + +## 1. 全局行为 + +- 默认监听 `${LISTEN_ADDR:-:8080}`,API 前缀为 `/api/v1/`。 +- `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。 +- JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。 +- 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。 +- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用。status 的其他方法返回 405;其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。 +- session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。 +- 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。 + +## 2. 公共对象 + +### Account + +```text +{id, displayName, email:string|null, planType:string|null, + expectedKind:"any"|"personal"|"team", + actualKind:"unknown"|"personal"|"team", + validationStatus:"pending"|"matched"|"mismatch"|"unknown", + possibleDuplicate:bool, connected:bool, createdAt, updatedAt} +``` + +### Dashboard + +```text +{accountId, displayName, + account:{email:string|null, authMode:string|null, planType:string|null, connected:bool}, + limits:[{limitId, limitName:string|null, windowType, + usedPercent, windowDurationMinutes, resetsAt, planType:string|null}], + summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?, + currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?}, + usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}], + fetchedAt, stale, lastError?} +``` + +时间字段为 Unix 秒。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。 + +## 3. 系统、初始化与会话 + +| 方法与路径 | 鉴权 | 行为 | +| --- | --- | --- | +| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。当前版本字段为 `0.2.0`。 | +| `POST /api/v1/setup` | 匿名、仅未初始化 | body `{username,password,timezone}`;用户名至少 3 位、密码至少 10 位,否则 400;时区有效时写入,否则使用默认 UTC。事务创建唯一管理员和通用设置,设置 session,返回 `201 {ok:true}`;已初始化返回 409。 | +| `POST /api/v1/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 | +| `GET /api/v1/auth/me` | session | `200 {username}`。 | +| `POST /api/v1/auth/logout` | session + 来源头 | 删除当前 session、清除 cookie,返回 `200 {ok:true}`。 | + +兼容文案包括:`系统已初始化`、`用户名至少3位,密码至少10位`、`请先初始化`、`用户名或密码错误`、`尝试次数过多,请稍后再试`、`未登录`、`请求来源校验失败`。 + +## 4. Codex 账号与用量 + +| 方法与路径 | 请求与响应 | +| --- | --- | +| `GET /api/v1/accounts` | 返回 `200 Account[]`,按 ID 升序。 | +| `POST /api/v1/accounts` | body `{displayName,expectedKind}`;空名称默认为 `新账号`,空类型默认为 `any`;成功返回 `201 Account`。 | +| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?}`;名称不能为空,省略类型时保留旧值;成功返回 `200 {ok:true}`。 | +| `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 | +| `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 | +| `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 | +| `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 | +| `GET /api/v1/dashboard?accountId={id}` | 返回内存中的 `Dashboard`;省略或无效的零值 ID 使用账号 1。 | +| `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 | + +账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。 + +## 5. 设置与通知 + +### 通用设置 + +`GET /api/v1/settings/general` 返回: + +```text +{timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter} +``` + +`PUT /api/v1/settings/general` 接受完整对象。`syncMinutes` 为 1–60,`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。 + +### SMTP + +- `GET /api/v1/settings/smtp` 返回 `{host,port,username,from,fromName,to,security,enabled,configured}`;`password` 为空或省略,默认端口 587、默认 `security=starttls`。 +- `PUT /api/v1/settings/smtp` 接受完整设置;`password` 留空时保留旧秘密。host、合法端口、from、to 必填;成功响应不返回密码。 +- `POST /api/v1/settings/smtp/test` 使用已保存配置发送测试邮件;成功 `200 {ok:true}`,发送失败 502。 +- `security` 的当前运行值为 `starttls`、`tls` 或 `none`。 + +### Telegram + +- `GET /api/v1/settings/telegram` 返回 `{chatId,enabled,menuEnabled,configured,botName?}`,不返回 Token 明文。 +- `PUT /api/v1/settings/telegram` 接受 `{token,chatId,enabled,menuEnabled}`;Token 留空时保留旧值,保存前调用 Bot API `getMe` 验证。失败返回 400 或 502。 +- `POST /api/v1/settings/telegram/bind` 返回六位 `{code}`,绑定码有效十分钟。 +- `POST /api/v1/settings/telegram/test` 向已绑定会话发送测试消息;未绑定或发送失败返回 502。 + +## 6. 维护接口 + +| 方法与路径 | 行为 | +| --- | --- | +| `POST /api/v1/maintenance/cleanup` | 按当前保留天数删除旧限额快照、通知和每日用量,返回 `200 {deleted}`。 | +| `GET /api/v1/maintenance/backup` | 使用 SQLite `VACUUM INTO` 生成包含已提交 WAL 数据的一致性快照,并以 `codex-helper.db` 下载。快照不含 `/data/secret.key` 或 Codex 凭据目录。 | + +## 7. 外部协议边界 + +每个账号通过 JSONL stdio 与 `codex app-server` 通信。当前使用的方法为 `initialize`、`account/read`、`account/login/start`、`account/logout`、`account/rateLimits/read` 和 `account/usage/read`,并响应 `account/login/completed`、`account/updated`、`account/rateLimits/updated` 通知。官方协议说明见 [Codex App Server](https://learn.chatgpt.com/docs/app-server);本项目以 Dockerfile 固定的 Codex CLI 版本、当前解析代码和测试作为兼容基线。 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 70642fa..480d734 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -19,6 +19,10 @@ import ( func (a *App) api(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/api/v1/") if p == "system/status" { + if r.Method != http.MethodGet { + jsonOut(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } connected := false a.mu.RLock() for _, rt := range a.runtimes { @@ -97,6 +101,12 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { rt.syncing.Lock() d := rt.dash rt.syncing.Unlock() + if d.Limits == nil { + d.Limits = []LimitBucket{} + } + if d.Usage == nil { + d.Usage = []UsagePoint{} + } jsonOut(w, 200, d) } case p == "sync" && r.Method == "POST": diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index 2bc423e..c5b823d 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -96,22 +96,38 @@ func (a *App) smtpTest(w http.ResponseWriter, r *http.Request) { } jsonOut(w, 200, map[string]bool{"ok": true}) } + +const smtpTimeout = 35 * time.Second + func sendSMTP(s SMTPSettings, subject, textBody, htmlBody string) error { + return sendSMTPWithTimeout(s, subject, textBody, htmlBody, smtpTimeout) +} + +func sendSMTPWithTimeout(s SMTPSettings, subject, textBody, htmlBody string, timeout time.Duration) error { addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port)) - var c *smtp.Client - var e error - if s.Security == "tls" { - conn, x := tls.Dial("tcp", addr, &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}) - if x != nil { - return x - } - c, e = smtp.NewClient(conn, s.Host) - } else { - c, e = smtp.Dial(addr) - } + dialer := net.Dialer{Timeout: timeout} + conn, e := dialer.Dial("tcp", addr) if e != nil { return e } + if e = conn.SetDeadline(time.Now().Add(timeout)); e != nil { + _ = conn.Close() + return e + } + var c *smtp.Client + if s.Security == "tls" { + tlsConn := tls.Client(conn, &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}) + if e = tlsConn.Handshake(); e != nil { + _ = conn.Close() + return e + } + conn = tlsConn + } + c, e = smtp.NewClient(conn, s.Host) + if e != nil { + _ = conn.Close() + return e + } defer c.Close() if s.Security == "starttls" { if e = c.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); e != nil { diff --git a/backend/internal/app/reminder_test.go b/backend/internal/app/reminder_test.go index 0976e27..edc6530 100644 --- a/backend/internal/app/reminder_test.go +++ b/backend/internal/app/reminder_test.go @@ -1,6 +1,7 @@ package app import ( + "net" "strconv" "strings" "testing" @@ -9,6 +10,42 @@ import ( "codex-helper/internal/store" ) +func TestSendSMTPStopsWhenServerDoesNotRespond(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan net.Conn, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr == nil { + accepted <- conn + } + }() + host, portRaw, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portRaw) + if err != nil { + t.Fatal(err) + } + started := time.Now() + err = sendSMTPWithTimeout(SMTPSettings{Host: host, Port: port, From: "from@example.com", To: "to@example.com"}, "subject", "text", "html", 100*time.Millisecond) + if err == nil { + t.Fatal("sendSMTPWithTimeout unexpectedly succeeded") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("SMTP timeout took %s", elapsed) + } + select { + case conn := <-accepted: + _ = conn.Close() + default: + } +} + func newReminderTestApp(t *testing.T) *App { t.Helper() s, err := store.Open(t.TempDir()) diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 88a2628..2f40fab 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -2,12 +2,53 @@ package app import ( "context" + "encoding/json" "errors" + "net/http" "net/http/httptest" "sync" "testing" + "time" + + "codex-helper/internal/security" ) +func TestSystemStatusRejectsNonGETMethods(t *testing.T) { + a := newReminderTestApp(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/system/status", nil) + a.api(recorder, request) + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { + a := newReminderTestApp(t) + a.runtimes[1] = &accountRuntime{} + _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix()) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil) + request.AddCookie(&http.Cookie{Name: "session", Value: "test-session"}) + a.api(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var body struct { + Limits []LimitBucket `json:"limits"` + Usage []UsagePoint `json:"usage"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Limits == nil || body.Usage == nil { + t.Fatalf("nil lists in response: %s", recorder.Body.String()) + } +} + type fakeCodexClient struct { mu sync.Mutex connected bool diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c1f0242 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Codex Helper 文档中心 + +代码和测试是当前行为的最终事实来源。本目录解释跨文件机制、工程约束和操作流程,不替代源码、根 `README.md` 或后端 API 契约。 + +## 按任务导航 + +| 任务 | 必读文档 | +| --- | --- | +| 修改启动、路由、中间件、健康检查或后台任务 | [`backend/runtime-and-api.md`](backend/runtime-and-api.md) | +| 修改初始化、登录、session、请求来源或秘密 | [`backend/authentication-and-security.md`](backend/authentication-and-security.md) | +| 修改 Codex 登录、账号进程、同步、套餐或用量解析 | [`backend/codex-integration-and-usage.md`](backend/codex-integration-and-usage.md) | +| 修改 SQLite、提醒、Telegram、SMTP、清理或备份 | [`backend/data-notifications-and-backup.md`](backend/data-notifications-and-backup.md) | +| 修改 React 路由、总览、设置、状态或 API 调用 | [`frontend/application.md`](frontend/application.md) | +| 开发、构建、测试或检查格式 | [`guides/development-and-validation.md`](guides/development-and-validation.md) | +| 修改镜像、Compose、运行用户或持久化 | [`guides/deployment.md`](guides/deployment.md) | +| 修改高风险行为或核对工程约束 | [`reference/engineering-invariants.md`](reference/engineering-invariants.md) | +| 代码审查 | [`reference/code-review-rules.md`](reference/code-review-rules.md) | +| 修改 API 路径、状态码、字段或兼容文案 | [`../backend/CONTRACT.md`](../backend/CONTRACT.md) | + +## 目录职责 + +- `backend/`:后端运行、认证、Codex 集成、数据和通知的实现事实。 +- `frontend/`:前端路由、状态、渲染和 API 集成。 +- `guides/`:可执行的开发、验证和部署流程。 +- `reference/`:工程不变量与代码审查等查表型规则。 + +## 维护规则 + +同一事实只保留一个权威位置,其他文档通过链接引用。用户功能和部署概览继续维护在根 [`README.md`](../README.md);端点级 API 契约维护在 [`backend/CONTRACT.md`](../backend/CONTRACT.md)。只有出现需要长期保留取舍背景的真实决策时,才新增 ADR,不创建空目录或占位文件。 diff --git a/docs/backend/authentication-and-security.md b/docs/backend/authentication-and-security.md new file mode 100644 index 0000000..7e6a800 --- /dev/null +++ b/docs/backend/authentication-and-security.md @@ -0,0 +1,25 @@ +# 认证与安全 + +## 初始化与管理员 + +新数据库始终创建账号表中的默认 Codex 账号,但只有 `settings.initialized` 存在才视为完成安装。`POST /api/v1/setup` 在事务中创建唯一 `admin(id=1)`、通用设置和安装标记;用户名至少 3 位、密码至少 10 位。首次初始化没有额外安装令牌,因此初始化完成前不得把实例直接暴露到不可信网络。 + +管理员密码使用 argon2id(3 次、64 MiB、2 lanes、32 字节结果和随机 salt)保存。当前没有改密或找回接口;不要通过新增旁路直接写入明文或弱摘要。 + +## Session 与请求来源 + +登录成功生成 32 字节随机 token,客户端得到七天 `HttpOnly`、`SameSite=Strict` cookie,SQLite 只保存 SHA-256 摘要。每次受保护请求回查未过期 session。登出删除当前摘要并清 cookie。 + +所有非 `GET`/`HEAD` 受保护请求还必须携带 `X-Requested-With: codex-helper`。这是当前同源部署下的额外 CSRF 门禁,不替代 session 校验,也不意味着可以放宽 CSP 或 cookie 策略。`Secure` 当前为 false,以支持 README 中直接 HTTP 部署;公网必须由 HTTPS 反向代理保护,调整此兼容行为时同步评估代理终止 TLS 的方式。 + +登录失败限流仅存在于单进程内,以 `RemoteAddr` 为 key,每 15 分钟最多 10 次。修改反向代理或客户端 IP 处理时,不能未经可信代理白名单就相信任意转发头。 + +## 密钥与外部凭据 + +`security.OpenVault` 首次启动创建权限 `0600` 的 `/data/secret.key`。SMTP 密码和 Telegram Bot Token 使用该 32 字节密钥经 AES-GCM 加密,密文写入 SQLite;GET 和保存响应不得返回秘密明文,空密码/Token 表示保留旧值。 + +Codex OAuth 凭据由 app-server 写入各账号隔离的 `CODEX_HOME`,不经过浏览器或 SQLite。`secret.key`、数据库、Codex 目录和日志都可能包含敏感运行信息,不得提交 Git、加入镜像层或复制到前端。 + +## HTTP 边界 + +JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。 diff --git a/docs/backend/codex-integration-and-usage.md b/docs/backend/codex-integration-and-usage.md new file mode 100644 index 0000000..aa40952 --- /dev/null +++ b/docs/backend/codex-integration-and-usage.md @@ -0,0 +1,35 @@ +# Codex 集成与用量同步 + +## 账号隔离与进程生命周期 + +每个 `accounts` 记录对应一个 `accountRuntime` 和独立 `codex app-server` 子进程。账号 1 为升级兼容固定使用 `/data/codex`;其他账号使用 `/data/accounts//codex`。该目录通过子进程 `CODEX_HOME` 注入,保存并刷新 ChatGPT 登录凭据。 + +`ensureReady` 用 lifecycle mutex 串行冷启动:创建目录、启动子进程、在 20 秒内调用 `initialize`,成功后才标记 ready。仅子进程存活不代表协议已初始化;失败进程必须关闭后重试。`syncing` mutex 串行同账号同步并保护 Dashboard,`stateMu` 保护 ready/stopped。删除账号先从应用 map 移除并停止进程,再删除数据库和精确凭据目录。 + +## JSONL 协议与登录 + +客户端以 stdio 启动 `codex app-server`,每行一个 JSON 消息。递增 ID 将响应关联到 buffered channel;请求受调用 context 或 20 秒 timeout 控制。旧进程晚退出时不能把替代进程标记为断开。 + +设备码登录调用: + +```text +account/login/start {type:"chatgptDeviceCode"} +``` + +前端展示 `verificationUrl` 与 `userCode`。收到 `account/login/completed`、`account/updated` 或 `account/rateLimits/updated` 后异步重拉数据;登录完成最多以 0、1、3 秒退避等待套餐分类可用。官方流程和字段见 [Codex App Server 文档](https://learn.chatgpt.com/docs/app-server)。Dockerfile 固定的 CLI 版本及项目测试仍是实际兼容基线。 + +## 同步数据流 + +一次同步依次读取 `account/read`、`account/rateLimits/read` 和 `account/usage/read`: + +- `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。 +- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平。失败时保留空限额而不令整次同步失败。 +- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。 +- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。 +- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。 + +`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。 + +## 套餐类型 + +用户期望类型仅为连接后的校验提示:`any`、`personal`、`team`。当前 personal 包括 free/go/plus/pro/prolite;team 包括 team/business 及两种 self-serve business 标识。未知新套餐必须显示 unknown,不能静默当作某一类型。相同邮箱和相同已知实际类型的多个账号标记 `possibleDuplicate`,但不会自动合并或删除。 diff --git a/docs/backend/data-notifications-and-backup.md b/docs/backend/data-notifications-and-backup.md new file mode 100644 index 0000000..96c24b7 --- /dev/null +++ b/docs/backend/data-notifications-and-backup.md @@ -0,0 +1,32 @@ +# 数据、通知与备份 + +## SQLite 与迁移 + +数据库位于 `${DATA_DIR:-/data}/codex-helper.db`,启用 WAL、5 秒 busy timeout 和 foreign keys。`backend/internal/store/store.go` 是 schema 与启动迁移的事实来源: + +- `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。 +- `admin` 与 `sessions` 保存唯一管理员和登录会话。 +- `accounts` 保存 Codex 连接元数据与期望套餐类型。 +- `daily_usage` 和 `limit_snapshots` 按 `account_id` 保存历史,删除账号时级联删除。 +- `notifications` 保存稳定去重键、调度时间、结构化消息、状态、次数和错误。 +- `telegram_updates` 保存 Bot API offset。 + +启动迁移必须幂等并兼容早期单账号库:创建默认账号 1,把旧用量和限额数据迁入该账号,补 `expected_kind` 和通知 body。schema 变化应增加覆盖旧结构且保留已有行的测试。 + +## 清理与备份 + +每分钟调度器根据 `retentionDays` 清理旧限额、通知和每日用量;允许范围为 30–365 天。`maintenance/backup` 使用 SQLite `VACUUM INTO` 创建独立一致性快照,包含已提交 WAL 数据且不中断写入。 + +数据库快照不包含 `/data/secret.key`、`/data/codex` 或 `/data/accounts/*/codex`,因此不能单独恢复通知凭据和 Codex 登录。完整灾难恢复必须停止容器并备份、恢复整个 `/data`,同时保持 UID `10001` 可读。不得把 `docker compose down -v` 写成普通升级步骤。 + +## 提醒生成与重试 + +限额快照按 `(account_id, limit_id, window_type)` 比较。计划提醒的 key 还包含 `resets_at` 和 `before|after`;异常提前重置以旧快照 ID 生成 `detected_after` key。百分比回落必须超过 `0.01`,旧快照不得超过六小时。 + +处理器每分钟为当前 Dashboard 生成到期提醒,并只发送 `scheduled_at` 后六小时内的未发送记录。Telegram 与 SMTP 中任何启用渠道失败都会把记录标记为 failed,后续周期在窗口内重试;全部启用渠道成功才标记 sent。稳定 key 和 `INSERT OR IGNORE` 是防重复边界。 + +## Telegram 与 SMTP + +Telegram 保存加密 Token、Chat ID、启用开关和菜单开关。保存 Token 前调用 `getMe`;long polling timeout 为 25 秒,HTTP client timeout 为 35 秒。六位绑定码十分钟有效且一次成功后清除。只有绑定 Chat ID 且启用菜单时才处理查询命令。 + +SMTP 支持 `starttls`、隐式 `tls` 和 `none`,TLS 最低 1.2;支持可选 PLAIN AUTH,发送 multipart text/html。TCP 连接和后续 SMTP/TLS 读写共享 35 秒 deadline。修改外部调用时必须保留这一超时边界、TLS server name、HTML 转义和不记录秘密的错误处理。 diff --git a/docs/backend/runtime-and-api.md b/docs/backend/runtime-and-api.md new file mode 100644 index 0000000..1bc0119 --- /dev/null +++ b/docs/backend/runtime-and-api.md @@ -0,0 +1,26 @@ +# 后端运行与 API + +## 启动与关闭 + +入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、每分钟调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。 + +`codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。 + +## 请求链路 + +- `internal/app/app.go`:应用生命周期、账号运行时、路由、静态前端和通用 HTTP 辅助函数。 +- `internal/app/api.go`:`/api/v1/` 分派、初始化、会话、账号和用量同步。 +- `internal/app/notify.go`:SMTP、Telegram、提醒生成和发送。 +- `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。 +- `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。 + +所有路由由 `http.ServeMux` 承载。API 先处理三个匿名入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。 + +## 后台任务 + +- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。 +- `scheduler` 每分钟按 `syncMinutes` 的 Unix 时间取模触发全账号同步,清理过期历史,并异步处理提醒。 +- `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。 +- app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。 + +这些 goroutine 都以应用 context 为退出边界。修改调度时必须避免无限阻塞、重复启动、停止后重启和同一账号并发同步。 diff --git a/docs/frontend/application.md b/docs/frontend/application.md new file mode 100644 index 0000000..8c07617 --- /dev/null +++ b/docs/frontend/application.md @@ -0,0 +1,25 @@ +# 前端应用 + +前端位于 `frontend/`,使用 React 19、TypeScript、Vite、React Router、Lucide 和 Recharts。`frontend/src/main.tsx` 当前集中承载应用组件和 API 数据类型,`frontend/src/api.ts` 是 fetch 包装层。 + +## 状态与路由 + +应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。这些分支只负责交互,服务端 session 才是安全边界。 + +主题和当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并每 30 秒刷新内存数据;切换账号必须清空旧 Dashboard,避免短暂展示另一账号信息。手动刷新调用账号级 sync 后重新读取 Dashboard。 + +## API 客户端 + +所有请求使用相对 `/api/v1/`、`credentials: same-origin`、JSON content type 和 `X-Requested-With: codex-helper`。非 2xx 响应优先显示 `{error}`,否则退化为 HTTP 状态。新增下载或非 JSON 响应不能直接套用当前 `api` helper。 + +后端的 optional、`null`、空数组及 unknown 套餐必须在 TypeScript 中准确表达。邮箱在账号选择器、总览和设置中统一经过 `maskEmail`;不得把未掩码邮箱添加到新的可见位置。 + +## 总览与设置 + +总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、四项摘要及每日 Token 图。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。 + +设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。 + +## 验证重点 + +纯逻辑和组件测试使用 Vitest;现有浏览器测试使用 Playwright 覆盖设置布局、键盘操作、隐藏表单隔离、账号删除、窄屏溢出和邮箱掩码。修改 effect、轮询或异步加载时应覆盖卸载清理、错误状态及旧响应覆盖新状态;修改 CSS 时同时跑 desktop 和 mobile projects。 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md new file mode 100644 index 0000000..3293d22 --- /dev/null +++ b/docs/guides/deployment.md @@ -0,0 +1,32 @@ +# 部署与运行 + +用户可见的安装和日常操作以根 [`README.md`](../../README.md) 为准;本文记录维护代码时必须理解的构建和数据边界。 + +## 镜像结构 + +`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。 + +前端产物复制到 `backend/internal/web/dist` 后嵌入二进制。运行层使用 UID `10001` 的 system 用户 `helper`,默认 `DATA_DIR=/data`、`LISTEN_ADDR=:8080`,并暴露 `/data` volume 和 8080。健康检查调用二进制自身的 `healthcheck` 子命令。 + +## Compose 与持久化 + +`docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。全部数据库、密钥、Codex 配置和多账号凭据都依赖这个卷。 + +升级应使用: + +```bash +git pull --ff-only +docker compose up -d --build +``` + +不要使用 `docker compose down -v`,也不要在未确认 volume 名和备份前重建、迁移或删除数据卷。需要改变运行 UID、volume 或 `DATA_DIR` 时,必须提供旧数据权限和路径的升级验证。 + +## 网络与安全 + +应用自身监听 HTTP。公网部署应放在 HTTPS 反向代理后,初始化前限制访问来源,并保证到 OpenAI 登录/Codex 服务、Telegram Bot API 和所选 SMTP 服务的出站连接。当前只有 `LISTEN_ADDR` 是 Compose 显式环境变量;运行配置由前端保存到数据库。 + +设备码登录不需要 OpenAI API Key。Codex app-server 为每个 `CODEX_HOME` 管理 ChatGPT token;不要把这些目录暴露为静态文件或外部共享目录。 + +## 备份与恢复 + +维护接口下载的 SQLite 快照适合查看或数据库级备份,但不包含解密密钥和 Codex 凭据。完整恢复步骤是:停止容器、备份或恢复整个 `/data`、确认 UID `10001` 可读写、再启动并检查 `/health/live`、`/health/ready`、管理员登录和各账号连接。恢复过程不得只替换数据库而遗失对应 `secret.key`。 diff --git a/docs/guides/development-and-validation.md b/docs/guides/development-and-validation.md new file mode 100644 index 0000000..f72b623 --- /dev/null +++ b/docs/guides/development-and-validation.md @@ -0,0 +1,75 @@ +# 开发与验证 + +## 环境政策 + +宿主机只保证 Docker 和 Docker Compose。所有 Go、Node、npm、格式化、构建、测试和安全扫描都在容器内执行。以下命令从仓库根目录运行;挂载源码时使用只读模式,只有构建输出确实需要写回工作区时才放宽。 + +## 后端 + +完整格式、构建、vet 和测试: + +```bash +docker run --rm \ + -v "$PWD/backend:/app:ro" \ + -v codex-helper-go-mod:/go/pkg/mod \ + -v codex-helper-go-cache:/root/.cache/go-build \ + -w /app golang:1.26.0-bookworm \ + sh -c 'test -z "$(gofmt -l .)" && go build ./... && go vet ./... && go test -count=1 ./...' +``` + +针对性测试可把最后一段替换为 `go test -count=1 ./internal/store` 或相应 package。测试通过 `t.TempDir()` 创建专用 SQLite,禁止把 `DATA_DIR` 指向运行实例。 + +## 前端 + +在临时副本中安装依赖、构建并运行 Vitest,避免容器把 root 所有权的 `node_modules` 写入工作区: + +```bash +docker run --rm -v "$PWD/frontend:/src:ro" node:24.19.0-bookworm-slim \ + sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm run build && npm test' +``` + +依赖或安全改动额外运行: + +```bash +docker run --rm -v "$PWD/frontend:/src:ro" node:24.19.0-bookworm-slim \ + sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm audit --omit=dev --audit-level=high' +``` + +## Playwright E2E + +使用与 `frontend/package.json` 中 `@playwright/test` 匹配的官方镜像: + +```bash +docker run --rm --ipc=host \ + -v "$PWD/frontend:/src:ro" \ + mcr.microsoft.com/playwright:v1.62.1-noble \ + sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm run test:e2e' +``` + +若升级 Playwright,必须同步镜像 tag。E2E 的 API 由 route mock 提供,不要求启动 Go 后端。 + +## 镜像与 Compose + +```bash +docker compose config --quiet +docker build --check . +docker build -t codex-helper:test . +``` + +涉及容器启动、静态资源或健康检查时,再使用隔离的临时 Compose project 和专用 volume 验证;不得连接或删除用户的运行卷。 + +## 最低验证矩阵 + +| 改动 | 最低验证 | +| --- | --- | +| 后端普通逻辑 | 相关 Go test、gofmt 检查、build、vet | +| SQLite schema、迁移、备份 | 新旧 schema 测试、WAL 快照测试、完整后端门禁 | +| app-server 生命周期或同步 | codex/app runtime 单测、并发和失败重试路径 | +| 鉴权、session 或秘密 | security 与 app 测试、成功和拒绝路径 | +| 通知 | reminder 渲染、去重、计划与异常重置测试 | +| 前端 | 相关 Vitest、生产 build | +| 布局、路由、设置或响应式 | Playwright desktop 和 mobile | +| Dockerfile、Compose、持久化 | 配置检查及相应镜像/运行验证 | +| 纯文档 | 链接、术语、事实来源和 `git diff --check` | + +先运行针对性检查,再按风险扩大范围。修复缺陷时优先添加修复前会失败的回归测试。 diff --git a/docs/reference/code-review-rules.md b/docs/reference/code-review-rules.md new file mode 100644 index 0000000..4d50700 --- /dev/null +++ b/docs/reference/code-review-rules.md @@ -0,0 +1,50 @@ +# Codex Helper 代码审查规则 + +目标是发现可复现的正确性、安全性、兼容性和可靠性问题,而不是增加评论数量。审查必须结合完整 diff、调用链、测试和部署边界,不能孤立阅读修改行。 + +## 输出格式 + +findings 优先,按 `P0` 至 `P3` 排序。每条必须包含严重级别和简短标题、精确文件与起始行号、触发输入或执行顺序、实际影响和最小修复方向。同一根因只报告一次。 + +- `P0`:可利用的安全突破、不可恢复数据丢失或全系统中断。 +- `P1`:高概率生产故障、认证绕过、账号凭据删除或核心同步失效。 +- `P2`:条件性但真实的错误、兼容性破坏、状态漂移、重复通知或资源风险。 +- `P3`:影响有限且不阻塞合并的真实维护问题。 + +不要报告纯格式偏好、没有现实失败路径的猜测、仓库已有且未被当前改动暴露的问题,或仅以“缺少测试”为问题本身。没有 finding 时明确说明,并列出未执行验证或剩余风险。 + +## 审查流程 + +1. 阅读完整 diff,识别 API、认证、SQLite、Codex runtime、通知、前端、部署和持久数据影响。 +2. 搜索修改符号的调用方、被调用方、相似路径和测试,走完实际成功与失败路径。 +3. 对照 [`engineering-invariants.md`](engineering-invariants.md) 和 [`backend/CONTRACT.md`](../../backend/CONTRACT.md)。 +4. 在容器内运行与风险成比例的验证;复核 finding,删除重复根因和不影响用户的推测。 + +## 后端专项 + +- 匿名端点是否意外扩大;session、过期检查和非只读来源头是否在所有路径生效;cookie 改动是否适配 HTTPS 反代。 +- setup 是否事务化且不能覆盖管理员;登录限流是否存在竞态、无限内存或错误信任代理头。 +- SQLite migration 是否可从旧 schema 启动且保留数据;查询是否带正确 `account_id`;rows、transaction 和临时文件是否关闭。 +- 删除账号是否精确停止 runtime、级联历史并删除正确目录,尤其是账号 1 的旧路径。 +- app-server 生命周期是否可能双启动、停止后重启、死锁或由旧进程回调污染新进程;pending 请求在所有结束路径是否释放。 +- app-server optional/null、多 bucket、未知套餐和部分接口失败是否降级,而非生成错误数据或清除有效凭据。 +- 提醒是否稳定去重、限定六小时重试、正确区分 before/after/detected reset;多渠道部分失败是否按既定语义重试。 +- SMTP、Telegram 错误是否有 timeout、TLS 和 HTML 转义;设置接口、日志和错误是否泄露秘密。 +- 备份是否包含 committed WAL 且不阻塞写入;文案是否误称数据库快照为完整恢复包。 + +## 前端专项 + +- 初始化、未登录、登录态路由是否无闪烁或循环;401 后是否进入可恢复状态。 +- 切换或删除账号时,旧 Dashboard、设备码和 localStorage 是否清理;异步旧响应是否覆盖新账号。 +- API 类型是否准确表达 `null`、optional、unknown 和空列表;错误响应是否可能被当作成功。 +- 邮箱是否在所有可见位置掩码;服务端文本和外部 URL 是否以安全方式渲染。 +- effect、30 秒 polling、设备码 polling、timer 和 event handler 是否在卸载时清理。 +- 设置 tab 是否保持键盘操作、`inert` 隔离、表单状态、滚动位置和窄屏无溢出。 + +## 部署与验证专项 + +- 固定 Go、Node、Codex CLI 和 Playwright 版本是否同步 Dockerfile、lockfile 与文档;架构和静态构建是否匹配运行层。 +- 静态前端是否在 Go build 前正确复制;`.dockerignore` 是否会丢失必须资源或带入运行数据。 +- 最终镜像是否继续非 root,`/data` 权限是否兼容 UID `10001`;Compose 升级是否复用原卷。 +- 新环境变量是否同步代码、Compose、README 和部署文档;秘密是否可能进入 build arg、镜像层或日志。 +- 最低验证遵循 [`development-and-validation.md`](../guides/development-and-validation.md) 的矩阵。纯文档至少检查链接、术语、事实来源和 `git diff --check`。 diff --git a/docs/reference/engineering-invariants.md b/docs/reference/engineering-invariants.md new file mode 100644 index 0000000..42bf5e7 --- /dev/null +++ b/docs/reference/engineering-invariants.md @@ -0,0 +1,40 @@ +# 工程不变量 + +修改鉴权、SQLite、账号删除、Codex 进程、秘密、通知、备份或部署边界前核对本页。 + +## API 与认证 + +- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口只能是当前 status、setup 和 login。 +- 所有受保护端点必须回查 session;非只读请求还必须验证 `X-Requested-With`。前端路由与按钮不能代替后端门禁。 +- session 原 token 只进入 cookie,SQLite 只保存摘要;密码保持 argon2id。错误和日志不得包含密码、cookie、Bot Token、SMTP 密码或 Codex token。 +- 初始化是事务性单管理员创建。新增自动初始化能力前必须保留并发与首次公网暴露的安全边界。 + +## 数据与账号 + +- `internal/store/store.go` 是 SQLite schema 和兼容迁移的事实来源。启动迁移必须幂等、保留旧数据并启用 foreign keys。 +- 旧单账号数据迁到账号 1;账号 1 的凭据路径永久为 `/data/codex`,不能统一搬到 `accounts/1`。 +- 删除账号必须先停止并移除精确 runtime,再删除该账号数据库行和精确凭据目录;不得使用未校验路径、glob 或宽泛递归删除。 +- `daily_usage` 与 `limit_snapshots` 以 `account_id` 隔离。任何查询、更新、提醒 key 或清理不得串账号。 +- `expectedKind` 只是校验期望;未知套餐保持 unknown。相同邮箱提示重复不能成为自动合并依据。 + +## Codex 运行时 + +- 每账号一套 app-server 和 `CODEX_HOME`。进程存活与协议 ready 是不同状态,初始化失败必须关闭旧进程。 +- lifecycle mutex 串行启动/停止,syncing mutex 串行同步和 Dashboard 访问,state mutex 保护 ready/stopped;不得以无锁读写替换。 +- 旧子进程晚退出不能断开新子进程或失败新请求。请求取消和 20 秒 timeout 必须清除 pending channel。 +- `account/read` 是同步的硬依赖;rate limits 与 usage 是可缺失数据。optional、`null`、多 bucket 和未知字段必须安全降级。 + +## 通知、外部输入与秘密 + +- SMTP 密码和 Telegram Token 使用 `/data/secret.key` 的 AES-GCM 密文保存;设置响应继续掩去秘密。密钥丢失不能通过返回密文或明文“修复”。 +- 提醒 dedupe key 必须包含账号、limit、window、reset 或旧 snapshot 身份及 kind;重试窗口保持计划时间后六小时。 +- Telegram/SMTP 用户文本进入 HTML 前必须转义。Telegram HTTP client 和 SMTP 连接/读写必须保留 timeout;SMTP TLS 最低 1.2 并验证 server name。 +- Telegram 绑定码十分钟且成功即消费;其他 chat 在绑定后不得查询实例数据。 + +## 前端与部署 + +- 所有可见邮箱继续掩码。账号切换清空旧 Dashboard;删除账号清除设备码和 localStorage 选择。 +- 非活动设置 panel 保持 `inert`,tab 键盘和布局稳定性是现有可访问性契约。 +- 前端生产资源嵌入 Go 二进制;Docker build stage 的复制顺序变化必须验证实际嵌入的是新产物。 +- `/data` 是唯一完整恢复单元。数据库快照不含 `secret.key` 和 Codex 凭据;任何文档不得暗示其可完整灾难恢复。 +- 运行数据、数据库、密钥、凭据、`.env`、`node_modules` 和构建缓存不得进入 Git 或 Docker 构建上下文。