# 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`。