diff --git a/docs/产品需求.md b/docs/产品需求.md new file mode 100644 index 0000000..b73a0ad --- /dev/null +++ b/docs/产品需求.md @@ -0,0 +1,151 @@ +# BPOS-Service-Gateway 产品需求(能力面 stub) + +> **本文是能力面 stub,字段级契约以协议册与 `src/contracts` 为准。** +> +> 上游《BPOS仓库结构》约定"各服务的产品需求文档随各自服务仓 `docs/` 维护",本文是本仓那一份。 + +--- + +## 1. 一句话定位 + +> **API 网关**:会话鉴权 · 按域路由(task/data/agent)· SSE 推送中心 · 上传转对象存储。 +> 前端唯一入口,8800,**无自有库**。 + +(照录《BPOS仓库结构》`docs/proto-v13` 分支 §1 表格中本仓那一行;该行标注 +【提案 · 待架构负责人确认】——网关落仓在 §3"待定项"里尚未定稿。) + +--- + +## 2. 能力面清单 + +每一项都对得上仓内**已存在**的路由或模块。 + +### 2.1 会话鉴权 + +- `requireAuth` / `requireRealmRole` / `requireService`,JWKS 远端验签(与 Task/Data 同一份实现,同一性文件)。 +- 鉴权失败分类**只此一份**:token 坏 → 401;**JWKS 拉不动 → 503**(不是 401)。 + SSE 是长连,客户端拿到 401 会清 token 去重登录,而重登录打的还是同一个 Keycloak + ——把"鉴权服务暂不可达"报成 401 会制造一场无解的重登录风暴。 +- `GET /health` **公开**(不过 `requireAuth`),汇总 `nats` 依赖状态;无自有库故无 `db` 探活。 + +### 2.2 按域路由(代理) + +`/api/task/*` → `TASK_SERVICE_URL`、`/api/data/*` → `DATA_SERVICE_URL`、 +`/api/agent/*` → `AGENT_SERVICE_URL`。全方法透传(`r.all`)。 + +**挂载顺序 load-bearing**:`/api/stream` 与 `/api/upload` 必须先于 `/api` 代理挂载, +否则被代理的 catch-all 吞掉。 + +三条安全约束(每条都是踩过的): + +| 约束 | 做法 | 不这么做的后果 | +|---|---|---| +| **主机不可位移** | 在 `new URL(target)` 上**赋 `pathname`**,绝不 `new URL(路径, target)` | 剥前缀后若余下 `//evil.example.com/x` 这种协议相对路径,URL 解析只保留 scheme 而把 host 整个换掉——调用方的 Authorization 与请求体发去攻击者主机,响应还原样回传(SSRF + 令牌外泄) | +| **内部头剥离** | 转发前删掉 `x-user-id`、`x-user-roles`、`x-internal-token`、`host`、`content-length` | 外部调用方自带 `x-user-id` 就能冒充任意身份——下游若信这类头即越权 | +| **响应头剥离** | 回传前删掉 `content-encoding`、`content-length`、`transfer-encoding` | undici 已按 `content-encoding` 解过压、body 是明文,原样回传就是自相矛盾的响应(客户端二次解压失败或按错长度截断) | + +**Authorization 原样带上**(下游自己再验一遍——纵深防御)。 +下游连不上 → 统一形状的 **502** 并点名是哪个下游。 +**traceparent**:调用方没带就在这里补发一条(网关是外部请求的第一跳),已有的**绝不覆盖** +(那会把上游已开好的链从中间截断)。 + +- 落点:`src/modules/proxy/`。 + +### 2.3 SSE 推送中心 + +`GET /api/stream`(建流)+ `POST /api/stream/subscribe`(改订阅集)。 + +**token 取法**:EventSource 带不了 `Authorization` 头,故 `?access_token=` 与头两收 +(降级轮询共用同一条口)。 + +**帧序与编号**: + +1. `hello`(带 `connectionId`)**必须先于回放**——客户端拿到 id 之前收到的事件无从对账 + (它还不知道该拿哪个 id 去 `/subscribe`); +2. 每条推送帧带 **`id: `**,`seq` 是 hub 内单调递增的全局序号; +3. `event:` 是主题名,`data:` 是原始载荷。 + +**Last-Event-ID 回放**:客户端重连带 `Last-Event-ID` 头(只认纯数字), +hub 在 **512 条**的环形窗口里补发 `seq > lastEventId` 且**本连接可见**的事件。 + +> **诚实的局限**:EventSource 重连不携带订阅状态,新连接的 `topics` 是空集, +> 所以实际只补得到 `touch.system` 与 `touch.staff.`。任务域的补发要求客户端收到 `hello` +> 后先重新 `/subscribe`——而那时这一跳已经过去。真正的任务域断点续传要把订阅集做成 +> 服务端持久态(按 `staffSub` 记住上次订阅),**尚未做**。 + +**心跳**:每 **25s** 发一帧 `event: ping`,`data: {}`。 +流的寿命挂在一个可被两端 resolve 的 promise 上:客户端 abort、服务端 shutdown 都能收流 +(否则心跳定时器与整个闭包会一直漏着不回收)。 + +**可订阅主题与授权**(默认拒;可订阅集 = **有授权规则**的集合): + +| 主题形状 | 授权判据 | +|---|---| +| `touch.task.` | 问任务服务 `GET /task/:id/access?staff=`(负责人/审批人/admin) | +| `stream.task.` | 同上;额外**按需**建 NATS 订阅,最后一个订阅者退订即撤 | +| `touch.workflow.` | 问任务服务 `GET /workflow/:id/access?staff=`——判据是该实例**定义里**的节点归属(参与人 `ownerSub` / 审批人 `approverSub`),与单张单的归属不是一回事,故是**另一口**而非复用 | +| `touch.system` | 人人可见(无需订阅) | +| `touch.staff.` | 只对本人可见(无需订阅) | + +`admin` 旁路两条 access 询问(既是规则,也免掉白跑的跨服务往返)。 +两次询问都走**服务账号 token**(不是转发用户 token),3s 超时,**非 200 一律当无权** +——推送面失败关闭:宁可少订一条,不可把别人的任务事件扇给不该看的人;但非 200 会打日志, +否则"人人被拒"无从诊断。 + +`/subscribe` 的三条守则: + +1. **形状预检先于任何变更**:`add` 里有一个不可订阅的主题就整单 422、零副作用 + (边订边查会把合法的那条真订上、还白跑一次跨服务往返,客户端以为全失败而服务端留了半截订阅); +2. **`add`/`remove` 必须是数组**,不是就 422(类型断言不是校验:字符串走到 `.find` 是 TypeError→500, + 请求写错却报成服务端故障,而 500 在网关这层还会被当成可重试); +3. **只能操作自己名下的连接**:`hub.subscribe` 拿的是**连接的** `staffSub` 授权, + 不校验归属的话任何登录用户只要知道别人的 `connectionId` 就能改人家的订阅集, + 还能拿 `results` 当权限探测器。`admin` 例外。 + +运行时状态(连接不存在/被拒)逐条落 `results` 回 200,客户端据此决定重连还是申请权限。 + +- 落点:`src/modules/sse/`(`hub.ts`、`routes.ts`、`task-access.ts`、`workflow-access.ts`)。 + +### 2.4 上传转对象存储 + +- `POST /api/upload`(`user`/`admin`/`service`):**8MB** 上限, + `bodyLimit` 先看 `content-length`、没有就边读边计数、一超限立刻断流 + ——413 必须在收完 body 之前定,1GB 的 POST 不会先整个躺进内存再挨拒。 + 返回 `{ ref }`(201)。`x-filename` 可选;记 `ownerSub`(只落盘,读侧鉴权尚未做)。 +- `GET /api/upload/:ref`:回原始字节,**一律 `Content-Disposition: attachment`**(不内联) + + `X-Content-Type-Options: nosniff`——`content-type` 是上传方自报的,原样回显+内联渲染 + 就是网关源上的存储型 XSS。文件名走 **RFC 5987** `filename*=UTF-8''…` + (`filename="…"` 只认 ASCII,中文名塞 percent-escape 会原样显示成 `%E4%B8%AD`)。 +- 存储是 **FS 适配器**(`OBJECT_STORE_DIR`,缺省 `./.objectstore`); + `objectstore.ts` / `fs-store.ts` 与 Task 仓 `channel/` 下那两份是同一性文件(逐字节相同)。 +- 落点:`src/modules/upload/`。 + +--- + +## 3. 明确不做(边界) + +- **不持久化**:无自有库、无 `db:migrate`、无 outbox、无 relay、无 consumer、无 durable。 +- **不做业务判断**:可见性一律**问任务服务**,网关不自己算归属。 +- **不建流、不发布业务事件**:只 core NATS 订阅 `touch.>` 与按需的 `stream.task.*`。 +- **上传读侧的归属鉴权**:`GET /api/upload/:ref` 现在只做验签+realm 角色校验 + (`user`/`admin`/`service`),**不校验对象归属**——拿到 `ref` 的任何登录用户都读得到。 + `ownerSub` 已随上传落盘,归属规则待后续。 +- **SSE 任务域断点续传**:见 §2.3 的局限说明,待订阅集服务端持久化。 +- **不做本地设施兜底**:见《开发环境册》§2.1。 + +--- + +## 4. 依据链 + +| 依据 | 位置 | 用来定什么 | +|---|---|---| +| 《系统架构设计.html》v2 §07「两端与实时推送」 | `BPOS-Document-Share/系统架构设计.html` | 网关的会话鉴权、按域路由、SSE 扇出 | +| 《系统架构设计.html》v2 §06「消息总线:四类消息,五项保障」 | 同上 | 总线的能力要求(BUS-*)——本仓只涉及 BUS-08 尽力而为推送 | +| 《系统架构设计.html》v2 §10「持久层与文档体系」 | 同上 | 文档与契约的家:谁维护什么、放在哪 | +| 《BPOS服务消息协议》SVC 分节 | `BPOS-Document-Share/BPOS服务消息协议.md` | `/task/:id/access`、`/workflow/:id/access` 等服务间同步接口 | +| 《BPOS仓库结构》§1 / §3 | `BPOS-Document-Share/BPOS仓库结构.md`(`docs/proto-v13` 分支) | 本仓定位一行(提案待确认)与"网关落仓"待定项 | +| 本仓 `src/contracts/envelope.ts` | 本仓 | 信封结构(同一性文件,真源在 BPOS-Document-Share) | + +仓内既有记录:`docs/keycloak-setup-log.md`。 +验收演练的一手记录在姊妹仓:`BPOS-Service-Task/tools/n2-acceptance.md`、`n3-acceptance.md`、 +`BPOS-Service-Data/tools/n1-acceptance.md`。 diff --git a/docs/开发环境册.md b/docs/开发环境册.md new file mode 100644 index 0000000..30a05c2 --- /dev/null +++ b/docs/开发环境册.md @@ -0,0 +1,127 @@ +# BPOS-Service-Gateway 开发环境册 + +> 简版。与姊妹仓《BPOS-Service-Task/docs/开发环境册.md》共享设施与纪律段落,本册只写本仓差异与要点。 +> +> **凭证纪律**:本册只写环境变量的**键名**,绝不落主机、IP、账号、口令、密钥值。 +> 真值去共享设施的凭证发放渠道要,写进本机 `.env`(该文件永不入 git)。 + +--- + +## 1. 本仓是什么 + +API 网关(`bpos-service-gateway`):前端唯一入口。 +会话鉴权 · 按域路由(task/data/agent)· SSE 推送中心 · 上传转对象存储。 + +**8800,无自有库**——不持久化,故无 `db:migrate`、无 outbox、无 relay、无 consumer。 +NATS 只用连通性(订阅 `touch.>` 与按需的 `stream.task.*`)。 + +> 落仓状态:上游《BPOS仓库结构》原六仓拓扑未含网关,§3"待定项"记为待定; +> `docs/proto-v13` 分支上已有【提案】"已建独立仓 BPOS-Service-Gateway",**待架构负责人确认**。 + +--- + +## 2. 共享设施与配置 + +### 2.1 一条硬性纪律:不许自建本地设施 + +**严禁在本机启动 Docker / 容器 / 任何本地替代基础设施。** NATS 在共享开发机上, +Keycloak 是共享站点。设施不可达时如实降级或停下,**不起本地兜底**: +NATS 不可达 → SSE 推送停摆、`/health` 照实报 fail,但代理/上传/健康检查照常服务 +(`src/app/server.ts` 明确不因此拖垮 HTTP 面)。交付如实标注 `SKIPPED-INFRA-DOWN` 或报 BLOCKED。 + +### 2.2 环境变量(只写键名) + +`.env.example` 是权威清单。 + +| 键名 | 作用 | 缺省与后果 | +|---|---|---| +| `PORT` | HTTP 监听端口 | 缺省 8800 | +| `NATS_URL` | 共享 NATS | `src/shared/config.ts` 有开发期缺省,部署一律显式配 | +| `KEYCLOAK_URL` / `KEYCLOAK_REALM` | 验签用的站点与 realm | 用户 token 与服务 token 同一 realm | +| `KEYCLOAK_CLIENT_ID` | 本仓 client,缺省 `api-gateway` | | +| `KEYCLOAK_CLIENT_SECRET` | **须为 `api-gateway` 服务账号的密钥** | 只从环境读、无缺省。配错/配成别的 client 的密钥=换来别人的服务账号 token(或直接被拒),SSE 的订阅授权全线失败并"人人被拒"。**值绝不写进任何文档或提交**,从 Keycloak 该 client 的 Credentials 页取 | +| `TASK_SERVICE_URL` | 代理与订阅授权的目标 | 缺省本机 8801 | +| `DATA_SERVICE_URL` | 代理目标 | 缺省本机 8802 | +| `AGENT_SERVICE_URL` | 代理目标 | 缺省本机 8901(开发期即 Task 仓的 fake-agent 桩) | +| `OBJECT_STORE_MODE` / `OBJECT_STORE_DIR` | 上传落盘(FS 适配器) | 缺省目录 `./.objectstore`,已在 `.gitignore` | + +**为什么这里必须是 `api-gateway` 自己的密钥**:SSE 订阅授权要拿服务账号 token 去问任务服务 +`GET /task/:id/access` 与 `GET /workflow/:id/access`,后者在任务侧是 +`requireService(['api-gateway'])`——只认网关。`src/shared/service-token.ts` 用 +`config.keycloak.clientId` + `KEYCLOAK_CLIENT_SECRET` 换 `client_credentials` token, +两者必须是**同一个 client** 的。相关的 realm 角色与 audience mapper 配置记录见 +`docs/keycloak-setup-log.md`(该文只记对象名与操作结果,同样不含密钥值)。 + +`.env` / `.env.*` 已在 `.gitignore`(`!.env.example` 例外)。收尾核验里 +`git log --all --oneline --diff-filter=A -- .env | wc -l` 必须为 `0`。 + +--- + +## 3. 端口与运行 + +```bash +pnpm i # 装依赖 +pnpm dev # tsx watch,读 .env,起在 :8800 +pnpm gates # typecheck + lint + test +``` + +**无 `db:migrate`**(本仓无自有库)。 + +启动顺序:建 SseHub → 建 app → 起 HTTP → 连 NATS(通则 `hub.attachNats`,不通只告警)。 +`SIGINT`/`SIGTERM`:**先收 SSE 长连**再 `server.close()`——那些响应永远不结束, +不主动关就会一直等;3s 兜底强退。 + +跨仓端口速查:Gateway 8800(无库)、Task 8801(`jx_dispatch`)、Data 8802(`jx_business`)、 +fake-agent 桩 8901(在 Task 仓 `tools/fake-agent.ts`,Activity 仓交付真桩后退役)。 + +--- + +## 4. 总线用法:只订阅,不建流 + +本仓**不建流、不发布业务事件、无 outbox、无 relay、无 durable**。 +只用 core NATS 订阅两类推送主题(BUS-08 尽力而为,本就不落 JetStream): + +- `touch.>`:连上就订,进程内按连接可见性扇出; +- `stream.task.`:**按需**订——有连接订阅了才建 NATS 订阅,最后一个连接退订就撤掉。 + +三条 JetStream 流(`BPOS_EVT` / `BPOS_CMD` / `BPOS_DLQ`)由 Task / Data 两仓引导,与本仓无关。 + +--- + +## 5. 测试纪律 + +本仓无库,共享库那套 `zz_test_` 前缀 / `afterAll` 谓词清理 / LIKE 下划线转义 / 生产扫描排除 +的纪律**在本仓无直接落点**,但两条相关的仍适用: + +1. **集成用例打的是共享 Keycloak 与共享 NATS**,不许起本地替身;不可达就如实跳过或失败。 +2. **`test/support/token.ts` 是三仓同一性文件**(逐字节相同)——改它要同步三仓并重跑同一性脚本。 +3. 用例造出来的 taskId / workflowId 一律用 `zz_test_` 前缀:它们会经服务账号 token 打到 + 任务服务的 `/access` 端点上,落在**共用**的 `jx_dispatch` 库前面,前缀是那边扫描排除的抓手。 + +--- + +## 6. 门禁与验收 + +```bash +pnpm typecheck # tsc --noEmit +pnpm lint # eslint src test +pnpm vitest run # 全量 +pnpm gates # 上面三条 +``` + +跨仓同一性由 Task 仓的脚本统一跑(三仓需为同级目录): + +```bash +node ../BPOS-Service-Task/scripts/check-identity.mjs # 期望 16/16 +``` + +本仓参与其中 11 条:`keycloak-auth.ts`、`bus/nats.ts`、`contracts/envelope.ts`、`app/health.ts`、 +`test/support/token.ts`、`vitest.config.ts`(三仓)+ `shared/errors.ts`、`shared/service-token.ts`、 +`shared/trace.ts`(与 Task 两仓)+ `upload/objectstore.ts`、`upload/fs-store.ts` +(与 Task 的 `channel/objectstore.ts`、`channel/fs-store.ts` 同内容、不同路径)。 +**改这些文件必须同步复制到相关仓,再重跑脚本。** + +仓内既有记录:`docs/keycloak-setup-log.md`(realm 角色 `service`、两个服务账号的 audience mapper、 +共享 client 影响面、解码真 token 的验证结果)。 +验收演练的一手记录在姊妹仓:`BPOS-Service-Task/tools/n2-acceptance.md`(touch 经 SSE 到浏览器)、 +`n3-acceptance.md`(编排全链)、`BPOS-Service-Data/tools/n1-acceptance.md`(含网关这条腿的 aud 边界)。