From 8ba4ab339e74afa21eb44a31f2ce5cf2351dc414 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 15:15:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92:=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=89=B4=E6=9D=83=E4=B8=AD=E5=BF=83(Keycloak)?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=85=A5=20=E2=80=94=E2=80=94=2011?= =?UTF-8?q?=20=E4=B8=AA=E4=BB=BB=E5=8A=A1,=E6=AF=8F=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=87=AA=E5=B8=A6=E6=B5=8B=E8=AF=95=E4=B8=8E=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 spec 拆:契约归属 → org 纯函数 → jose 验签 / Admin 名册两个 adapter → 注册表 / 票据 / 中间件三件 app 小件 → 一次提交的大切换(org 重写 + app 重接线 + 迁移 0032)→ 启动自检与 路由覆盖 fail-closed → e2e 改服务账号 → ADR-012 与文档。执行前置:用户先处理工作树里未提交的改动。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../plans/2026-08-25-keycloak-auth.md | 4893 +++++++++++++++++ 1 file changed, 4893 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-keycloak-auth.md diff --git a/docs/superpowers/plans/2026-08-25-keycloak-auth.md b/docs/superpowers/plans/2026-08-25-keycloak-auth.md new file mode 100644 index 0000000..97e2706 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-keycloak-auth.md @@ -0,0 +1,4893 @@ +# 统一鉴权中心(Keycloak)后端接入 —— 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把身份交给 Keycloak(后端只验 Bearer JWT),权限收成「角色 → 能进哪几个端」一张可动态改的表,名册从 Keycloak 同步、岗位本地指派;口令 / 会话表 / 岗位授权整套退役。只做后端,web 一个文件不动。 + +**Architecture:** `org`(L1)原地重写:`authenticate(token)` = jose 本地验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × `sys.role_access` → `Principal{staff, position, roles, ends}`。app 层一处鉴权中间件按**路由注册表**(每条契约自带 `access`)统一判 401/403,治理路由里约 100 处逐条守卫整体删除;SSE 与 `` 下载走 60s HMAC 票据。 + +**Tech Stack:** TypeScript ESM(import 带 `.ts` 后缀)· Hono 4 · Drizzle(sys schema)· zod 4 · vitest · **jose ^5.10.0(新依赖,用户已批)** · Node 22 `node:crypto`(HKDF / HMAC) + +**Spec:** `docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`(计划从它推出来;执行者两份都读) + +## Global Constraints + +- 五道门禁以退出码验:`pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test`;每个任务提交前至少 typecheck / lint / test 绿,改了契约面(`@contracts` / 模块 `index|contract|port.ts`)的任务还要 `pnpm api:check --update && git add api-report`。 +- §12.5 禁止清单:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO、零 `Date.now` / `Math.random` / `new Date()`;模块级零 `let`;不删无关测试;不动门禁配置。 +- 模块内 `export` 的函数必须写显式返回类型(eslint `explicit-module-boundary-types`)。可选属性一律用 `...(x === undefined ? {} : { x })` 的写法(仓库开着 `exactOptionalPropertyTypes` 风格)。 +- 验签:`algorithms: ['RS256']` 写死、校 `issuer` 与 `audience`、`clockTolerance: 30`;远程 JWKS `cooldownDuration: 30_000` / `cacheMaxAge: 10 * 60_000` / `timeoutDuration: 5_000`;本服务**不配 client secret**。 +- 票据:`v1...`,HMAC-SHA256,密钥 `HKDF(JCAI_SECRET_KEY, info='jcai-ticket')`,TTL 60s,**只在 GET 的 `?ticket=` 上认**。 +- 名册:`last_seen_at` 60s 内不重写;Admin API 分页 `max=100`;噪声角色 `default-roles-*` / `offline_access` / `uma_authorization` 一律滤掉。 +- 岗位哨兵码 `NONE`(未分配)、测试岗 `TEST`;缺省角色→端:`admin → design+ops+workbench`,`user → workbench`。 +- 环境变量:`KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_ID` `KEYCLOAK_PROVISIONING_CLIENT_SECRET`(可选)、`JCAI_TEST_ACCOUNTS`(可选,逗号分隔 Keycloak 用户名)、`JCAI_SECRET_KEY`(沿用);`JCAI_SEED_PASSWORD` 删除。 +- HTTP 语义:无凭据 / 坏 token → 401 `unauthenticated`(message 是手册 4.4 的人话);账号停用或端不够 → 403 `forbidden`;角色→端锁死 → 409 `conflict`;退役路由 → 410 `gone`(新增错误码)。 +- 路由归属(`access`):`public` 只有 `healthRoute` / `ingestMailRoute` / 退役八条;`any` 只有 `meRoute` / `authTicketRoute` / `GET /api/stream`;其余按 §4.4 表(本计划 Task 1 逐条列出)。 +- **执行前置**:工作树里有用户未提交的改动(`CLAUDE.md` `api-report/.../task/contract.d.ts` `asset-archive*.ts` `desk.ts` `engines.ts` `wake.test.ts` `e2e-a2.mjs` + 两份 untracked 文档)。**开工前请用户先把它们提交或 stash**;本计划每次提交只 `git add` 任务点名的文件。分支 `feat/keycloak-auth`(spec 已在上面)。 + +--- + +## 文件结构(改动全貌) + +| 目的 | 文件 | 职责 | +|---|---|---| +| 契约 | `packages/contracts/src/api/http.ts` | `SURFACES` / `RouteAccess` / `RouteContract.access` / 错误码 `gone` | +| | `packages/contracts/src/org.ts` | `staffSchema`(+email/roles)、`principalSchema`、`roleAccessSchema`、`rosterSyncOutcomeSchema`、`inFlightWorkSchema`;旧授权 / 会话类型标 `@deprecated` | +| | `packages/contracts/src/api/workbench.ts` `governance.ts` `ingest.ts` | 每条路由加 `access`;新路由 8 条 | +| | `packages/contracts/src/__tests__/route-access.test.ts` | 每条路由都声明了合法的 `access`;公开路由只有那几条 | +| org domain | `apps/api/src/modules/org/internal/domain/claims.ts` | claims → Identity(名回退、噪声角色) | +| | `…/domain/ends.ts` | 角色 × 表 → 端;锁死不变量 | +| | `…/domain/roster-diff.ts` | 同步差异(增 / 改 / 停 / 复活) | +| | `…/domain/test-account.ts` | 测试账号策略(TEST 岗 + 独立工作组) | +| | `…/domain/in-flight.ts` | 在途工作(原 disable-check 改名) | +| | `…/domain/seed-data.ts` | 岗位种子(含 NONE / TEST)、缺省角色→端 | +| org adapter | `…/adapter/keycloak-token-verifier.ts` | jose 验签 + 错误译人话 | +| | `…/adapter/keycloak-admin-roster.ts` | Admin API 拉名册(provisioning 服务账号) | +| | `…/adapter/org-schema.ts` `org-repository.ts` | 表与仓储(staff 新列、role_access 表;session / grant / password 退役) | +| org 面 | `…/org/contract.ts` `port.ts` `index.ts` `internal/usecase/org-service.ts` `CONTRACT.md` | 新出口 | +| app | `apps/api/src/app/route-registry.ts` | 契约命名空间 → 归属注册表(`lookup` / `has`) | +| | `apps/api/src/app/ticket.ts` | 60s HMAC 票据 | +| | `apps/api/src/app/auth-middleware.ts` | 唯一一处鉴权:凭据 → Principal → 端判定 | +| | `apps/api/src/app/keycloak-check.ts` | 启动自检(issuer 一致才起) | +| | `apps/api/src/app/{config,container,http-env,server,main,seed}.ts` | 配置 / 装配 / 上下文 / 中间件挂载与退役路由 / 启动 | +| | `apps/api/src/app/routes/{workbench,governance,governance-ops,desk,desk-elements,desk-approval,registration}.ts` | 去守卫、取当前人改读 principal、新路由 | +| | `apps/api/src/app/wiring/engines.ts` | 死信收件人 → `staffWithEnd('ops')`;`positions()` 滤 NONE | +| 删除 | `apps/api/src/app/session-cookie.ts` `set-password.ts`;`org/internal/adapter/password.ts`;`org/internal/domain/{authorization,disable-check}.ts`;`org/__tests__/{authorization,password}.test.ts` | 随口令 / 会话 / 岗位授权退役 | +| 数据 | `db/migrations/0032_keycloak_identity.sql` + `meta/_journal.json` | 见 spec §6 | +| 测试 | `org/__tests__/{claims,ends,roster-diff,test-account,keycloak-token-verifier,keycloak-admin-roster,org-service}.test.ts`;`app/__tests__/{route-registry,ticket,auth-middleware,config-keycloak,keycloak-check}.test.ts`;`app/__tests__/route-coverage.it.test.ts`;三份既有 `*.it.test.ts` 改造人 | | +| 工具 / 文档 | `tools/e2e-a2.mjs`;`docs/adr/ADR-012-keycloak-identity.md`;`docs/编码规范.md`;`docs/debt.md`;`CLAUDE.md`;`api-report/` | | + +--- + +### Task 1: 契约 —— 端、路由归属、新 schema 与新路由 + +**Files:** +- Modify: `packages/contracts/src/api/http.ts`(整文件替换) +- Modify: `packages/contracts/src/org.ts`(整文件替换) +- Modify: `packages/contracts/src/api/workbench.ts`、`packages/contracts/src/api/governance.ts`、`packages/contracts/src/api/ingest.ts`(每条路由加 `access`;新增 8 条) +- Create: `packages/contracts/src/__tests__/route-access.test.ts` + +**Interfaces:** +- Produces:`SURFACES` `surfaceSchema` `Surface` `SURFACE_LABEL` `RouteAccess`;`RouteContract.access: RouteAccess`(必填);`API_ERROR_CODES` 增 `'gone'`;`staffSchema`(+ `email?: string` / `roles: string[]`)、`principalSchema` / `Principal`、`roleAccessSchema` / `RoleAccess`、`rosterSyncOutcomeSchema` / `RosterSyncOutcome`、`inFlightWorkSchema` / `InFlightWork`;路由 `meRoute` `authTicketRoute` `listRoleAccessRoute` `setRoleAccessRoute` `deleteRoleAccessRoute` `syncStaffRoute` `setStaffPositionRoute` `staffInFlightRoute`。 +- 不删任何既有导出(旧的标 `@deprecated`),web 编译不受影响。 + +- [ ] **Step 1: 写失败测试** + +`packages/contracts/src/__tests__/route-access.test.ts`: + +```ts +/** 路由契约:每条都得说清归哪个端(ADR-012)—— 中间件靠它,漏一条就是一个没人管的口子 */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '../index.ts'; +import type { RouteContract } from '../index.ts'; +import { SURFACES } from '../index.ts'; + +function isRoute(value: unknown): value is RouteContract { + if (typeof value !== 'object' || value === null) return false; + const v = value as { method?: unknown; path?: unknown; output?: unknown }; + return typeof v.method === 'string' && typeof v.path === 'string' && v.output !== undefined; +} + +const routes = Object.entries(contracts).filter((entry): entry is [string, RouteContract] => isRoute(entry[1])); +const surfaces: readonly string[] = SURFACES; + +describe('路由契约的归属(access)', () => { + it('收得到全部路由(至少 90 条)', () => { + expect(routes.length).toBeGreaterThanOrEqual(90); + }); + + it('access 只取 public / any / 端名 / 非空端名数组', () => { + for (const [name, route] of routes) { + const a = route.access; + const ok = + a === 'public' || + a === 'any' || + (typeof a === 'string' ? surfaces.includes(a) : a.length > 0 && a.every((s) => surfaces.includes(s))); + expect(ok, `${name}.access = ${JSON.stringify(a)}`).toBe(true); + } + }); + + it('公开路由只有健康检查、入站邮件与退役八条', () => { + const publicNames = routes.filter(([, r]) => r.access === 'public').map(([n]) => n).sort(); + expect(publicNames).toEqual( + [ + 'healthRoute', + 'ingestMailRoute', + 'loginRoute', + 'logoutRoute', + 'sessionRoute', + 'createStaffRoute', + 'disableStaffCheckRoute', + 'disableStaffRoute', + 'listGrantsRoute', + 'setGrantsRoute', + ].sort(), + ); + }); + + it('只要登录不看端的只有 me / 票据', () => { + const anyNames = routes.filter(([, r]) => r.access === 'any').map(([n]) => n).sort(); + expect(anyNames).toEqual(['authTicketRoute', 'meRoute']); + }); + + it('两端共用的读路由就是 spec §4.4 点名的那八条', () => { + const both = routes + .filter(([, r]) => Array.isArray(r.access)) + .map(([n]) => n) + .sort(); + expect(both).toEqual( + [ + 'listStaffRoute', + 'listProcessPackagesRoute', + 'listTriggersRoute', + 'listChannelsRoute', + 'listLedgerObjectsRoute', + 'ledgerByActivityRoute', + 'browseLedgerRoute', + 'govWorkflowRoute', + ].sort(), + ); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run packages/contracts/src/__tests__/route-access.test.ts` +Expected: FAIL —— `Property 'access' does not exist` / `SURFACES` 不存在(typecheck 阶段就红)。 + +- [ ] **Step 3: 重写 `packages/contracts/src/api/http.ts`** + +```ts +/** + * 路由契约的形状(编码规范 §8.1 / §9.2)。 + * + * 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。 + * **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。 + */ +import { z } from 'zod'; + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** + * 三个端(ADR-012):设计中心 / 运营中心 / 工作台。 + * 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。 + */ +export const SURFACES = ['design', 'ops', 'workbench'] as const; +export const surfaceSchema = z.enum(SURFACES); +export type Surface = z.infer; + +export const SURFACE_LABEL: Readonly> = { + design: '设计中心', + ops: '运营中心', + workbench: '工作台', +}; + +/** + * 一条路由归哪个端(ADR-012): + * `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。 + * 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。 + */ +export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[]; + +/** + * 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。 + * body 只在 POST/PUT/PATCH 上有意义;GET 用 query。 + */ +export type RouteContract< + TParams extends z.ZodTypeAny = z.ZodTypeAny, + TQuery extends z.ZodTypeAny = z.ZodTypeAny, + TBody extends z.ZodTypeAny = z.ZodTypeAny, + TOutput extends z.ZodTypeAny = z.ZodTypeAny, +> = { + readonly method: HttpMethod; + readonly path: string; + readonly access: RouteAccess; + readonly params?: TParams; + readonly query?: TQuery; + readonly body?: TBody; + readonly output: TOutput; +}; + +export type RouteOutput = z.infer; +export type RouteBody = R['body'] extends z.ZodTypeAny + ? z.infer + : never; + +/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ +export const API_ERROR_CODES = [ + 'unauthenticated', + 'forbidden', + 'not-found', + 'validation-failed', + 'conflict', + 'precondition-failed', + 'internal', + 'gone', +] as const; +export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; + +export type ApiError = { + readonly code: ApiErrorCode; + readonly message: string; + readonly detail?: unknown; +}; +``` + +- [ ] **Step 4: 重写 `packages/contracts/src/org.ts`** + +```ts +/** + * 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。 + * + * 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench), + * 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。 + * 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。 + */ +import { z } from 'zod'; +import { positionIdSchema, staffIdSchema } from './id.ts'; +import { surfaceSchema } from './api/http.ts'; + +export const positionSchema = z.object({ + id: positionIdSchema, + code: z.string().min(1), + name: z.string().min(1), + note: z.string().optional(), +}); +export type Position = z.infer; + +export const STAFF_STATES = ['active', 'disabled'] as const; +export const staffStateSchema = z.enum(STAFF_STATES); +export type StaffState = z.infer; + +export const staffSchema = z.object({ + /** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */ + id: staffIdSchema, + /** = `preferred_username`,只作显示 —— 用户名允许改,别拿它当键 */ + account: z.string().min(1), + /** = `name` claim,缺则回退 account */ + name: z.string().min(1), + email: z.string().optional(), + /** 未指派 = 哨兵岗 `NONE / 未分配` 的 id */ + positionId: positionIdSchema, + /** 只由名册同步置 `disabled`(Keycloak 停用或删除);有效 token 不改它 */ + state: staffStateSchema.default('active'), + /** realm 角色快照(token 签发时;噪声角色已滤) */ + roles: z.array(z.string()).default([]), +}); +export type Staff = z.infer; + +/* ── 我是谁、能进哪几个端(ADR-012) ───────────────────────────────── */ + +export const principalSchema = z.object({ + staff: staffSchema, + position: positionSchema, + roles: z.array(z.string()), + ends: z.array(surfaceSchema), +}); +export type Principal = z.infer; + +/** 角色 → 端:一行一个 realm 角色 */ +export const roleAccessSchema = z.object({ + role: z.string().min(1), + ends: z.array(surfaceSchema), +}); +export type RoleAccess = z.infer; + +/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */ +export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [ + z.object({ configured: z.literal(false), message: z.string() }), + z.object({ + configured: z.literal(true), + inserted: z.number().int().min(0), + updated: z.number().int().min(0), + disabled: z.number().int().min(0), + total: z.number().int().min(0), + }), +]); +export type RosterSyncOutcome = z.infer; + +/* ── 在途工作(只读;OS 02:改派先于停用,停用本身已归鉴权中心) ───── */ + +export const inFlightWorkSchema = z.object({ + staffId: staffIdSchema, + blocked: z.boolean(), + openTasks: z.array(z.object({ taskId: z.string(), title: z.string() })).default([]), + openCards: z.array(z.object({ cardId: z.string(), title: z.string() })).default([]), + openApprovals: z.array(z.object({ approvalId: z.string(), title: z.string() })).default([]), +}); +export type InFlightWork = z.infer; + +/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */ +export const disableCheckSchema = inFlightWorkSchema; +/** @deprecated 改用 `InFlightWork` */ +export type DisableCheck = InFlightWork; + +/* ── 岗位授权(已退役,ADR-012;类型留一轮给 web 编译,下个迭代删) ──── */ + +/** @deprecated 权限只到端,动作三分已退役 */ +export const GRANT_ACTIONS = ['read', 'write', 'govern'] as const; +/** @deprecated */ +export const grantActionSchema = z.enum(GRANT_ACTIONS); +/** @deprecated */ +export type GrantAction = z.infer; +/** @deprecated */ +export const GRANT_ACTION_LABEL: Readonly> = { + read: '读', + write: '写', + govern: '治理', +}; + +/** @deprecated 治理台九功能不再是授权客体 */ +export const GOVERNANCE_FUNCTIONS = [ + 'task', + 'staff', + 'process', + 'trigger', + 'activity', + 'tool', + 'data', + 'memory', + 'system', +] as const; +/** @deprecated */ +export const governanceFunctionSchema = z.enum(GOVERNANCE_FUNCTIONS); +/** @deprecated */ +export type GovernanceFunction = z.infer; +/** @deprecated */ +export const GOVERNANCE_FUNCTION_LABEL: Readonly> = { + task: '任务管理', + staff: '员工管理', + process: '工作流管理', + trigger: '触发器配置', + activity: '活动库管理', + tool: '工具管理', + data: '数据管理', + memory: '记忆管理', + system: '系统管理', +}; + +/** @deprecated */ +export const grantObjectSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('data'), + domain: z.string().min(1), + object: z.string().min(1), + field: z.string().optional(), + }), + z.object({ kind: z.literal('governance'), function: governanceFunctionSchema }), + z.object({ kind: z.literal('task'), activityId: z.string().optional() }), +]); +/** @deprecated */ +export type GrantObject = z.infer; + +/** @deprecated */ +export const grantSchema = z.object({ + positionId: positionIdSchema, + object: grantObjectSchema, + action: grantActionSchema, +}); +/** @deprecated */ +export type Grant = z.infer; + +/* ── 会话(已退役:身份在 token 里,ADR-012) ──────────────────────── */ + +/** @deprecated 改用 `principalSchema` / `GET /api/me` */ +export const sessionSchema = z.object({ + staff: staffSchema, + position: positionSchema, + grants: z.array(grantSchema), +}); +/** @deprecated 改用 `Principal` */ +export type Session = z.infer; + +/* ── 工作组(ADR-011) ────────────────────────────────────────────────── */ + +/** + * 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。 + * + * **不是第五种负责人策略**(OS 03「策略封闭四选」不动): + * 四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,策略于是无从发问。 + * 键是**岗位 code**(BD / PM / FAE …),与流程包 `owner.position` 同一口径。 + */ +export const workGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + /** 组归谁 —— 起单时按来源人取其组 */ + ownerId: staffIdSchema, + members: z.record(z.string().min(1), staffIdSchema), +}); +export type WorkGroup = z.infer; + +/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */ +export const workGroupBindingSchema = z.record(z.string().min(1), staffIdSchema); +export type WorkGroupBinding = z.infer; +``` + +- [ ] **Step 5: `packages/contracts/src/api/workbench.ts` —— 每条路由加 `access`,新增两条** + +在每个 `xxxRoute = { method: …, path: …,` 的 `path:` 行之后插入一行 `access: …,`。取值: + +| 路由 | access | +|---|---| +| `sessionRoute` `loginRoute` `logoutRoute` | `'public'`(退役;各加 JSDoc `/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */`) | +| `healthRoute` | `'public'` | +| `listTasksRoute` `taskDetailRoute` `replyWorkOrderRoute` `replyChatCardRoute` `markCommReadRoute` `saveTableRoute` `openAssetFileRoute` `sayRoute` `listMessagesRoute` `markMessageReadRoute` `answerMessageRoute` `confirmTriggerPendingRoute` `listMinutesRoute` `createMinuteRoute` `appendResolutionRoute` `registerResolutionRoute` `draftReportRoute` `listReportsRoute` `submitReportRoute` `listApprovalsForMeRoute` `decideApprovalRoute` `listRegistrationTriggersRoute` `fireRegistrationRoute` | `'workbench'` | + +把 `import { sessionSchema } from '../org.ts';` 改成 `import { principalSchema, sessionSchema } from '../org.ts';`,并在 `healthRoute` 之后加: + +```ts +/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */ +export const meRoute = { + method: 'GET', + path: '/api/me', + access: 'any', + output: principalSchema, +} as const satisfies RouteContract; + +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。GET 上 `?ticket=` 认它。 + */ +export const authTicketRoute = { + method: 'GET', + path: '/api/auth/ticket', + access: 'any', + output: z.object({ ticket: z.string().min(1), expiresAt: z.string() }), +} as const satisfies RouteContract; +``` + +- [ ] **Step 6: `packages/contracts/src/api/governance.ts` —— 每条路由加 `access`,新增六条** + +取值(写归所属端;两端共用的读标数组): + +| access | 路由 | +|---|---| +| `'public'`(退役,加 `@deprecated` JSDoc) | `createStaffRoute` `disableStaffCheckRoute` `disableStaffRoute` `listGrantsRoute` `setGrantsRoute` | +| `'design'` | `saveProcessRoute` `setProcessStateRoute` `deleteProcessVersionRoute` `deleteProcessFlowRoute` `editProcessVersionRoute` `validateProcessRoute` `listApprovalTemplatesRoute` `upsertApprovalTemplateRoute` `deleteApprovalTemplateRoute` `upsertTriggerRoute` `listActivitiesRoute` `installActivityRoute` `listToolsRoute` `registerToolRoute` `listActivityMemoryRoute` `listLlmProvidersRoute` `listLlmInstancesRoute` `upsertLlmInstanceRoute` `deleteLlmInstanceRoute` `probeLlmDraftRoute` `probeLlmInstanceRoute` `testRunRoute` `testRunActivityRoute` `testStopRoute` `testCurrentRoute` `testActivityStateRoute` `listTestLogsRoute` `testLogDetailRoute` `deleteTestLogRoute` `clearTestLogsRoute` `uploadFileRoute` `downloadFileRoute` `traceOfWorkflowRoute` | +| `'ops'` | `listGovTasksRoute` `controlTaskRoute` `resumeTaskRoute` `reassignTaskRoute` `govTaskLogRoute` `listApprovalsRoute` `govMetricsRoute` `toggleTriggerRoute` `setTriggerConfirmRoute` `listNodeOwnersRoute` `setNodeOwnerRoute` `listWorkGroupsRoute` `saveWorkGroupRoute` | +| `['design', 'ops']` | `listStaffRoute` `listProcessPackagesRoute` `listTriggersRoute` `listChannelsRoute` `listLedgerObjectsRoute` `ledgerByActivityRoute` `browseLedgerRoute` `govWorkflowRoute` | + +把第 7 行的 import 改成: + +```ts +import { + disableCheckSchema, + grantSchema, + inFlightWorkSchema, + positionSchema, + roleAccessSchema, + rosterSyncOutcomeSchema, + staffSchema, + workGroupSchema, +} from '../org.ts'; +import { surfaceSchema } from './http.ts'; +``` + +在「员工管理」段末尾(`setGrantsRoute` 之后)加六条: + +```ts +/* ── 员工管理 · 名册与岗位(ADR-012:人来自鉴权中心,岗位本地指派) ────── */ + +/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */ +export const syncStaffRoute = { + method: 'POST', + path: '/api/gov/staff/sync', + access: 'ops', + output: rosterSyncOutcomeSchema, +} as const satisfies RouteContract; + +/** 指派岗位;指回哨兵岗 NONE = 取消指派 */ +export const setStaffPositionRoute = { + method: 'PUT', + path: '/api/gov/staff/:staffId/position', + access: 'ops', + params: z.object({ staffId: staffIdSchema }), + body: z.object({ positionId: positionIdSchema }), + output: staffSchema.extend({ position: positionSchema }), +} as const satisfies RouteContract; + +/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */ +export const staffInFlightRoute = { + method: 'GET', + path: '/api/gov/staff/:staffId/in-flight', + access: 'ops', + params: z.object({ staffId: staffIdSchema }), + output: inFlightWorkSchema, +} as const satisfies RouteContract; + +/* ── 角色 → 端(ADR-012:权限只到端,这张表运营中心可改) ─────────────── */ + +export const listRoleAccessRoute = { + method: 'GET', + path: '/api/gov/access/roles', + access: 'ops', + output: z.object({ + roles: z.array(roleAccessSchema), + /** 名册上见过的角色 ∪ 已配置的角色 —— 配置前它等于「无端可进」 */ + knownRoles: z.array(z.string()), + }), +} as const satisfies RouteContract; + +/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */ +export const setRoleAccessRoute = { + method: 'PUT', + path: '/api/gov/access/roles/:role', + access: 'ops', + params: z.object({ role: z.string().min(1) }), + body: z.object({ ends: z.array(surfaceSchema) }), + output: roleAccessSchema, +} as const satisfies RouteContract; + +export const deleteRoleAccessRoute = { + method: 'DELETE', + path: '/api/gov/access/roles/:role', + access: 'ops', + params: z.object({ role: z.string().min(1) }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; +``` + +`packages/contracts/src/api/ingest.ts`:`ingestMailRoute` 加 `access: 'public',`(外界边界,ADR-010 原判)。 + +- [ ] **Step 7: 跑测试与 typecheck** + +Run: `pnpm vitest run packages/contracts/src/__tests__/route-access.test.ts && pnpm typecheck` +Expected: 5 条 PASS;typecheck 绿(web 引用的 `Session` / `loginRoute` 等都还在,只是多了 `access` 字段)。若 typecheck 报某条路由缺 `access`,补上。 + +- [ ] **Step 8: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add packages/contracts api-report +git commit -m "契约:三个端与路由归属(access 必填)、Principal / 角色→端 / 名册同步 schema、退役路由标 @deprecated(ADR-012)" +``` + +--- + +### Task 2: org domain —— claims 归一、角色→端、名册差异、测试账号策略(纯函数) + +**Files:** +- Modify: `apps/api/src/modules/org/contract.ts`(追加 `Identity` 与三个错误类) +- Modify: `apps/api/src/modules/org/port.ts`(追加 `VerifiedClaims` `TokenVerifier` `RosterUser` `RosterSource` `IdentityPatch`) +- Modify: `apps/api/src/modules/org/internal/domain/seed-data.ts`(`POSITION_SEED` 加 NONE;追加三个常量) +- Create: `apps/api/src/modules/org/internal/domain/claims.ts` `ends.ts` `roster-diff.ts` `test-account.ts` `in-flight.ts` +- Modify: `apps/api/src/modules/org/index.ts`(追加导出) +- Test: `apps/api/src/modules/org/__tests__/claims.test.ts` `ends.test.ts` `roster-diff.test.ts` `test-account.test.ts` + +**Interfaces:** +- Produces:`Identity{sub,account,name,email?,roles}`;`TokenRejectedError(message)` `AccountDisabledError(account)` `AccessLockoutError()`;`VerifiedClaims` `TokenVerifier{verify(token)}` `RosterUser = Identity & {enabled}` `RosterSource{listUsers()}` `IdentityPatch{account,name,email?,roles,lastSeenAt?}`;`identityOf(claims)` `isNoiseRole` `normalizeRoles`;`endsOf(roles, table)` `assertNotLockedOut(table, activeStaffRoles)` `normalizeEnds`;`diffRoster(remote, local)` `identityChanged(staff, identity)` `patchOf(identity)` `sameRoles`;`isTestAccount(account, list)` `soloGroupOf(staff)`;`buildInFlightWork(staffId, work)`;`NONE_POSITION_CODE='NONE'` `TEST_POSITION_CODE='TEST'` `DEFAULT_ROLE_ACCESS`。 +- 旧导出(`STAFF_SEED` 等)本任务**不删**,Task 8 一并退役。 + +- [ ] **Step 1: 写失败测试(四个文件)** + +`apps/api/src/modules/org/__tests__/claims.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts'; + +describe('identityOf —— claims 归一成一个人', () => { + it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => { + const id = identityOf({ + sub: 'sub-1', + preferred_username: 'alice', + name: '爱丽丝', + email: 'a@x.test', + realm_access: { roles: ['user', 'default-roles-corp', 'offline_access', 'uma_authorization', 'admin', 'user'] }, + }); + expect(id).toEqual({ sub: 'sub-1', account: 'alice', name: '爱丽丝', email: 'a@x.test', roles: ['admin', 'user'] }); + }); + + it('没有 name(服务账号常见)→ 用用户名;没有角色 → 空数组;没有邮箱 → 不带键', () => { + const id = identityOf({ sub: 'sub-svc', preferred_username: 'service-account-bpos-e2e' }); + expect(id.name).toBe('service-account-bpos-e2e'); + expect(id.roles).toEqual([]); + expect('email' in id).toBe(false); + }); + + it('name 只有空白 → 同样回退;email 空串 → 不带键', () => { + const id = identityOf({ sub: 's', preferred_username: 'bob', name: ' ', email: '' }); + expect(id.name).toBe('bob'); + expect('email' in id).toBe(false); + }); + + it('realm_access 有但 roles 缺 → 空角色', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]); + }); +}); + +describe('噪声角色', () => { + it('三种家具角色都算噪声,业务角色不算', () => { + expect(isNoiseRole('default-roles-corp')).toBe(true); + expect(isNoiseRole('offline_access')).toBe(true); + expect(isNoiseRole('uma_authorization')).toBe(true); + expect(isNoiseRole('admin')).toBe(false); + }); + it('normalizeRoles 去重、排序、丢空串', () => { + expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']); + }); +}); +``` + +`apps/api/src/modules/org/__tests__/ends.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { RoleAccess } from '@contracts'; +import { AccessLockoutError } from '../contract.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../internal/domain/ends.ts'; + +const table: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + { role: 'designer', ends: ['design'] }, +]; + +describe('endsOf —— 角色命中行的并集', () => { + it('单角色', () => { + expect(endsOf(['user'], table)).toEqual(['workbench']); + }); + it('多角色并集,按 SURFACES 顺序', () => { + expect(endsOf(['user', 'designer'], table)).toEqual(['design', 'workbench']); + }); + it('没有任何行命中 → 空(无端可进)', () => { + expect(endsOf(['auditor'], table)).toEqual([]); + }); + it('表空 → 空', () => { + expect(endsOf(['admin'], [])).toEqual([]); + }); +}); + +describe('assertNotLockedOut —— 改完得有人还能进运营中心', () => { + it('名册空着不算锁死', () => { + expect(() => assertNotLockedOut([], [])).not.toThrow(); + }); + it('唯一的 admin 被摘掉 ops → 拒', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['user']])).toThrow(AccessLockoutError); + }); + it('ops 换到另一个在册角色上 → 过', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design'] }, + { role: 'ops-lead', ends: ['ops'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['ops-lead']])).not.toThrow(); + }); + it('有 ops 行但没人持有那个角色 → 拒', () => { + expect(() => assertNotLockedOut([{ role: 'ghost', ends: ['ops'] }], [['user']])).toThrow(AccessLockoutError); + }); +}); + +describe('normalizeEnds', () => { + it('去重、按固定顺序', () => { + expect(normalizeEnds(['workbench', 'design', 'workbench'])).toEqual(['design', 'workbench']); + }); +}); +``` + +`apps/api/src/modules/org/__tests__/roster-diff.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts'; +import type { RosterUser } from '../port.ts'; + +const none = positionIdSchema.parse('none'); +const local = (sub: string, over: Partial = {}): Staff => ({ + id: staffIdSchema.parse(sub), + account: sub, + name: sub, + positionId: none, + state: 'active', + roles: ['user'], + ...over, +}); +const remote = (sub: string, over: Partial = {}): RosterUser => ({ + sub, + account: sub, + name: sub, + roles: ['user'], + enabled: true, + ...over, +}); + +describe('diffRoster —— 远端说了算', () => { + it('远端有本地无 → 插', () => { + const d = diffRoster([remote('a')], []); + expect(d.insert.map((u) => u.sub)).toEqual(['a']); + expect(d.update).toEqual([]); + expect(d.setState).toEqual([]); + }); + it('改名 / 改角色 → 改;没变的不出现', () => { + const d = diffRoster([remote('a', { name: '阿尔法', roles: ['admin', 'user'] }), remote('b')], [local('a'), local('b')]); + expect(d.update).toEqual([{ id: 'a', patch: { account: 'a', name: '阿尔法', roles: ['admin', 'user'] } }]); + expect(d.setState).toEqual([]); + }); + it('远端 enabled=false → 停;本地已停不重复', () => { + const d = diffRoster( + [remote('a', { enabled: false }), remote('b', { enabled: false })], + [local('a'), local('b', { state: 'disabled' })], + ); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('本地有远端无(Keycloak 删了人)→ 停', () => { + const d = diffRoster([], [local('a'), local('b', { state: 'disabled' })]); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('远端又启用了 → 复活', () => { + const d = diffRoster([remote('a')], [local('a', { state: 'disabled' })]); + expect(d.setState).toEqual([{ id: 'a', state: 'active' }]); + }); +}); + +describe('identityChanged / sameRoles', () => { + it('邮箱从无到有算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'], email: 'a@x' })).toBe(true); + }); + it('一模一样不算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'] })).toBe(false); + }); + it('角色顺序不同不算变', () => { + expect(sameRoles(['a', 'b'], ['b', 'a'])).toBe(true); + expect(sameRoles(['a'], ['a', 'b'])).toBe(false); + }); +}); +``` + +`apps/api/src/modules/org/__tests__/test-account.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { staffIdSchema } from '@contracts'; +import { isTestAccount, soloGroupOf } from '../internal/domain/test-account.ts'; + +describe('测试账号策略(ADR-011 语义原样)', () => { + it('用户名比对不分大小写、忽略首尾空白;空串永远不算', () => { + expect(isTestAccount('AI', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('Chaihao', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('fengbing', ['ai'])).toBe(false); + expect(isTestAccount('', [''])).toBe(false); + }); + it('独立工作组:五个业务岗全指向本人,id 由用户名铸', () => { + const g = soloGroupOf({ id: staffIdSchema.parse('sub-ai'), account: 'service-account-bpos-e2e', name: 'AI' }); + expect(g.id).toBe('wg-solo-service-account-bpos-e2e'); + expect(g.ownerId).toBe('sub-ai'); + expect(g.name).toBe('AI 独立测试组'); + expect(g.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/claims.test.ts apps/api/src/modules/org/__tests__/ends.test.ts apps/api/src/modules/org/__tests__/roster-diff.test.ts apps/api/src/modules/org/__tests__/test-account.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3: `contract.ts` 末尾追加** + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ + readonly account: string; + /** name claim,缺则回退 account */ + readonly name: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; +}; + +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export class TokenRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenRejectedError'; + } +} + +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export class AccountDisabledError extends Error { + constructor(readonly account: string) { + super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`); + this.name = 'AccountDisabledError'; + } +} + +/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */ +export class AccessLockoutError extends Error { + constructor() { + super('改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝'); + this.name = 'AccessLockoutError'; + } +} +``` + +- [ ] **Step 4: `port.ts` 末尾追加(并在文件头 import 加一行 `import type { Identity } from './contract.ts';`)** + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { readonly roles?: readonly string[] }; +}; + +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { verify(token: string): Promise }; + +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { readonly enabled: boolean }; +export type RosterSource = { listUsers(): Promise }; + +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; +``` + +- [ ] **Step 5: `seed-data.ts` 追加常量,`POSITION_SEED` 加哨兵岗** + +在 `POSITION_SEED` 数组的 TEST 项之后加一项: + +```ts + /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */ + { code: 'NONE', name: '未分配', note: '未指派岗位;流程包不得声明它' }, +``` + +文件末尾追加: + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +export const NONE_POSITION_CODE = 'NONE'; +export const TEST_POSITION_CODE = 'TEST'; + +/** + * 缺省的角色 → 端(表为空时播,之后由运营中心改): + * 与手册两个前端的口径一致 —— 用户端要 `user`、管理端要 `admin`。 + */ +export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; +``` + +文件头加 `import type { RoleAccess } from '@contracts';`。 + +- [ ] **Step 6: 五个 domain 文件** + +`apps/api/src/modules/org/internal/domain/claims.ts`: + +```ts +/** + * token claims → 一个人(纯函数,零 IO;ADR-012)。 + * + * 只认手册 §17 列出的那几个 claim;`name` 缺了回退用户名(服务账号常常没有 name)。 + * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: + * `default-roles-` / `offline_access` / `uma_authorization`。 + */ +import type { Identity } from '../../contract.ts'; +import type { VerifiedClaims } from '../../port.ts'; + +const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); + +export function isNoiseRole(role: string): boolean { + return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); +} + +/** 去重、滤噪声、排序 —— 角色快照要能逐字比对(「变了没有」靠它) */ +export function normalizeRoles(roles: readonly string[]): string[] { + return [...new Set(roles.filter((r) => r.trim() !== '' && !isNoiseRole(r)))].sort(); +} + +export function identityOf(claims: VerifiedClaims): Identity { + const account = claims.preferred_username; + const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); + return { + sub: claims.sub, + account, + name, + roles: normalizeRoles(claims.realm_access?.roles ?? []), + ...(email === undefined ? {} : { email }), + }; +} +``` + +`apps/api/src/modules/org/internal/domain/ends.ts`: + +```ts +/** + * 角色 → 端(纯函数,零 IO;ADR-012)。 + * + * 一个人的端 = 他的角色命中的每一行的并集。表可以空、角色可以一行都不命中 —— 那就是「无端可进」。 + * **锁死不变量**:改完之后至少一名在册活跃员工仍能进运营中心,否则这张表再也没人改得了。 + */ +import type { RoleAccess, Surface } from '@contracts'; +import { SURFACES } from '@contracts'; +import { AccessLockoutError } from '../../contract.ts'; + +export function endsOf(roles: readonly string[], table: readonly RoleAccess[]): Surface[] { + const hit = new Set(); + for (const row of table) { + if (!roles.includes(row.role)) continue; + for (const end of row.ends) hit.add(end); + } + return SURFACES.filter((s) => hit.has(s)); +} + +/** 名册空着(全新安装)不算锁死 —— 那时缺省表还在,第一个人进来就能改 */ +export function assertNotLockedOut( + table: readonly RoleAccess[], + activeStaffRoles: ReadonlyArray, +): void { + if (activeStaffRoles.length === 0) return; + const someoneKeepsOps = activeStaffRoles.some((roles) => endsOf(roles, table).includes('ops')); + if (!someoneKeepsOps) throw new AccessLockoutError(); +} + +/** 一行存进表里的样子只有一种:去重、按 SURFACES 的顺序 */ +export function normalizeEnds(ends: readonly Surface[]): Surface[] { + return SURFACES.filter((s) => ends.includes(s)); +} +``` + +`apps/api/src/modules/org/internal/domain/roster-diff.ts`: + +```ts +/** + * 名册同步的差异(纯函数,零 IO;ADR-012)。 + * + * 远端(Keycloak Admin API)说了算: + * 不在本地 → 插;字段变了 → 改;远端 enabled=false 或**根本不在远端** → 停;远端又启用 → 复活。 + * 没变的不出现在结果里 —— 同步一次不该把全表重写一遍。 + */ +import type { Staff, StaffId } from '@contracts'; +import type { Identity } from '../../contract.ts'; +import type { IdentityPatch, RosterUser } from '../../port.ts'; + +export type RosterDiff = { + readonly insert: RosterUser[]; + readonly update: Array<{ readonly id: StaffId; readonly patch: IdentityPatch }>; + readonly setState: Array<{ readonly id: StaffId; readonly state: Staff['state'] }>; +}; + +export function sameRoles(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + return sa.every((x, i) => x === sb[i]); +} + +/** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */ +export function identityChanged(staff: Staff, identity: Identity): boolean { + return ( + staff.account !== identity.account || + staff.name !== identity.name || + staff.email !== identity.email || + !sameRoles(staff.roles, identity.roles) + ); +} + +export function patchOf(identity: Identity): IdentityPatch { + return { + account: identity.account, + name: identity.name, + roles: [...identity.roles], + ...(identity.email === undefined ? {} : { email: identity.email }), + }; +} + +export function diffRoster(remote: readonly RosterUser[], local: readonly Staff[]): RosterDiff { + const bySub = new Map(local.map((s) => [s.id, s])); + const diff: RosterDiff = { insert: [], update: [], setState: [] }; + const seen = new Set(); + for (const user of remote) { + seen.add(user.sub); + const existing = bySub.get(user.sub); + if (existing === undefined) { + diff.insert.push(user); + continue; + } + if (identityChanged(existing, user)) diff.update.push({ id: existing.id, patch: patchOf(user) }); + const wanted: Staff['state'] = user.enabled ? 'active' : 'disabled'; + if (existing.state !== wanted) diff.setState.push({ id: existing.id, state: wanted }); + } + for (const s of local) { + if (!seen.has(s.id) && s.state !== 'disabled') diff.setState.push({ id: s.id, state: 'disabled' }); + } + return diff; +} +``` + +`apps/api/src/modules/org/internal/domain/test-account.ts`: + +```ts +/** + * 测试账号策略(纯函数,零 IO;ADR-011 语义原样搬到 ADR-012 的名册上)。 + * + * 试跑与 e2e 用的账号要能一个人走完整条流:落 TEST 岗(不混进业务岗,免得轮值把真单派给它), + * 再建一个「所有业务岗都指向自己」的独立工作组。 + */ +import type { Staff, WorkGroup } from '@contracts'; +import { SOLO_GROUP_POSITIONS } from './seed-data.ts'; + +export function isTestAccount(account: string, testAccounts: readonly string[]): boolean { + const needle = account.trim().toLowerCase(); + return needle !== '' && testAccounts.some((a) => a.trim().toLowerCase() === needle); +} + +export function soloGroupOf(staff: Pick): WorkGroup { + return { + id: `wg-solo-${staff.account.toLowerCase()}`, + name: `${staff.name} 独立测试组`, + ownerId: staff.id, + members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])), + }; +} +``` + +`apps/api/src/modules/org/internal/domain/in-flight.ts`(原 `disable-check.ts` 的内容改名,旧文件 Task 8 删): + +```ts +/** + * 在途工作(纯逻辑,零 IO;OS 02:员工停用前,其在途任务与待答卡须先改派完毕)。 + * 停用本身已归鉴权中心(ADR-012),这里只剩「摆给人看」:此人身上还挂着什么。 + */ +import type { InFlightWork, StaffId } from '@contracts'; + +export type InFlightInput = { + readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>; + readonly openCards: ReadonlyArray<{ cardId: string; title: string }>; + readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>; +}; + +export function buildInFlightWork(staffId: StaffId, work: InFlightInput): InFlightWork { + const openTasks = [...work.openTasks]; + const openCards = [...work.openCards]; + const openApprovals = [...work.openApprovals]; + return { + staffId, + blocked: openTasks.length > 0 || openCards.length > 0 || openApprovals.length > 0, + openTasks, + openCards, + openApprovals, + }; +} +``` + +- [ ] **Step 7: `index.ts` 追加导出(放在既有导出之后)** + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ +export type { Identity } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, TokenRejectedError } from './contract.ts'; +export type { IdentityPatch, RosterSource, RosterUser, TokenVerifier, VerifiedClaims } from './port.ts'; +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; +``` + +- [ ] **Step 8: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/modules/org && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 新四个文件全 PASS,旧两个(authorization / password)照旧 PASS;typecheck / lint / depcruise 绿(`roster-diff.ts` 与 `test-account.ts` 被各自的测试引用,不算孤儿)。 + +- [ ] **Step 9: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add apps/api/src/modules/org api-report +git commit -m "org domain:claims 归一、角色→端与锁死不变量、名册差异、测试账号策略(ADR-012,纯函数)" +``` + +--- + +### Task 3: org adapter —— jose 验签(新依赖 `jose`) + +**Files:** +- Modify: `apps/api/package.json`(加 `jose`) +- Create: `apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts` +- Modify: `apps/api/src/modules/org/index.ts`(导出 `createKeycloakTokenVerifier` `explainJoseError`) +- Test: `apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts` + +**Interfaces:** +- Consumes:`TokenVerifier` `VerifiedClaims`(Task 2)、`TokenRejectedError`。 +- Produces:`createKeycloakTokenVerifier({ issuer, audience, getKey, clockTolerance? }): TokenVerifier`;`getKey` 是 jose 的 `JWTVerifyGetKey`(app 给 `createRemoteJWKSet(...)`,测试给 `createLocalJWKSet(...)`)。 + +- [ ] **Step 1: 装依赖** + +```bash +pnpm --filter @app/api add jose@^5.10.0 +``` +Expected:`apps/api/package.json` dependencies 里出现 `"jose": "^5.10.0"`(手册核实过的版本线;`createRemoteJWKSet` / `jwtVerify` / `errors` 在 5 与 6 同 API)。 + +- [ ] **Step 2: 写失败测试** + +`apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts`: + +```ts +/** + * Keycloak 是外界,替身只能在外界:这里用本地密钥对充当它的 JWKS(`createLocalJWKSet`), + * 验签逻辑本身一行不假 —— 与生产走的是同一个 `jwtVerify`。 + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { SignJWT, UnsecuredJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'; +import type { JWK, JWTVerifyGetKey } from 'jose'; +import { TokenRejectedError } from '../contract.ts'; +import { createKeycloakTokenVerifier } from '../internal/adapter/keycloak-token-verifier.ts'; +import type { TokenVerifier } from '../port.ts'; + +const ISSUER = 'https://keycloak.test/realms/corp'; +const AUDIENCE = 'api-gateway'; + +let privateKey: Awaited>['privateKey']; +let getKey: JWTVerifyGetKey; +let verifier: TokenVerifier; + +beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + const jwk: JWK = { ...(await exportJWK(pair.publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }; + getKey = createLocalJWKSet({ keys: [jwk] }); + verifier = createKeycloakTokenVerifier({ issuer: ISSUER, audience: AUDIENCE, getKey }); +}); + +type Mint = { + sub?: string; + issuer?: string; + audience?: string | string[]; + /** 秒级 unix 时刻;缺省 5 分钟后 */ + exp?: number; + kid?: string; + claims?: Record; +}; + +const nowSec = (): number => Math.floor(Date.now() / 1000); + +async function mint(o: Mint = {}): Promise { + const jwt = new SignJWT({ + preferred_username: 'alice', + name: '爱丽丝', + email: 'alice@akhr-ai.com', + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...o.claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: o.kid ?? 'k1' }) + .setSubject(o.sub ?? 'sub-alice') + .setIssuer(o.issuer ?? ISSUER) + .setAudience(o.audience ?? [AUDIENCE, 'account']) + .setIssuedAt() + .setExpirationTime(o.exp ?? nowSec() + 300); + return jwt.sign(privateKey); +} + +describe('Keycloak 验签', () => { + it('RS256 · iss · aud 都对 → 返回 claims', async () => { + const claims = await verifier.verify(await mint()); + expect(claims.sub).toBe('sub-alice'); + expect(claims.preferred_username).toBe('alice'); + expect(claims.name).toBe('爱丽丝'); + expect(claims.realm_access?.roles).toContain('user'); + }); + + it('过期 30 秒内(时钟容差)仍通过', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined(); + }); + + it('过期超过容差 → 拒,话是「已过期」', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 120 }))).rejects.toThrow(/已过期/); + }); + + it('aud 不含本服务 → 拒,话点名 audience mapper', async () => { + await expect(verifier.verify(await mint({ audience: ['data-service'] }))).rejects.toThrow(/audience mapper/); + }); + + it('iss 不符 → 拒,话点名 KEYCLOAK_URL', async () => { + await expect(verifier.verify(await mint({ issuer: 'https://other.test/realms/corp' }))).rejects.toThrow(/KEYCLOAK_URL/); + }); + + it('kid 对不上 JWKS → 拒,话点名 JWKS', async () => { + await expect(verifier.verify(await mint({ kid: 'k-unknown' }))).rejects.toThrow(/JWKS/); + }); + + it('HS256 → 拒(算法写死 RS256)', async () => { + const secret = new TextEncoder().encode('0123456789abcdef0123456789abcdef'); + const token = await new SignJWT({ preferred_username: 'alice' }) + .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(secret); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('alg: none → 拒', async () => { + const token = new UnsecuredJWT({ preferred_username: 'alice' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .encode(); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('缺 preferred_username → 拒,话点名 client scope', async () => { + const token = await new SignJWT({ realm_access: { roles: ['user'] } }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setSubject('sub-x') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(privateKey); + await expect(verifier.verify(token)).rejects.toThrow(/preferred_username/); + }); + + it('拒回的一律是 TokenRejectedError(中间件据此 401)', async () => { + await expect(verifier.verify('not-a-jwt')).rejects.toBeInstanceOf(TokenRejectedError); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts` +Expected: FAIL —— adapter 文件不存在。 + +- [ ] **Step 4: 写 adapter** + +`apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts`: + +```ts +/** + * Keycloak 验签(手册 §4;ADR-012)—— 本地计算,不联网(公钥由 getKey 缓存)。 + * + * 四项校验,顺序即安全要点:`algorithms: ['RS256']` 写死(绝不读 header 的 alg)、 + * `issuer`、`audience`(本服务 clientId)、`clockTolerance: 30`。 + * jose 的错误翻成人话(手册 4.4)后包成 TokenRejectedError —— 排错时能省很多时间。 + */ +import { jwtVerify, errors as joseErrors } from 'jose'; +import type { JWTVerifyGetKey } from 'jose'; +import { z } from 'zod'; +import { TokenRejectedError } from '../../contract.ts'; +import type { TokenVerifier, VerifiedClaims } from '../../port.ts'; + +export type KeycloakVerifierOptions = { + readonly issuer: string; + readonly audience: string; + /** app 给 `createRemoteJWKSet(certsUrl, {...})`;测试给 `createLocalJWKSet(jwks)` */ + readonly getKey: JWTVerifyGetKey; + /** 秒;缺省 30 */ + readonly clockTolerance?: number; +}; + +const claimsSchema = z.object({ + sub: z.string().min(1), + preferred_username: z.string().min(1), + name: z.string().optional(), + email: z.string().optional(), + realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), +}); + +/** jose 的错误 → 人话(手册 4.4)。JWTExpired 是 JWTClaimValidationFailed 的子类,先判它 */ +export function explainJoseError(error: unknown, ctx: { readonly issuer: string; readonly audience: string }): string { + if (error instanceof joseErrors.JWTExpired) return 'token 已过期,用 refresh_token 换个新的'; + if (error instanceof joseErrors.JWTClaimValidationFailed) { + if (error.claim === 'aud') { + return `token 的 aud 不含 "${ctx.audience}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; + } + if (error.claim === 'iss') return `token 的 iss 与 "${ctx.issuer}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + return `token 的 ${error.claim} 校验未过`; + } + if (error instanceof joseErrors.JWKSNoMatchingKey) { + return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; + } + if (error instanceof joseErrors.JWSSignatureVerificationFailed) { + return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; + } + if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'token 的签名算法不被接受 —— 只认 RS256'; + if (error instanceof joseErrors.JWSInvalid || error instanceof joseErrors.JWTInvalid) return 'token 格式不合法'; + return error instanceof Error ? error.message : String(error); +} + +export function createKeycloakTokenVerifier(options: KeycloakVerifierOptions): TokenVerifier { + return { + async verify(token: string): Promise { + let payload: unknown; + try { + ({ payload } = await jwtVerify(token, options.getKey, { + algorithms: ['RS256'], + issuer: options.issuer, + audience: options.audience, + clockTolerance: options.clockTolerance ?? 30, + })); + } catch (error) { + throw new TokenRejectedError(explainJoseError(error, options)); + } + const parsed = claimsSchema.safeParse(payload); + if (!parsed.success) { + throw new TokenRejectedError('token 缺少 sub 或 preferred_username —— 检查 Keycloak 的 client scope 是否带 profile'); + } + return parsed.data; + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +``` + +- [ ] **Step 5: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 10 条 PASS;门禁绿(`jose` 在 adapter,不在 domain / usecase)。 + +- [ ] **Step 6: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add apps/api/package.json pnpm-lock.yaml apps/api/src/modules/org api-report +git commit -m "org adapter:jose 验签 —— RS256 写死、校 iss/aud、30s 容差、错误译人话(ADR-012;新依赖 jose,用户已批)" +``` + +--- + +### Task 4: org adapter —— Keycloak Admin API 拉名册 + +**Files:** +- Create: `apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts` +- Modify: `apps/api/src/modules/org/index.ts`(导出 `createKeycloakRoster`) +- Test: `apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts` + +**Interfaces:** +- Consumes:`RosterSource` `RosterUser`(Task 2)、`isNoiseRole`(Task 2)。 +- Produces:`createKeycloakRoster({ url, realm, clientId, secret, fetch?, now? }): RosterSource`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts`: + +```ts +/** Admin API 是外界 —— fetch 是注入的,这里给一个会分页、会发 token 的假 Keycloak */ +import { describe, expect, it } from 'vitest'; +import { createKeycloakRoster } from '../internal/adapter/keycloak-admin-roster.ts'; + +function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { + const calls: string[] = []; + const users = Array.from({ length: 105 }, (_, i) => ({ + id: `sub-${i}`, + username: `u${i}`, + enabled: i !== 3, + firstName: i === 0 ? '' : '名', + lastName: i === 0 ? '' : `${i}`, + email: `u${i}@x.test`, + })); + const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (url.endsWith('/realms/master/protocol/openid-connect/token')) { + return json({ access_token: 'admin-token', expires_in: 300 }); + } + const u = new URL(url); + if (u.pathname === '/admin/realms/corp/users') { + const first = Number(u.searchParams.get('first')); + const max = Number(u.searchParams.get('max')); + return json(users.slice(first, first + max)); + } + const m = /^\/admin\/realms\/corp\/users\/([^/]+)\/role-mappings\/realm\/composite$/.exec(u.pathname); + if (m !== null) { + return json([ + { name: 'user' }, + { name: 'default-roles-corp' }, + { name: 'offline_access' }, + ...(m[1] === 'sub-1' ? [{ name: 'admin' }] : []), + ]); + } + return json({ error: 'not found' }, 404); + }; + return { fetch: fetchImpl, calls }; +} + +describe('Keycloak Admin API 名册', () => { + it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + const kc = fakeKeycloak(); + const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); + const users = await roster.listUsers(); + expect(users).toHaveLength(105); + expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + expect(users[3]?.enabled).toBe(false); + expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); + }); + + it('管理 token 只取一次(闭包缓存,提前 60 秒过期)', async () => { + const kc = fakeKeycloak(); + let t = 0; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch, now: () => t }); + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(1); + t = 241 * 1000; // expires_in 300 - 60 = 240 秒后才过期 + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(2); + }); + + it('Admin API 非 2xx → 抛带状态码的错(不吞)', async () => { + const fetchImpl: typeof fetch = async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith('/token')) return new Response(JSON.stringify({ access_token: 't', expires_in: 300 }), { status: 200 }); + return new Response('forbidden', { status: 403 }); + }; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'p', secret: 's', fetch: fetchImpl }); + await expect(roster.listUsers()).rejects.toThrow(/403/); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts` +Expected: FAIL —— adapter 文件不存在。 + +- [ ] **Step 3: 写 adapter** + +`apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts`: + +```ts +/** + * 名册来源:Keycloak Admin API(手册 §11 / §14;ADR-012)。 + * + * 用 master realm 的 provisioning 服务账号(client_credentials)取管理 token,只读: + * GET /admin/realms/{realm}/users?first&max=100(列表默认 max=100,必须分页) + * GET /users/{id}/role-mappings/realm/composite(实际生效的 realm 角色) + * 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。 + */ +import type { RosterSource, RosterUser } from '../../port.ts'; +import { isNoiseRole } from '../domain/claims.ts'; + +export type KeycloakRosterOptions = { + readonly url: string; + readonly realm: string; + readonly clientId: string; + readonly secret: string; + /** 测试注入;缺省全局 fetch */ + readonly fetch?: typeof fetch; + /** 毫秒时刻;测试注入 */ + readonly now?: () => number; +}; + +type KcUser = { + readonly id: string; + readonly username: string; + readonly enabled: boolean; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; +type KcRole = { readonly name: string }; + +const PAGE = 100; + +export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSource { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? ((): number => Date.now()); + const base = options.url.replace(/\/+$/, ''); + let cached: { token: string; expiresAt: number } | undefined; + + async function adminToken(): Promise { + if (cached !== undefined && now() < cached.expiresAt) return cached.token; + const resp = await fetchImpl(`${base}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: options.clientId, + client_secret: options.secret, + }), + }); + if (!resp.ok) throw new Error(`取 Keycloak 管理 token 失败:${resp.status} ${await resp.text()}`); + const data = (await resp.json()) as { access_token: string; expires_in: number }; + // 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调 + cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 }; + return cached.token; + } + + async function adminGet(path: string): Promise { + const token = await adminToken(); + const resp = await fetchImpl(`${base}/admin/realms/${options.realm}${path}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`Admin API GET ${path} 失败:${resp.status} ${await resp.text()}`); + return (await resp.json()) as T; + } + + return { + async listUsers(): Promise { + const users: KcUser[] = []; + for (let first = 0; ; first += PAGE) { + const page = await adminGet(`/users?first=${first}&max=${PAGE}&briefRepresentation=false`); + users.push(...page); + if (page.length < PAGE) break; + } + const out: RosterUser[] = []; + for (const u of users) { + const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); + const name = [u.firstName, u.lastName] + .filter((s): s is string => s !== undefined && s.trim() !== '') + .join(' ') + .trim(); + out.push({ + sub: u.id, + account: u.username, + name: name === '' ? u.username : name, + roles: [...new Set(roles.map((r) => r.name).filter((r) => !isNoiseRole(r)))].sort(), + enabled: u.enabled, + ...(u.email === undefined || u.email === '' ? {} : { email: u.email }), + }); + } + return out; + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +``` + +- [ ] **Step 4: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 3 条 PASS;门禁绿。 + +- [ ] **Step 5: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add apps/api/src/modules/org api-report +git commit -m "org adapter:Keycloak Admin API 拉名册(provisioning 服务账号,分页 100,只读 view-users)" +``` + +--- + +### Task 5: app —— 路由注册表(契约命名空间 → 归属) + +**Files:** +- Create: `apps/api/src/app/route-registry.ts` +- Test: `apps/api/src/app/__tests__/route-registry.test.ts` + +**Interfaces:** +- Consumes:`RouteContract.access` `HTTP_METHODS`(Task 1)。 +- Produces:`RegisteredRoute{method,path,access}`;`RouteRegistry{size, lookup(method, path), has(method, pathPattern)}`;`isRouteContract(value)`;`routesOf(values: readonly unknown[]): RegisteredRoute[]`;`buildRouteRegistry(routes): RouteRegistry`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/app/__tests__/route-registry.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { buildRouteRegistry, isRouteContract, routesOf } from '../route-registry.ts'; + +const registry = buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/testbed/logs', access: 'design' }, + { method: 'GET', path: '/api/gov/testbed/logs/:id', access: 'design' }, + { method: 'POST', path: '/api/gov/process/validate', access: 'design' }, + { method: 'POST', path: '/api/gov/process/:id/:version/state', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, +]); + +describe('路由注册表', () => { + it('字面路径精确命中', () => { + expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops'); + expect(registry.lookup('GET', '/api/health')).toBe('public'); + }); + it('参数段:一段认一段,多一段少一段都不算', () => { + expect(registry.lookup('POST', '/api/gov/tasks/t-1/control')).toBe('ops'); + expect(registry.lookup('POST', '/api/gov/tasks/t-1/x/control')).toBeUndefined(); + expect(registry.lookup('POST', '/api/gov/process/p1/3/state')).toBe('design'); + }); + it('方法不同不混', () => { + expect(registry.lookup('POST', '/api/gov/tasks')).toBeUndefined(); + expect(registry.lookup('GET', '/api/gov/tasks/t-1/control')).toBeUndefined(); + }); + it('字面优先于参数段(logs 与 logs/:id 各归各)', () => { + expect(registry.lookup('GET', '/api/gov/testbed/logs')).toBe('design'); + expect(registry.lookup('GET', '/api/gov/testbed/logs/abc')).toBe('design'); + expect(registry.lookup('GET', '/api/gov/testbed/logs/abc/def')).toBeUndefined(); + }); + it('未知路由 → undefined', () => { + expect(registry.lookup('GET', '/api/nope')).toBeUndefined(); + }); + it('数组归属原样回', () => { + expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); + }); + it('has 按注册时的模版查', () => { + expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true); + expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false); + expect(registry.has('GET', '/api/health')).toBe(true); + expect(registry.size).toBe(9); + }); +}); + +describe('routesOf —— 从契约命名空间挑路由', () => { + it('只认长得像路由契约的导出', () => { + expect(isRouteContract(contracts.meRoute)).toBe(true); + expect(isRouteContract(contracts.staffSchema)).toBe(false); + expect(isRouteContract('x')).toBe(false); + expect(isRouteContract(null)).toBe(false); + }); + it('扫真契约至少 90 条,且每条都能 has 到自己', () => { + const all = routesOf(Object.values(contracts)); + expect(all.length).toBeGreaterThanOrEqual(90); + const reg = buildRouteRegistry(all); + for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/route-registry.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3: 写 `apps/api/src/app/route-registry.ts`** + +```ts +/** + * 路由注册表(ADR-012)—— 「哪条路由归哪个端」的唯一事实源是契约本身: + * 每条 `RouteContract` 自带 `access`,这里把契约命名空间扫一遍,给中间件一个 `lookup`。 + * 新路由写在契约里就自动生效,不必谁记得再加一行守卫(漏一条就是一个没人管的口子)。 + * + * 匹配:`:name` 段认任意非空段;字面精确命中优先于参数段命中;方法不同不混。 + */ +import type { HttpMethod, RouteAccess, RouteContract } from '@contracts'; +import { HTTP_METHODS } from '@contracts'; + +export type RegisteredRoute = { + readonly method: HttpMethod; + readonly path: string; + readonly access: RouteAccess; +}; + +export type RouteRegistry = { + readonly size: number; + /** 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404) */ + lookup(method: string, path: string): RouteAccess | undefined; + /** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */ + has(method: string, pathPattern: string): boolean; +}; + +const METHODS: readonly string[] = HTTP_METHODS; + +export function isRouteContract(value: unknown): value is RouteContract { + if (typeof value !== 'object' || value === null) return false; + const v = value as { method?: unknown; path?: unknown; access?: unknown; output?: unknown }; + return ( + typeof v.method === 'string' && + METHODS.includes(v.method) && + typeof v.path === 'string' && + v.access !== undefined && + v.output !== undefined + ); +} + +/** 从一堆导出里挑出路由契约(`Object.values(contracts)` 喂进来) */ +export function routesOf(values: readonly unknown[]): RegisteredRoute[] { + return values.filter(isRouteContract).map((r) => ({ method: r.method, path: r.path, access: r.access })); +} + +function escapeSegment(segment: string): string { + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matcherOf(pattern: string): RegExp { + const body = pattern + .split('/') + .map((seg) => (seg.startsWith(':') ? '[^/]+' : escapeSegment(seg))) + .join('/'); + return new RegExp(`^${body}$`); +} + +export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry { + const literal = new Map(); + const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = []; + for (const r of routes) { + if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); + else literal.set(`${r.method} ${r.path}`, r.access); + } + return { + size: routes.length, + lookup(method, path) { + const exact = literal.get(`${method} ${path}`); + if (exact !== undefined) return exact; + return patterns.find((p) => p.method === method && p.re.test(path))?.access; + }, + has(method, pathPattern) { + return literal.has(`${method} ${pathPattern}`) || patterns.some((p) => p.method === method && p.pattern === pathPattern); + }, + }; +} +``` + +- [ ] **Step 4: 跑测试** + +Run: `pnpm vitest run apps/api/src/app/__tests__/route-registry.test.ts && pnpm typecheck && pnpm lint` +Expected: 9 条 PASS;绿。 + +- [ ] **Step 5: 提交** + +```bash +git add apps/api/src/app/route-registry.ts apps/api/src/app/__tests__/route-registry.test.ts +git commit -m "app:路由注册表 —— 契约自带 access,一处 lookup 给中间件(ADR-012)" +``` + +--- + +### Task 6: app —— 60 秒票据(HKDF + HMAC) + +**Files:** +- Create: `apps/api/src/app/ticket.ts` +- Test: `apps/api/src/app/__tests__/ticket.test.ts` + +**Interfaces:** +- Produces:`TICKET_TTL_MS = 60_000`;`TicketIssuer{ issue(staffId, now): {ticket, expiresAt}; verify(ticket, now): {staffId} | undefined }`;`createTicketIssuer(masterKey: Buffer): TicketIssuer`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/app/__tests__/ticket.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { TICKET_TTL_MS, createTicketIssuer } from '../ticket.ts'; + +const issuer = createTicketIssuer(Buffer.alloc(32, 7)); +const t0 = new Date('2026-08-25T09:00:00Z'); + +describe('60 秒票据', () => { + it('签出来能验回 sub;形状 v1...;到期时刻 = 签发 + 60s', () => { + const { ticket, expiresAt } = issuer.issue('sub-1', t0); + expect(ticket.split('.')).toHaveLength(4); + expect(ticket.startsWith('v1.')).toBe(true); + expect(expiresAt.getTime() - t0.getTime()).toBe(TICKET_TTL_MS); + expect(issuer.verify(ticket, new Date(t0.getTime() + 30_000))).toEqual({ staffId: 'sub-1' }); + }); + + it('到期即拒(61 秒后)', () => { + const { ticket } = issuer.issue('sub-1', t0); + expect(issuer.verify(ticket, new Date(t0.getTime() + TICKET_TTL_MS + 1000))).toBeUndefined(); + }); + + it('改一个字就拒:mac / sub / exp', () => { + const { ticket } = issuer.issue('sub-1', t0); + const [v, id, exp, mac] = ticket.split('.') as [string, string, string, string]; + expect(issuer.verify(`${v}.${id}.${exp}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`, t0)).toBeUndefined(); + expect(issuer.verify(`${v}.${Buffer.from('sub-2').toString('base64url')}.${exp}.${mac}`, t0)).toBeUndefined(); + expect(issuer.verify(`${v}.${id}.${Number(exp) + 600}.${mac}`, t0)).toBeUndefined(); + }); + + it('别的密钥签的不认', () => { + const other = createTicketIssuer(Buffer.alloc(32, 8)); + expect(issuer.verify(other.issue('sub-1', t0).ticket, t0)).toBeUndefined(); + }); + + it('垃圾输入不抛只拒', () => { + expect(issuer.verify('', t0)).toBeUndefined(); + expect(issuer.verify('v2.a.b.c', t0)).toBeUndefined(); + expect(issuer.verify('a.b', t0)).toBeUndefined(); + expect(issuer.verify('v1...', t0)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/ticket.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3: 写 `apps/api/src/app/ticket.ts`** + +```ts +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。 + * + * 形状 `v1...`;密钥由 JCAI_SECRET_KEY 经 HKDF 派生(不直接拿主密钥签)。 + * 票据里**没有权限**:中间件拿 sub 回库现算 principal,停用 / 改角色即时生效。 + * 无状态、不单次 —— 60 秒可重放窗换零状态表(记 debt);只在 GET 的 ?ticket= 上认,POST 带了也当没带。 + */ +import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto'; + +export const TICKET_TTL_MS = 60_000; + +export type TicketIssuer = { + issue(staffId: string, now: Date): { ticket: string; expiresAt: Date }; + verify(ticket: string, now: Date): { staffId: string } | undefined; +}; + +export function createTicketIssuer(masterKey: Buffer): TicketIssuer { + const key = Buffer.from(hkdfSync('sha256', masterKey, '', 'jcai-ticket', 32)); + const sign = (body: string): string => createHmac('sha256', key).update(body).digest('base64url'); + + return { + issue(staffId, now) { + const expUnix = Math.floor((now.getTime() + TICKET_TTL_MS) / 1000); + const body = `v1.${Buffer.from(staffId, 'utf8').toString('base64url')}.${expUnix}`; + return { ticket: `${body}.${sign(body)}`, expiresAt: new Date(expUnix * 1000) }; + }, + verify(ticket, now) { + const parts = ticket.split('.'); + if (parts.length !== 4) return undefined; + const [version, idRaw, expRaw, mac] = parts; + if (version !== 'v1' || idRaw === undefined || idRaw === '' || expRaw === undefined || mac === undefined) return undefined; + const expected = sign(`${version}.${idRaw}.${expRaw}`); + const given = Buffer.from(mac, 'utf8'); + const wanted = Buffer.from(expected, 'utf8'); + if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) return undefined; + const expUnix = Number(expRaw); + if (!Number.isInteger(expUnix) || expUnix * 1000 <= now.getTime()) return undefined; + const staffId = Buffer.from(idRaw, 'base64url').toString('utf8'); + return staffId === '' ? undefined : { staffId }; + }, + }; +} +``` + +- [ ] **Step 4: 跑测试** + +Run: `pnpm vitest run apps/api/src/app/__tests__/ticket.test.ts && pnpm typecheck && pnpm lint` +Expected: 5 条 PASS;绿。 + +- [ ] **Step 5: 提交** + +```bash +git add apps/api/src/app/ticket.ts apps/api/src/app/__tests__/ticket.test.ts +git commit -m "app:60 秒 HMAC 票据 —— 给 EventSource 与 下载这两条带不了头的路(ADR-012)" +``` + +--- + +### Task 7: app —— 鉴权中间件(凭据 → Principal → 端判定) + +**Files:** +- Modify: `apps/api/src/app/http-env.ts`(`Variables` 加 `principal`;旧的 `account` / `session` 本任务先留着,Task 8 删) +- Create: `apps/api/src/app/auth-middleware.ts` +- Test: `apps/api/src/app/__tests__/auth-middleware.test.ts` + +**Interfaces:** +- Consumes:`RouteRegistry`(Task 5)、`TicketIssuer`(Task 6)、`TokenRejectedError` / `AccountDisabledError`(Task 2)、`Principal` `SURFACE_LABEL`(Task 1)。 +- Produces:`Authenticator{ authenticate(token): Promise; principalOf(staffId: StaffId): Promise }`;`AuthMiddlewareDeps{registry, org: Authenticator, tickets, clock}`;`createAuthMiddleware(deps): MiddlewareHandler`;`bearerOf(header)` `requiredEnds(access)` `forbiddenBody(need, principal)`。Task 8 的 `OrgService` 结构上满足 `Authenticator`。 + +- [ ] **Step 1: `http-env.ts` 加 `principal`** + +```ts +/** + * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 + */ +import type { Principal, Session } from '@contracts'; + +/** + * 请求上下文。`principal` 由鉴权中间件从 Bearer / 票据解出(ADR-012); + * `account` / `session` 是 ADR-010 的旧物,随 Task 8 一并删除。 + */ +export type AppEnv = { + Variables: { account: string; session: Session | undefined; principal: Principal | undefined }; +}; +``` + +- [ ] **Step 2: 写失败测试** + +`apps/api/src/app/__tests__/auth-middleware.test.ts`: + +```ts +/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import type { Position, Principal, Surface } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts'; +import type { AppEnv } from '../http-env.ts'; +import { buildRouteRegistry } from '../route-registry.ts'; +import { createTicketIssuer } from '../ticket.ts'; + +const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; +const principal = (ends: Surface[], roles: string[]): Principal => ({ + staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles }, + position, + roles, + ends, +}); +const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']); +const USER = principal(['workbench'], ['user']); +const NOBODY = principal([], ['auditor']); + +const tickets = createTicketIssuer(Buffer.alloc(32, 3)); +const now = new Date('2026-08-25T09:00:00Z'); + +function makeApp(): Hono { + const app = new Hono(); + app.use( + '*', + createAuthMiddleware({ + registry: buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/desk/tasks', access: 'workbench' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, + ]), + org: { + authenticate: async (token) => { + if (token === 'admin') return ADMIN; + if (token === 'user') return USER; + if (token === 'nobody') return NOBODY; + if (token === 'disabled') throw new AccountDisabledError('alice'); + throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'); + }, + principalOf: async (id) => (id === 'sub-1' ? USER : undefined), + }, + tickets, + clock: { now: () => now }, + }), + ); + app.get('/api/health', (c) => c.json({ ok: true })); + app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null })); + app.get('/api/desk/tasks', (c) => c.json({ ok: true })); + app.get('/api/gov/tasks', (c) => c.json({ ok: true })); + app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true })); + app.get('/api/gov/staff', (c) => c.json({ ok: true })); + return app; +} + +const app = makeApp(); +const get = (path: string, headers: Record = {}): Promise => app.request(path, { headers }); +const post = (path: string, headers: Record = {}): Promise => + app.request(path, { method: 'POST', headers }); +type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; + +describe('鉴权中间件', () => { + it('public 不要凭据', async () => { + expect((await get('/api/health')).status).toBe(200); + }); + it('没凭据 → 401', async () => { + const res = await get('/api/me'); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' }); + }); + it('坏 token → 401 带人话', async () => { + const res = await get('/api/me', { authorization: 'Bearer garbage' }); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('签名校验失败'); + }); + it('any:登录即可,连无端可进的人也行', async () => { + const res = await get('/api/me', { authorization: 'Bearer nobody' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ account: 'alice' }); + }); + it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => { + const res = await get('/api/gov/tasks', { authorization: 'Bearer user' }); + expect(res.status).toBe(403); + const body = (await res.json()) as Body; + expect(body.code).toBe('forbidden'); + expect(body.message).toContain('运营中心'); + expect(body.message).toContain('工作台'); + expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] }); + }); + it('端够了 → 放行;数组归属任一命中即可', async () => { + expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('账号停用 → 403(重新登录没用)', async () => { + const res = await get('/api/me', { authorization: 'Bearer disabled' }); + expect(res.status).toBe(403); + expect(((await res.json()) as Body).message).toContain('停用'); + }); + it('票据:GET 上认,POST 上不认', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401); + }); + it('过期票据 → 401 指路重取', async () => { + const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000)); + const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('/api/auth/ticket'); + }); + it('Bearer 与票据同时在:Bearer 说了算', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); + }); + it('未知路由:没凭据先 401,有凭据再 404', async () => { + expect((await get('/api/nope')).status).toBe(401); + expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); + }); +}); + +describe('小件', () => { + it('bearerOf 认大小写与空白', () => { + expect(bearerOf('bearer abc')).toBe('abc'); + expect(bearerOf('Bearer abc ')).toBe('abc'); + expect(bearerOf('Basic abc')).toBeUndefined(); + expect(bearerOf(undefined)).toBeUndefined(); + expect(bearerOf('Bearer ')).toBeUndefined(); + }); + it('requiredEnds', () => { + expect(requiredEnds('public')).toBeUndefined(); + expect(requiredEnds('any')).toBeUndefined(); + expect(requiredEnds('ops')).toEqual(['ops']); + expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']); + }); + it('forbiddenBody 对无端可进的人如实说', () => { + expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进'); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/auth-middleware.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 4: 写 `apps/api/src/app/auth-middleware.ts`** + +```ts +/** + * 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。 + * + * ① 查路由注册表拿 access;public 直接放行。 + * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / 的路)。 + * ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。 + * ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。 + * 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。 + */ +import type { MiddlewareHandler } from 'hono'; +import type { ApiError, Principal, RouteAccess, StaffId, Surface } from '@contracts'; +import { SURFACE_LABEL, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import type { AppEnv } from './http-env.ts'; +import type { RouteRegistry } from './route-registry.ts'; +import type { TicketIssuer } from './ticket.ts'; + +/** org 出口里中间件用到的那两口 —— 窄接口,测试好伪造 */ +export type Authenticator = { + authenticate(token: string): Promise; + principalOf(staffId: StaffId): Promise; +}; + +export type AuthMiddlewareDeps = { + readonly registry: RouteRegistry; + readonly org: Authenticator; + readonly tickets: TicketIssuer; + readonly clock: { now(): Date }; +}; + +export function bearerOf(header: string | undefined): string | undefined { + if (header === undefined) return undefined; + const match = /^bearer\s+(\S+)\s*$/i.exec(header.trim()); + return match?.[1]; +} + +/** access 要求的端;public / any 不要求 */ +export function requiredEnds(access: RouteAccess): readonly Surface[] | undefined { + if (access === 'public' || access === 'any') return undefined; + return typeof access === 'string' ? [access] : access; +} + +export function forbiddenBody(need: readonly Surface[], principal: Principal): ApiError { + const needText = need.map((s) => `「${SURFACE_LABEL[s]}」`).join(' / '); + const roles = principal.roles.length === 0 ? '无' : principal.roles.join(', '); + const can = principal.ends.length === 0 ? '无端可进' : principal.ends.map((s) => SURFACE_LABEL[s]).join('、'); + return { + code: 'forbidden', + message: `进入${needText}需要授权;你的角色(${roles})可进:${can}`, + detail: { need: [...need], ends: [...principal.ends] }, + }; +} + +const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated', message }); + +export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler { + return async (c, next) => { + c.set('principal', undefined); + // 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表) + const access = deps.registry.lookup(c.req.method, c.req.path) ?? 'any'; + if (access === 'public') { + await next(); + return undefined; + } + + const bearer = bearerOf(c.req.header('authorization')); + const ticket = c.req.method === 'GET' ? c.req.query('ticket') : undefined; + if (bearer === undefined && (ticket === undefined || ticket === '')) { + return c.json(unauthenticated('缺少 Bearer token'), 401); + } + + let principal: Principal; + try { + if (bearer !== undefined) { + principal = await deps.org.authenticate(bearer); + } else { + const opened = deps.tickets.verify(ticket ?? '', deps.clock.now()); + if (opened === undefined) { + return c.json(unauthenticated('票据无效或已过期 —— 重新取一张 GET /api/auth/ticket'), 401); + } + const found = await deps.org.principalOf(staffIdSchema.parse(opened.staffId)); + if (found === undefined) return c.json(unauthenticated('票据对应的员工不存在'), 401); + principal = found; + } + } catch (error) { + if (error instanceof TokenRejectedError) return c.json(unauthenticated(error.message), 401); + if (error instanceof AccountDisabledError) { + const body: ApiError = { code: 'forbidden', message: error.message }; + return c.json(body, 403); + } + throw error; + } + + const need = requiredEnds(access); + if (need !== undefined && !need.some((s) => principal.ends.includes(s))) { + return c.json(forbiddenBody(need, principal), 403); + } + + c.set('principal', principal); + await next(); + return undefined; + }; +} +``` + +- [ ] **Step 5: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/app/__tests__/auth-middleware.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 14 条 PASS;绿(`@modules/org` 的两个错误类 Task 2 已导出)。 + +- [ ] **Step 6: 提交** + +```bash +git add apps/api/src/app/http-env.ts apps/api/src/app/auth-middleware.ts apps/api/src/app/__tests__/auth-middleware.test.ts +git commit -m "app:鉴权中间件 —— Bearer / 票据 → Principal → 按路由归属判 401/403(ADR-012)" +``` + +--- + +### Task 8: 大切换 —— org 重写 + app 重接线 + 迁移(一次提交) + +> 这一任务是原子的:org 的新出口与 app 的装配互相依赖,拆开中间态 typecheck 必红。步骤 A(org 模块)与 B(app 层)顺着做,**最后一起跑门禁、一起提交**。先写 org-service 的测试(A1),再实现。 + +**Files(A · org 模块):** +- Modify(整文件替换):`apps/api/src/modules/org/contract.ts` `port.ts` `index.ts` `internal/domain/seed-data.ts` `internal/adapter/org-schema.ts` `internal/adapter/org-repository.ts` `internal/usecase/org-service.ts` +- Delete:`internal/adapter/password.ts` `internal/domain/authorization.ts` `internal/domain/disable-check.ts` `__tests__/authorization.test.ts` `__tests__/password.test.ts` +- Create:`db/migrations/0032_keycloak_identity.sql`;Modify `db/migrations/meta/_journal.json` +- Test:`apps/api/src/modules/org/__tests__/org-service.test.ts` + +**Files(B · app 层):** +- Modify(整文件替换):`apps/api/src/app/config.ts` `http-env.ts` `server.ts` `routes/workbench.ts` `routes/governance.ts` +- Modify(定点):`apps/api/src/app/container.ts` `routes/governance-ops.ts` `routes/desk.ts` `routes/desk-elements.ts` `routes/desk-approval.ts` `routes/registration.ts` `wiring/engines.ts` `seed.ts` `apps/api/tsup.config.ts` `apps/api/package.json`;三份 `__tests__/*.it.test.ts` +- Delete:`apps/api/src/app/session-cookie.ts` `set-password.ts` + +**Interfaces:** +- Consumes:Task 1–7 的一切。 +- Produces:`OrgService`(下文);`createContainer(config, overrides?: { tokenVerifier?, roster? })`;`Container.tickets: TicketIssuer`;`AppEnv = { Variables: { principal } }` + `staffOf(c)`;`createServer` 挂中间件与 410 退役路由;`appRouteRegistry()` `EXTRA_ROUTES`(Task 9 的覆盖测试用)。 + +#### A1 · 写失败测试:`apps/api/src/modules/org/__tests__/org-service.test.ts` + +- [ ] **Step A1** + +```ts +/** + * usecase 测试:仓储全是内存实现(模块内部机制不 mock,只是换个存法), + * 验签是注入的假 verifier(Keycloak 是外界)。 + */ +import { describe, expect, it } from 'vitest'; +import type { Position, RoleAccess, WorkGroup } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccessLockoutError, AccountDisabledError, TokenRejectedError } from '../contract.ts'; +import { createOrgService } from '../internal/usecase/org-service.ts'; +import type { OrgServiceDeps } from '../internal/usecase/org-service.ts'; +import type { RosterSource, StaffRecord, VerifiedClaims } from '../port.ts'; + +type Harness = { + org: ReturnType; + tokens: Map; + staff: Map; + roleAccess: Map; + groups: Map; + positions: Map; + calls: { updateIdentity: number }; + advance(ms: number): void; +}; + +function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness { + const positions = new Map(); + const staff = new Map(); + const roleAccess = new Map(); + const groups = new Map(); + const tokens = new Map(); + const calls = { updateIdentity: 0 }; + let nowMs = Date.parse('2026-08-25T09:00:00Z'); + let seq = 0; + + const deps: OrgServiceDeps = { + verifier: { + verify: async (token) => { + const claims = tokens.get(token); + if (claims === undefined) throw new TokenRejectedError('签名校验失败'); + return claims; + }, + }, + ...(opts.roster === undefined ? {} : { roster: opts.roster }), + positions: { + list: async () => [...positions.values()], + findByCode: async (code) => [...positions.values()].find((p) => p.code === code), + findById: async (id) => positions.get(id), + upsert: async (p) => { + const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase()); + const row: Position = { id, code: p.code, name: p.name, ...(p.note === undefined ? {} : { note: p.note }) }; + positions.set(id, row); + return row; + }, + }, + staff: { + list: async () => [...staff.values()], + findById: async (id) => staff.get(id), + listByPosition: async (pid) => [...staff.values()].filter((s) => s.positionId === pid), + insert: async (s, marks) => { + const row: StaffRecord = { ...s, ...marks }; + staff.set(s.id, row); + return row; + }, + updateIdentity: async (id, patch) => { + calls.updateIdentity += 1; + const { email: _dropped, ...rest } = staff.get(id)!; + staff.set(id, { + ...rest, + account: patch.account, + name: patch.name, + roles: [...patch.roles], + ...(patch.email === undefined ? {} : { email: patch.email }), + ...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }), + }); + }, + setPosition: async (id, pid) => { + staff.set(id, { ...staff.get(id)!, positionId: pid }); + }, + setState: async (id, state, syncedAt) => { + staff.set(id, { ...staff.get(id)!, state, syncedAt }); + }, + }, + roleAccess: { + list: async () => [...roleAccess.values()], + upsert: async (row) => { + roleAccess.set(row.role, row); + return row; + }, + remove: async (role) => { + roleAccess.delete(role); + }, + }, + workGroups: { + listByOwner: async (owner) => [...groups.values()].filter((g) => g.ownerId === owner), + upsert: async (g) => { + groups.set(g.id, g); + return g; + }, + }, + inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] }, + clock: { now: () => new Date(nowMs) }, + ids: { next: () => `id-${(seq += 1)}` }, + testAccounts: opts.testAccounts ?? [], + }; + + return { + org: createOrgService(deps), + tokens, + staff, + roleAccess, + groups, + positions, + calls, + advance: (ms) => { + nowMs += ms; + }, + }; +} + +const claims = (over: Partial & { sub: string; preferred_username: string }): VerifiedClaims => ({ + name: over.preferred_username, + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...over, +}); + +describe('authenticate —— 验签 → 名册即时更新 → 角色→端', () => { + it('首次见到:建档在 NONE 岗、active、角色滤了噪声、端按缺省表', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t-alice', claims({ sub: 'sub-alice', preferred_username: 'alice', name: '爱丽丝', email: 'a@x.test' })); + const p = await h.org.authenticate('t-alice'); + expect(p.staff.id).toBe('sub-alice'); + expect(p.staff.account).toBe('alice'); + expect(p.staff.name).toBe('爱丽丝'); + expect(p.staff.email).toBe('a@x.test'); + expect(p.staff.state).toBe('active'); + expect(p.position.code).toBe('NONE'); + expect(p.roles).toEqual(['user']); + expect(p.ends).toEqual(['workbench']); + }); + + it('admin → 三端', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'root', realm_access: { roles: ['admin'] } })); + expect((await h.org.authenticate('t')).ends).toEqual(['design', 'ops', 'workbench']); + }); + + it('坏 token → TokenRejectedError 原样抛出', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await expect(h.org.authenticate('nope')).rejects.toBeInstanceOf(TokenRejectedError); + }); + + it('60 秒内再来且没变 → 不重写;改了名 → 写回并返回新名', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃' })); + await h.org.authenticate('t'); + h.advance(10_000); + await h.org.authenticate('t'); + expect(h.calls.updateIdentity).toBe(0); + h.advance(61_000); + await h.org.authenticate('t'); + expect(h.calls.updateIdentity).toBe(1); // 过了 60s 只为刷 last_seen_at 写一次 + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃·新' })); + const p = await h.org.authenticate('t'); + expect(p.staff.name).toBe('鲍勃·新'); + expect(h.calls.updateIdentity).toBe(2); + }); + + it('名册标了停用 → 即使 token 有效也抛 AccountDisabledError;principalOf 同款', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' })); + await h.org.authenticate('t'); + h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' }); + await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError); + await expect(h.org.principalOf(staffIdSchema.parse('s'))).rejects.toBeInstanceOf(AccountDisabledError); + expect(await h.org.principalOf(staffIdSchema.parse('ghost'))).toBeUndefined(); + }); + + it('测试账号:首次建档即 TEST 岗 + 独立工作组(五岗指向本人)', async () => { + const h = harness({ testAccounts: ['service-account-bpos-e2e'] }); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 'sub-ai', preferred_username: 'service-account-bpos-e2e', realm_access: { roles: ['admin'] } })); + const p = await h.org.authenticate('t'); + expect(p.position.code).toBe('TEST'); + const group = await h.org.workGroupOf(staffIdSchema.parse('sub-ai')); + expect(group?.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); + +describe('名册与岗位', () => { + it('staffOfPosition 只回 active;NONE 恒回 []', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] }); + const b = await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: [] }); + const bd = (await h.org.listStaff()).positions.find((p) => p.code === 'BD')!; + await h.org.setPosition(a.id, bd.id); + await h.org.setPosition(b.id, bd.id); + h.staff.set('b', { ...h.staff.get('b')!, state: 'disabled' }); + expect((await h.org.staffOfPosition('BD')).map((s) => s.id)).toEqual(['a']); + expect(await h.org.staffOfPosition('NONE')).toEqual([]); + }); + + it('syncRoster 未配置 → configured:false', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const outcome = await h.org.syncRoster(); + expect(outcome.configured).toBe(false); + }); + + it('syncRoster:新增 / 改名 / 远端停用 / 本地有远端无 → 停;远端启用 → 复活', async () => { + const remote: RosterSource = { + listUsers: async () => [ + { sub: 'a', account: 'a', name: '阿', roles: ['user'], enabled: true }, + { sub: 'b', account: 'b', name: 'B', roles: ['user'], enabled: false }, + { sub: 'c', account: 'c', name: 'C', roles: ['admin'], enabled: true }, + ], + }; + const h = harness({ roster: remote }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'z', account: 'z', name: 'Z', roles: ['user'] }); + h.staff.set('c', { ...h.staff.get('a')!, id: staffIdSchema.parse('c'), account: 'c', name: 'C', roles: ['admin'], state: 'disabled' }); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3 }); + expect(h.staff.get('a')?.name).toBe('阿'); + expect(h.staff.get('b')?.state).toBe('disabled'); + expect(h.staff.get('z')?.state).toBe('disabled'); + expect(h.staff.get('c')?.state).toBe('active'); + }); + + it('syncRoster 插入的新人也走测试账号策略,且远端停用的直接落 disabled', async () => { + const remote: RosterSource = { + listUsers: async () => [{ sub: 'ai', account: 'ai', name: 'AI', roles: ['admin'], enabled: true }, { sub: 'x', account: 'x', name: 'X', roles: [], enabled: false }], + }; + const h = harness({ roster: remote, testAccounts: ['AI'] }); + await h.org.ensureSeeded(); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2 }); + expect((await h.org.getStaff(staffIdSchema.parse('ai'))).position.code).toBe('TEST'); + expect(h.staff.get('x')?.state).toBe('disabled'); + }); +}); + +describe('角色 → 端', () => { + it('listRoleAccess:knownRoles = 名册上见过的 ∪ 已配置的', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['auditor'] }); + const { roles, knownRoles } = await h.org.listRoleAccess(); + expect(roles.map((r) => r.role).sort()).toEqual(['admin', 'user']); + expect(knownRoles).toEqual(['admin', 'auditor', 'user']); + }); + + it('setRoleAccess 归一端的顺序;唯一 admin 摘 ops → 锁死拒;换到别的在册角色 → 过', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'lead', account: 'lead', name: 'L', roles: ['ops-lead'] }); + expect((await h.org.setRoleAccess('designer', ['workbench', 'design'])).ends).toEqual(['design', 'workbench']); + await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).rejects.toBeInstanceOf(AccessLockoutError); + await h.org.setRoleAccess('ops-lead', ['ops']); + await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).resolves.toBeDefined(); + await expect(h.org.deleteRoleAccess('ops-lead')).rejects.toBeInstanceOf(AccessLockoutError); + }); + + it('staffWithEnd:能进该端的活跃员工', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] }); + expect((await h.org.staffWithEnd('ops')).map((s) => s.id)).toEqual(['root']); + expect((await h.org.staffWithEnd('workbench')).map((s) => s.id).sort()).toEqual(['root', 'u']); + }); +}); + +describe('ensureSeeded —— 幂等,不覆盖人工改动', () => { + it('岗位含 NONE 与 TEST;缺省两行只在表空时播;改过的行不被重播', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const codes = (await h.org.listStaff()).positions.map((p) => p.code).sort(); + expect(codes).toEqual(['BD', 'FAE', 'NONE', 'OP', 'PM', 'PRM', 'TEST']); + expect([...h.roleAccess.keys()].sort()).toEqual(['admin', 'user']); + h.roleAccess.set('user', { role: 'user', ends: [] }); + await h.org.ensureSeeded(); + expect(h.roleAccess.get('user')?.ends).toEqual([]); + expect(h.positions.size).toBe(7); + }); + + it('播种时给已在册的测试账号补策略', async () => { + const h = harness({ testAccounts: ['chaihao'] }); + await h.org.ensureSeeded(); + const none = (await h.org.listStaff()).positions.find((p) => p.code === 'NONE')!; + h.staff.set('ch', { id: staffIdSchema.parse('ch'), account: 'Chaihao', name: '柴浩', positionId: none.id, state: 'active', roles: [] }); + await h.org.ensureSeeded(); + expect((await h.org.getStaff(staffIdSchema.parse('ch'))).position.code).toBe('TEST'); + expect((await h.org.workGroupOf(staffIdSchema.parse('ch')))?.id).toBe('wg-solo-chaihao'); + }); +}); + +describe('工作组(ADR-011 原样)', () => { + it('建组 / 取组 / 绑定快照', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] }); + const g = await h.org.saveWorkGroup({ name: 'A 组', ownerId: a.id, members: { BD: a.id } }); + expect((await h.org.workGroupOf(a.id))?.id).toBe(g.id); + expect(await h.org.bindingOf(a.id)).toEqual({ BD: 'a' }); + }); +}); +``` + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/org-service.test.ts` +Expected: FAIL(`createOrgService` 的 deps 形状还是旧的)。 + +#### A2 · `apps/api/src/modules/org/contract.ts`(整文件) + +- [ ] **Step A2** + +```ts +/** + * module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。 + * + * 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。 + * 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。 + * 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。 + */ +import type { + InFlightWork, + Position, + PositionId, + Principal, + RoleAccess, + RosterSyncOutcome, + Staff, + StaffId, + Surface, + WorkGroup, + WorkGroupBinding, +} from '@contracts'; + +export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; + +/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ +export type StaffWithPosition = Staff & { readonly position: Position }; + +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ + readonly account: string; + /** name claim,缺则回退 account */ + readonly name: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; +}; + +export class StaffNotFoundError extends Error { + constructor(readonly staffId: StaffId) { + super(`员工不存在:${staffId}`); + this.name = 'StaffNotFoundError'; + } +} + +export class PositionNotFoundError extends Error { + constructor(readonly positionCode: string) { + super(`岗位不存在:${positionCode}`); + this.name = 'PositionNotFoundError'; + } +} + +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export class TokenRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenRejectedError'; + } +} + +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export class AccountDisabledError extends Error { + constructor(readonly account: string) { + super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`); + this.name = 'AccountDisabledError'; + } +} + +/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */ +export class AccessLockoutError extends Error { + constructor() { + super('改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝'); + this.name = 'AccessLockoutError'; + } +} + +export type OrgService = { + /* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */ + + /** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */ + authenticate(token: string): Promise; + /** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */ + principalOf(staffId: StaffId): Promise; + /** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */ + registerIdentity(identity: Identity): Promise; + + /* ── 名册与岗位 ───────────────────────────────────────────────────── */ + + listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>; + getStaff(staffId: StaffId): Promise; + /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */ + staffOfPosition(positionCode: string): Promise; + setPosition(staffId: StaffId, positionId: PositionId): Promise; + /** Admin API 同步;未配 provisioning 回 configured:false */ + syncRoster(): Promise; + /** 此人身上还挂着什么(只读;OS 02 改派先于停用) */ + inFlightOf(staffId: StaffId): Promise; + + /* ── 角色 → 端 ───────────────────────────────────────────────────── */ + + listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>; + /** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */ + setRoleAccess(role: string, ends: readonly Surface[]): Promise; + deleteRoleAccess(role: string): Promise; + /** 能进该端的活跃员工 —— 治理台通知的收件人 */ + staffWithEnd(surface: Surface): Promise; + + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ + + /** 建组 / 改组;id 留空即新建 */ + saveWorkGroup(input: { + id?: string; + name: string; + ownerId: StaffId; + members: Readonly>; + }): Promise; + /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ + workGroupOf(staffId: StaffId): Promise; + /** 装进工作流的快照:改组不改已起的单 */ + bindingOf(staffId: StaffId): Promise; + + /** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */ + ensureSeeded(): Promise; +}; +``` + +#### A3 · `apps/api/src/modules/org/port.ts`(整文件) + +- [ ] **Step A3** + +```ts +/** + * module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + * adapter 实现之,usecase 依赖之,app 装配之。 + */ +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Identity } from './contract.ts'; + +export type Clock = { now(): Date }; + +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { readonly roles?: readonly string[] }; +}; + +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { verify(token: string): Promise }; + +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { readonly enabled: boolean }; +export type RosterSource = { listUsers(): Promise }; + +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; + +/* ── 仓储 ─────────────────────────────────────────────────────────────── */ + +export type PositionRepository = { + list(): Promise; + findByCode(code: string): Promise; + findById(id: PositionId): Promise; + upsert(position: Omit & { id?: PositionId }): Promise; +}; + +/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ +export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; + +export type StaffRepository = { + list(): Promise; + findById(id: StaffId): Promise; + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; + updateIdentity(id: StaffId, patch: IdentityPatch): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; + +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; +}; + +/** + * 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`, + * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 + */ +export type InFlightWorkProbe = { + openTasksOf(staffId: StaffId): Promise>; + openCardsOf(staffId: StaffId): Promise>; + openApprovalsOf(staffId: StaffId): Promise>; +}; + +/** 工作组仓储(ADR-011) */ +export type WorkGroupRepository = { + listByOwner(ownerId: StaffId): Promise; + upsert(group: WorkGroup): Promise; +}; + +/** ID 生成 —— domain 零随机(§5.3) */ +export type IdGenerator = { next(): string }; +``` + +#### A4 · `apps/api/src/modules/org/internal/domain/seed-data.ts`(整文件) + +- [ ] **Step A4** + +```ts +/** + * 种子数据 —— 纯常量,零 IO。 + * 人不再播种(名册来自鉴权中心,ADR-012);这里只有岗位与缺省的角色→端。 + * 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。 + */ +import type { RoleAccess } from '@contracts'; + +export const NONE_POSITION_CODE = 'NONE'; +export const TEST_POSITION_CODE = 'TEST'; + +export const POSITION_SEED = [ + { code: 'PM', name: '项目经理', note: '定价审批、策略拍板' }, + { code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' }, + { code: 'FAE', name: '现场应用', note: '替代选型' }, + { code: 'PRM', name: '采购/寻源', note: '渠道寻源' }, + { code: 'OP', name: '运营', note: '运营支持' }, + /* + * 测试岗(ADR-011):测试账号单独占一个岗位,**不混进五个业务岗**。 + * 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 —— + * 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。 + */ + { code: TEST_POSITION_CODE, name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' }, + /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */ + { code: NONE_POSITION_CODE, name: '未分配', note: '未指派岗位;流程包不得声明它' }, +] as const; + +export type PositionSeed = (typeof POSITION_SEED)[number]; + +/** + * 缺省的角色 → 端(表为空时播,之后由运营中心改): + * 与手册两个前端的口径一致 —— 用户端要 `user`、管理端要 `admin`。 + */ +export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; + +/** 独立测试组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */ +export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const; +``` + +#### A5 · `apps/api/src/modules/org/internal/adapter/org-schema.ts`(整文件) + +- [ ] **Step A5** + +```ts +/** + * org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。 + * drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。 + * 会话表与岗位授权表已随 ADR-012 退役(迁移 0032 drop)。 + */ +import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const positionTable = sys.table( + 'position', + { + id: text('id').primaryKey(), + code: text('code').notNull(), + name: text('name').notNull(), + note: text('note'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [uniqueIndex('position_code_key').on(t.code)], +); + +/** 员工 —— `id` 就是 Keycloak 的 sub(ADR-012);`account` 只作显示 */ +export const staffTable = sys.table( + 'staff', + { + id: text('id').primaryKey(), + account: text('account').notNull(), + name: text('name').notNull(), + email: text('email'), + positionId: text('position_id') + .notNull() + .references(() => positionTable.id), + /** 只由名册同步置 disabled;有效 token 不改它 */ + state: text('state').notNull().default('active'), + /** realm 角色快照(签发时;噪声角色已滤) */ + roles: jsonb('roles').$type().notNull().default([]), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + syncedAt: timestamp('synced_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)], +); + +/** 角色 → 端(ADR-012):一行一个 realm 角色,三列布尔 —— 运营中心可改 */ +export const roleAccessTable = sys.table('role_access', { + role: text('role').primaryKey(), + design: boolean('design').notNull().default(false), + ops: boolean('ops').notNull().default(false), + workbench: boolean('workbench').notNull().default(false), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。 + * 起单时按来源人取其组、快照进工作流;这里存的是**当前**成员表,可改。 + */ +export const workGroupTable = sys.table( + 'work_group', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + ownerId: text('owner_id') + .notNull() + .references(() => staffTable.id, { onDelete: 'cascade' }), + members: jsonb('members').notNull().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('work_group_owner_idx').on(t.ownerId)], +); +``` + +#### A6 · `apps/api/src/modules/org/internal/adapter/org-repository.ts`(整文件) + +- [ ] **Step A6** + +```ts +/** + * org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。 + */ +import { eq } from 'drizzle-orm'; +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import { SURFACES, positionIdSchema, positionSchema, staffSchema, workGroupSchema } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { + PositionRepository, + RoleAccessRepository, + StaffRecord, + StaffRepository, + WorkGroupRepository, +} from '../../port.ts'; +import { positionTable, roleAccessTable, staffTable, workGroupTable } from './org-schema.ts'; + +export function createPositionRepository(db: Db): PositionRepository { + const toPosition = (row: typeof positionTable.$inferSelect): Position => + positionSchema.parse({ + id: row.id, + code: row.code, + name: row.name, + ...(row.note === null ? {} : { note: row.note }), + }); + + return { + async list() { + const rows = await db.select().from(positionTable).orderBy(positionTable.code); + return rows.map(toPosition); + }, + async findByCode(code) { + const rows = await db.select().from(positionTable).where(eq(positionTable.code, code)).limit(1); + return rows[0] === undefined ? undefined : toPosition(rows[0]); + }, + async findById(id) { + const rows = await db.select().from(positionTable).where(eq(positionTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toPosition(rows[0]); + }, + async upsert(position) { + const id = position.id ?? positionIdSchema.parse(position.code.toLowerCase()); + const [row] = await db + .insert(positionTable) + .values({ id, code: position.code, name: position.name, note: position.note ?? null }) + .onConflictDoUpdate({ + target: positionTable.code, + set: { name: position.name, note: position.note ?? null }, + }) + .returning(); + if (row === undefined) throw new Error('岗位写入未返回行'); + return toPosition(row); + }, + }; +} + +export function createStaffRepository(db: Db): StaffRepository { + const toRecord = (row: typeof staffTable.$inferSelect): StaffRecord => ({ + ...staffSchema.parse({ + id: row.id, + account: row.account, + name: row.name, + ...(row.email === null ? {} : { email: row.email }), + positionId: row.positionId, + state: row.state, + roles: row.roles, + }), + ...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }), + ...(row.syncedAt === null ? {} : { syncedAt: row.syncedAt }), + }); + + return { + async list() { + const rows = await db.select().from(staffTable).orderBy(staffTable.account); + return rows.map(toRecord); + }, + async findById(id: StaffId) { + const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toRecord(rows[0]); + }, + async listByPosition(positionId: PositionId) { + const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId)); + return rows.map(toRecord); + }, + async insert(staff: Staff, marks) { + const [row] = await db + .insert(staffTable) + .values({ + id: staff.id, + account: staff.account, + name: staff.name, + email: staff.email ?? null, + positionId: staff.positionId, + state: staff.state, + roles: [...staff.roles], + lastSeenAt: marks.lastSeenAt ?? null, + syncedAt: marks.syncedAt ?? null, + }) + .returning(); + if (row === undefined) throw new Error('员工写入未返回行'); + return toRecord(row); + }, + async updateIdentity(id: StaffId, patch) { + await db + .update(staffTable) + .set({ + account: patch.account, + name: patch.name, + email: patch.email ?? null, + roles: [...patch.roles], + ...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }), + }) + .where(eq(staffTable.id, id)); + }, + async setPosition(id: StaffId, positionId: PositionId) { + await db.update(staffTable).set({ positionId }).where(eq(staffTable.id, id)); + }, + async setState(id: StaffId, state, syncedAt: Date) { + await db.update(staffTable).set({ state, syncedAt }).where(eq(staffTable.id, id)); + }, + }; +} + +/** 角色 → 端:契约里是端名数组,表里是三列布尔 —— 这里来回翻译 */ +export function createRoleAccessRepository(db: Db): RoleAccessRepository { + const toRow = (r: typeof roleAccessTable.$inferSelect): RoleAccess => ({ + role: r.role, + ends: SURFACES.filter((s) => r[s]), + }); + + return { + async list() { + const rows = await db.select().from(roleAccessTable).orderBy(roleAccessTable.role); + return rows.map(toRow); + }, + async upsert(row: RoleAccess) { + const flags = { + design: row.ends.includes('design'), + ops: row.ends.includes('ops'), + workbench: row.ends.includes('workbench'), + }; + const [saved] = await db + .insert(roleAccessTable) + .values({ role: row.role, ...flags }) + .onConflictDoUpdate({ target: roleAccessTable.role, set: { ...flags, updatedAt: new Date() } }) + .returning(); + if (saved === undefined) throw new Error('角色→端写入未返回行'); + return toRow(saved); + }, + async remove(role: string) { + await db.delete(roleAccessTable).where(eq(roleAccessTable.role, role)); + }, + }; +} + +/** 工作组仓储(ADR-011)—— 成员表整块存 jsonb,键是岗位 code */ +export function createWorkGroupRepository(db: Db): WorkGroupRepository { + const toGroup = (row: typeof workGroupTable.$inferSelect): WorkGroup => + workGroupSchema.parse({ id: row.id, name: row.name, ownerId: row.ownerId, members: row.members }); + + return { + async listByOwner(ownerId: StaffId) { + const rows = await db.select().from(workGroupTable).where(eq(workGroupTable.ownerId, ownerId)); + return rows.map(toGroup); + }, + async upsert(group: WorkGroup) { + const [row] = await db + .insert(workGroupTable) + .values({ id: group.id, name: group.name, ownerId: group.ownerId, members: group.members }) + .onConflictDoUpdate({ + target: workGroupTable.id, + set: { name: group.name, ownerId: group.ownerId, members: group.members }, + }) + .returning(); + if (row === undefined) throw new Error('工作组写入未返回行'); + return toGroup(row); + }, + }; +} +``` + +#### A7 · `apps/api/src/modules/org/internal/usecase/org-service.ts`(整文件) + +- [ ] **Step A7** + +```ts +/** + * org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。 + * + * 身份归统一鉴权中心(ADR-012): + * authenticate = 验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × 角色→端表 → Principal。 + * `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 —— + * 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。 + */ +import type { Position, Principal, RoleAccess, Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts'; +import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts'; +import type { + Clock, + IdGenerator, + InFlightWorkProbe, + PositionRepository, + RoleAccessRepository, + RosterSource, + StaffRecord, + StaffRepository, + TokenVerifier, + WorkGroupRepository, +} from '../../port.ts'; +import { identityOf } from '../domain/claims.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts'; +import { buildInFlightWork } from '../domain/in-flight.ts'; +import { diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts'; +import { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from '../domain/seed-data.ts'; +import { isTestAccount, soloGroupOf } from '../domain/test-account.ts'; + +export type OrgServiceDeps = { + readonly verifier: TokenVerifier; + /** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */ + readonly roster?: RosterSource; + readonly positions: PositionRepository; + readonly staff: StaffRepository; + readonly roleAccess: RoleAccessRepository; + readonly workGroups: WorkGroupRepository; + readonly inFlight: InFlightWorkProbe; + readonly clock: Clock; + readonly ids: IdGenerator; + /** 测试账号(Keycloak 用户名):首次建档与播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; +}; + +/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */ +const LAST_SEEN_REWRITE_MS = 60_000; + +/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */ +function toStaff(record: StaffRecord): Staff { + const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record; + return staff; +} + +export function createOrgService(deps: OrgServiceDeps): OrgService { + async function positionMap(): Promise> { + const all = await deps.positions.list(); + return new Map(all.map((p) => [p.id, p])); + } + + async function withPosition(staff: Staff, positions?: Map): Promise { + const map = positions ?? (await positionMap()); + const position = map.get(staff.positionId); + if (!position) throw new PositionNotFoundError(staff.positionId); + return { ...staff, position }; + } + + async function positionByCode(code: string): Promise { + const position = await deps.positions.findByCode(code); + if (!position) throw new PositionNotFoundError(code); + return position; + } + + async function principalFor(record: StaffRecord): Promise { + if (record.state !== 'active') throw new AccountDisabledError(record.account); + const staff = toStaff(record); + const position = await deps.positions.findById(staff.positionId); + if (!position) throw new PositionNotFoundError(staff.positionId); + const table = await deps.roleAccess.list(); + return { staff, position, roles: [...staff.roles], ends: endsOf(staff.roles, table) }; + } + + /** 测试账号策略(ADR-011):TEST 岗 + 独立工作组;不是测试账号就什么都不做 */ + async function applyTestAccountPolicy(staff: Staff): Promise { + if (!isTestAccount(staff.account, deps.testAccounts)) return; + const none = await positionByCode(NONE_POSITION_CODE); + if (staff.positionId === none.id) { + const test = await positionByCode(TEST_POSITION_CODE); + await deps.staff.setPosition(staff.id, test.id); + } + if ((await deps.workGroups.listByOwner(staff.id)).length === 0) { + await deps.workGroups.upsert(soloGroupOf(staff)); + } + } + + async function activeStaffRoles(): Promise { + return (await deps.staff.list()).filter((s) => s.state === 'active').map((s) => [...s.roles]); + } + + async function insertPerson( + identity: Identity, + state: Staff['state'], + marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }, + ): Promise { + const none = await positionByCode(NONE_POSITION_CODE); + const created = await deps.staff.insert( + { + id: staffIdSchema.parse(identity.sub), + account: identity.account, + name: identity.name, + ...(identity.email === undefined ? {} : { email: identity.email }), + positionId: none.id, + state, + roles: [...identity.roles], + }, + marks, + ); + await applyTestAccountPolicy(toStaff(created)); + const fresh = await deps.staff.findById(created.id); + return toStaff(fresh ?? created); + } + + const service: OrgService = { + async authenticate(token) { + const claims = await deps.verifier.verify(token); + await service.registerIdentity(identityOf(claims)); + const id = staffIdSchema.parse(claims.sub); + const record = await deps.staff.findById(id); + if (record === undefined) throw new StaffNotFoundError(id); + return principalFor(record); + }, + + async principalOf(staffId) { + const record = await deps.staff.findById(staffId); + return record === undefined ? undefined : principalFor(record); + }, + + async registerIdentity(identity) { + const now = deps.clock.now(); + const id = staffIdSchema.parse(identity.sub); + const existing = await deps.staff.findById(id); + if (existing === undefined) return insertPerson(identity, 'active', { lastSeenAt: now }); + + const changed = identityChanged(existing, identity); + const stale = + existing.lastSeenAt === undefined || now.getTime() - existing.lastSeenAt.getTime() >= LAST_SEEN_REWRITE_MS; + if (!changed && !stale) return toStaff(existing); + await deps.staff.updateIdentity(id, { ...patchOf(identity), lastSeenAt: now }); + const fresh = await deps.staff.findById(id); + return toStaff(fresh ?? existing); + }, + + async listStaff() { + const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]); + const map = new Map(positions.map((p) => [p.id, p])); + const staff = await Promise.all(rows.map((s) => withPosition(toStaff(s), map))); + return { staff, positions }; + }, + + async getStaff(staffId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + return withPosition(toStaff(record)); + }, + + async staffOfPosition(positionCode) { + if (positionCode === NONE_POSITION_CODE) return []; + const position = await positionByCode(positionCode); + const rows = await deps.staff.listByPosition(position.id); + return rows.filter((s) => s.state === 'active').map((s) => ({ ...toStaff(s), position })); + }, + + async setPosition(staffId, positionId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + const position = await deps.positions.findById(positionId); + if (!position) throw new PositionNotFoundError(positionId); + await deps.staff.setPosition(staffId, positionId); + return { ...toStaff(record), positionId, position }; + }, + + async syncRoster(): Promise { + if (deps.roster === undefined) { + return { + configured: false, + message: '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档', + }; + } + const remote = await deps.roster.listUsers(); + const local = await deps.staff.list(); + const diff = diffRoster(remote, local); + const now = deps.clock.now(); + for (const user of diff.insert) await insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now }); + for (const u of diff.update) await deps.staff.updateIdentity(u.id, u.patch); + for (const s of diff.setState) await deps.staff.setState(s.id, s.state, now); + return { + configured: true, + inserted: diff.insert.length, + updated: diff.update.length, + disabled: diff.setState.filter((s) => s.state === 'disabled').length, + total: remote.length, + }; + }, + + async inFlightOf(staffId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + const [openTasks, openCards, openApprovals] = await Promise.all([ + deps.inFlight.openTasksOf(staffId), + deps.inFlight.openCardsOf(staffId), + deps.inFlight.openApprovalsOf(staffId), + ]); + return buildInFlightWork(staffId, { openTasks, openCards, openApprovals }); + }, + + async listRoleAccess() { + const rows = await deps.roleAccess.list(); + const seen = new Set(rows.map((r) => r.role)); + for (const s of await deps.staff.list()) for (const r of s.roles) seen.add(r); + return { roles: rows, knownRoles: [...seen].sort() }; + }, + + async setRoleAccess(role, ends) { + const row: RoleAccess = { role, ends: normalizeEnds(ends) }; + const current = await deps.roleAccess.list(); + assertNotLockedOut([...current.filter((r) => r.role !== role), row], await activeStaffRoles()); + return deps.roleAccess.upsert(row); + }, + + async deleteRoleAccess(role) { + const current = await deps.roleAccess.list(); + assertNotLockedOut( + current.filter((r) => r.role !== role), + await activeStaffRoles(), + ); + await deps.roleAccess.remove(role); + }, + + async staffWithEnd(surface) { + const table = await deps.roleAccess.list(); + const { staff } = await service.listStaff(); + return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface)); + }, + + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */ + + async saveWorkGroup(input) { + const record = await deps.staff.findById(input.ownerId); + if (!record) throw new StaffNotFoundError(input.ownerId); + return deps.workGroups.upsert({ + id: input.id ?? deps.ids.next(), + name: input.name, + ownerId: input.ownerId, + members: { ...input.members }, + }); + }, + + async workGroupOf(staffId) { + // 一人一组:多组时取稳定的第一条(选组这一步 MVP 不做,见 debt) + const groups = await deps.workGroups.listByOwner(staffId); + return [...groups].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]; + }, + + async bindingOf(staffId) { + const group = await service.workGroupOf(staffId); + return group === undefined ? undefined : { ...group.members }; + }, + + async ensureSeeded() { + for (const seed of POSITION_SEED) { + const existing = await deps.positions.findByCode(seed.code); + if (existing !== undefined) continue; + await deps.positions.upsert({ + id: positionIdSchema.parse(deps.ids.next()), + code: seed.code, + name: seed.name, + note: seed.note, + }); + } + // 缺省的角色→端只在表空时播 —— 之后是运营中心的事,播种不覆盖人工改动 + if ((await deps.roleAccess.list()).length === 0) { + for (const row of DEFAULT_ROLE_ACCESS) await deps.roleAccess.upsert(row); + } + // 已在册的测试账号补策略(名册先同步后配 JCAI_TEST_ACCOUNTS 的那种顺序) + for (const record of await deps.staff.list()) { + if (isTestAccount(record.account, deps.testAccounts)) await applyTestAccountPolicy(toStaff(record)); + } + }, + }; + + return service; +} +``` + +四个类型 import 都在文件里用到(`Position` 岗位表、`Principal` 返回值、`RoleAccess` 行、`Staff` 剥时刻);再多一个 lint 就会报未用。 + +#### A8 · `apps/api/src/modules/org/index.ts`(整文件) + +- [ ] **Step A8** + +```ts +/** + * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + Identity, + InFlightWork, + OrgService, + Position, + Principal, + RoleAccess, + RosterSyncOutcome, + Staff, + StaffWithPosition, + Surface, + WorkGroup, + WorkGroupBinding, +} from './contract.ts'; +export { + AccessLockoutError, + AccountDisabledError, + PositionNotFoundError, + StaffNotFoundError, + TokenRejectedError, +} from './contract.ts'; + +export type { + Clock, + IdGenerator, + IdentityPatch, + InFlightWorkProbe, + PositionRepository, + RoleAccessRepository, + RosterSource, + RosterUser, + StaffRecord, + StaffRepository, + TokenVerifier, + VerifiedClaims, + WorkGroupRepository, +} from './port.ts'; + +export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; +export { createOrgService } from './internal/usecase/org-service.ts'; + +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; + +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { + createPositionRepository, + createRoleAccessRepository, + createStaffRepository, + createWorkGroupRepository, +} from './internal/adapter/org-repository.ts'; +export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; +``` + +#### A9 · 删旧文件、写迁移 + +- [ ] **Step A9** + +```bash +git rm apps/api/src/modules/org/internal/adapter/password.ts \ + apps/api/src/modules/org/internal/domain/authorization.ts \ + apps/api/src/modules/org/internal/domain/disable-check.ts \ + apps/api/src/modules/org/__tests__/authorization.test.ts \ + apps/api/src/modules/org/__tests__/password.test.ts +``` + +`db/migrations/0032_keycloak_identity.sql`: + +```sql +-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak 的 sub。 +-- +-- 从前(ADR-010)是自建账号口令 + 服务端会话表 + 岗位授权(客体三类 × 动作三种)。 +-- 现在:后端只验 Bearer JWT;权限只到「端」(design / ops / workbench), +-- 角色 → 端是运营中心可改的一张表;名册从 Keycloak 同步、岗位本地指派。 +drop table if exists sys.session; +drop table if exists sys.position_grant; + +alter table sys.staff + drop column if exists password_hash, + add column if not exists email text, + add column if not exists roles jsonb not null default '[]'::jsonb, + add column if not exists last_seen_at timestamptz, + add column if not exists synced_at timestamptz; + +create table if not exists sys.role_access ( + role text primary key, + design boolean not null default false, + ops boolean not null default false, + workbench boolean not null default false, + updated_at timestamptz not null default now() +); + +-- 种子人不是 Keycloak 的人:id 不是 sub,账号还会与 Keycloak 用户名撞唯一索引 —— 清掉。 +-- 他们名下的运行数据由 `reset-runs` 清(迁移不碰运行表)。 +delete from sys.work_group; +delete from sys.staff; +``` + +登记进 `db/migrations/meta/_journal.json`: + +```bash +python3 - <<'EOF' +import json +p = 'db/migrations/meta/_journal.json' +j = json.load(open(p, encoding='utf-8')) +assert j['entries'][-1]['idx'] == 31, j['entries'][-1] +j['entries'].append({"idx": 32, "version": "7", "when": 1785130120180, "tag": "0032_keycloak_identity", "breakpoints": True}) +with open(p, 'w', encoding='utf-8') as f: + json.dump(j, f, indent=2, ensure_ascii=False) + f.write('\n') +EOF +``` + +- [ ] **Step A10: 跑 org 的单元测试(此时 app 层还没接线,typecheck 会红 —— 先只跑测试)** + +Run: `pnpm vitest run apps/api/src/modules/org` +Expected: `org-service.test.ts` 16 条 + 之前六个文件全 PASS。若 `org-service.test.ts` 里 `syncRoster` 那条的 `updated`/`disabled` 计数对不上,先核 `diffRoster` 的输入(本地那条 `c` 是手工塞进 map 的、state 为 disabled;远端 `c` enabled → 复活计入 setState 但不计入 disabled)。 + +#### B1 · `apps/api/src/app/config.ts`(整文件) + +- [ ] **Step B1** + +```ts +/** + * 环境配置 —— 只在 app/ 层读环境变量;模块内一律经 port 注入(§5.3)。 + */ +import { join } from 'node:path'; + +/** 统一鉴权中心(ADR-012)。本服务只验不调,**不配 client secret**(手册 13.2) */ +export type KeycloakConfig = { + readonly url: string; + readonly realm: string; + /** 校 aud 用 —— 填错全线 401 */ + readonly clientId: string; + readonly issuer: string; + readonly jwksUrl: string; + /** master realm 的 provisioning 服务账号(只读名册,view-users);可选 */ + readonly provisioning?: { readonly clientId: string; readonly secret: string }; +}; + +export type AppConfig = { + readonly nodeEnv: 'development' | 'test' | 'production'; + readonly port: number; + readonly databaseUrl: string; + /** 原件存档根目录 —— 信封的原件指针指向这里 */ + readonly attachmentRoot: string; + /** 派发器轮询间隔(毫秒) */ + readonly dispatchIntervalMs: number; + /** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */ + readonly keycloak?: KeycloakConfig; + /** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; +}; + +const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos'; + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +function keycloakOf(env: NodeJS.ProcessEnv): KeycloakConfig | undefined { + const url = nonEmpty(env['KEYCLOAK_URL'])?.replace(/\/+$/, ''); + const realm = nonEmpty(env['KEYCLOAK_REALM']); + const clientId = nonEmpty(env['KEYCLOAK_CLIENT_ID']); + if (url === undefined || realm === undefined || clientId === undefined) return undefined; + const issuer = `${url}/realms/${realm}`; + const provisioningId = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_ID']); + const provisioningSecret = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_SECRET']); + return { + url, + realm, + clientId, + issuer, + jwksUrl: `${issuer}/protocol/openid-connect/certs`, + ...(provisioningId === undefined || provisioningSecret === undefined + ? {} + : { provisioning: { clientId: provisioningId, secret: provisioningSecret } }), + }; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + const nodeEnv = env['NODE_ENV']; + const keycloak = keycloakOf(env); + return { + nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development', + port: Number(env['PORT'] ?? 8787), + databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, + attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), + dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), + ...(keycloak === undefined ? {} : { keycloak }), + testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), + }; +} +``` + +#### B2 · `apps/api/src/app/http-env.ts`(整文件) + +- [ ] **Step B2** + +```ts +/** + * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 + */ +import type { Context } from 'hono'; +import type { Position, Principal, Staff } from '@contracts'; + +/** + * 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 —— + * 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。 + */ +export type AppEnv = { Variables: { principal: Principal | undefined } }; + +/** + * 当前登录者(连同岗位)。非公开路由上中间件已保证存在;公开路由上是 undefined —— + * 调用方照旧判一下 undefined,免得哪天有人把一条路由改成 public 还在读它。 + */ +export function staffOf(c: Context): (Staff & { readonly position: Position }) | undefined { + const principal = c.get('principal'); + return principal === undefined ? undefined : { ...principal.staff, position: principal.position }; +} +``` + +#### B3 · `apps/api/src/app/container.ts`(定点改) + +- [ ] **Step B3** + +(1)把 org 的 import 段 + +```ts +import type { OrgService, InFlightWorkProbe } from '@modules/org'; +import { + createGrantRepository, + createOrgService, + createPositionRepository, + createSessionRepository, + createStaffRepository, + createWorkGroupRepository, + hashPassword, + newSessionToken, + verifyPassword, +} from '@modules/org'; +``` + +换成 + +```ts +import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org'; +import { + createKeycloakRoster, + createKeycloakTokenVerifier, + createOrgService, + createPositionRepository, + createRoleAccessRepository, + createStaffRepository, + createWorkGroupRepository, + TokenRejectedError, +} from '@modules/org'; +import { createRemoteJWKSet } from 'jose'; +import { createTicketIssuer } from './ticket.ts'; +import type { TicketIssuer } from './ticket.ts'; +``` + +(2)`Container` 类型里 `readonly org: OrgService;` 之后加一行: + +```ts + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + readonly tickets: TicketIssuer; +``` + +(3)`export async function createContainer(config: AppConfig): Promise {` 之前加类型、并改签名: + +```ts +/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */ +export type ContainerOverrides = { + readonly tokenVerifier?: TokenVerifier; + readonly roster?: RosterSource; +}; + +export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise { +``` + +(4)`const secrets = createSecretBox(loadSecretKey());` 换成 + +```ts + const masterKey = loadSecretKey(); + const secrets = createSecretBox(masterKey); + const tickets = createTicketIssuer(masterKey); +``` + +(5)把 `const org = createOrgService({ … seedPassword: config.seedPassword, ids, });` 整块换成 + +```ts + /* + * 身份归统一鉴权中心(ADR-012):验签走 jose 远程 JWKS(首次用到才拉、缓存 10 分钟、 + * 30 秒冷却、5 秒超时 —— 手册 4.1);没配 KEYCLOAK_* 就装一个恒拒的 verifier: + * 集成测试走用例不走 HTTP,不受影响;真跑起来缺配置的话 main.ts 早就拒绝启动了。 + */ + const keycloak = config.keycloak; + const verifier: TokenVerifier = + overrides.tokenVerifier ?? + (keycloak === undefined + ? { + verify: async () => { + throw new TokenRejectedError('未配置 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID'); + }, + } + : createKeycloakTokenVerifier({ + issuer: keycloak.issuer, + audience: keycloak.clientId, + getKey: createRemoteJWKSet(new URL(keycloak.jwksUrl), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, + }), + })); + const roster: RosterSource | undefined = + overrides.roster ?? + (keycloak?.provisioning === undefined + ? undefined + : createKeycloakRoster({ + url: keycloak.url, + realm: keycloak.realm, + clientId: keycloak.provisioning.clientId, + secret: keycloak.provisioning.secret, + })); + + const org = createOrgService({ + verifier, + ...(roster === undefined ? {} : { roster }), + positions: createPositionRepository(db), + staff: createStaffRepository(db), + roleAccess: createRoleAccessRepository(db), + workGroups: createWorkGroupRepository(db), + inFlight: inFlightProbe, + clock: systemClock, + ids, + testAccounts: config.testAccounts, + }); +``` + +(6)message 装配里的 `governanceStaff` 整块 + +```ts + governanceStaff: async () => { + const { staff } = await org.listStaff(); + const governing = await Promise.all( + staff.map(async (s) => ({ + s, + ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'), + })), + ); + return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id })); + }, +``` + +换成 + +```ts + // 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端) + governanceStaff: async () => (await org.staffWithEnd('ops')).map((s) => ({ id: s.id })), +``` + +(7)`return { config, broadcaster, pool, db, org,` 里 `org,` 之后加 `tickets,`。 + +#### B4 · `apps/api/src/app/server.ts`(整文件) + +- [ ] **Step B4** + +```ts +/** + * Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。 + * 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。 + */ +import { Hono } from 'hono'; +import * as contracts from '@contracts'; +import type { ApiError, RouteContract } from '@contracts'; +import { + createStaffRoute, + disableStaffCheckRoute, + disableStaffRoute, + listGrantsRoute, + loginRoute, + logoutRoute, + sessionRoute, + setGrantsRoute, +} from '@contracts'; +import type { Container } from './container.ts'; +import type { AppEnv } from './http-env.ts'; +import { createAuthMiddleware } from './auth-middleware.ts'; +import { buildRouteRegistry, routesOf } from './route-registry.ts'; +import type { RegisteredRoute, RouteRegistry } from './route-registry.ts'; +import { systemClock } from './runtime.ts'; +import { registerDeskRoutes } from './routes/desk.ts'; +import { registerDeskElementRoutes } from './routes/desk-elements.ts'; +import { registerDeskApprovalRoutes } from './routes/desk-approval.ts'; +import { registerRegistrationRoutes } from './routes/registration.ts'; +import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; +import { registerGovernanceRoutes } from './routes/governance.ts'; +import { registerIngestRoutes } from './routes/ingest.ts'; +import { registerStreamRoute } from './routes/stream.ts'; +import { registerWorkbenchRoutes } from './routes/workbench.ts'; + +export type { AppEnv }; + +/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */ +export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }]; + +/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */ +export function appRouteRegistry(): RouteRegistry { + return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]); +} + +/** + * 随统一鉴权中心退役的路由(§12.4 三步之第二步):契约留一轮给 web 编译,后端一律 410 并指路。 + * 它们的 access 是 public —— 410 不需要凭据,也不泄漏任何东西。 + */ +const RETIRED_ROUTES: ReadonlyArray<{ readonly route: RouteContract; readonly hint: string }> = [ + { route: loginRoute, hint: '登录由 Keycloak 完成,请求带 Authorization: Bearer ' }, + { route: logoutRoute, hint: '登出在前端丢弃 token,或调 Keycloak 的 end-session' }, + { route: sessionRoute, hint: '改用 GET /api/me' }, + { route: createStaffRoute, hint: '人来自鉴权中心 —— 首次登录自动建档,或 POST /api/gov/staff/sync 同步名册' }, + { route: disableStaffCheckRoute, hint: '改用 GET /api/gov/staff/:staffId/in-flight' }, + { route: disableStaffRoute, hint: '在 Keycloak 停用账号后 POST /api/gov/staff/sync' }, + { route: listGrantsRoute, hint: '岗位授权已退役,改用 GET /api/gov/access/roles(角色→端)' }, + { route: setGrantsRoute, hint: '岗位授权已退役,改用 PUT /api/gov/access/roles/:role' }, +]; + +export function createServer(container: Container): Hono { + const app = new Hono(); + + /** + * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。 + * 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判, + * 路由里不再各写守卫 —— 一处拦住,新路由自动生效。 + */ + app.use( + '*', + createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }), + ); + + /** + * 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。 + * + * 从前 `kind:'system'` 只在类型里声明过,**全仓一处也没发过** —— 于是治理台的 + * 全部配置面(活动库 / 工具箱 / 大模型 / 流程包 / 数据域 / 驾驶舱指标 / 触发器) + * 只有自己那一次 mutation 之后的本地失效才刷新:换一个浏览器标签、或另一个人改了配置, + * 这边纹丝不动,直到手动刷新。 + * + * 放在中间件而不是逐个路由里,是因为治理写路由有二十几条、还会长 —— + * 挨个加一行的写法,漏一条就是一处**看不见的**不同步(这类漏最难发现: + * 它不报错,只是画面比事实旧)。这里一处拦住,新增路由自动生效。 + * + * 只在**成功**(2xx)之后发;推送不带载荷,前端照旧回服务端重取。 + */ + app.use('/api/gov/*', async (c, next) => { + await next(); + const mutating = c.req.method !== 'GET' && c.req.method !== 'HEAD'; + if (mutating && c.res.status >= 200 && c.res.status < 300) { + container.broadcaster.publish({ kind: 'system', id: 'gov', reason: `${c.req.method} ${c.req.path}` }); + } + }); + + app.onError((error, c) => { + const body: ApiError = { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + }; + process.stderr.write(`[api] ${body.message}\n`); + return c.json(body, 500); + }); + + app.notFound((c) => { + const body: ApiError = { code: 'not-found', message: `无此路由:${c.req.path}` }; + return c.json(body, 404); + }); + + for (const { route, hint } of RETIRED_ROUTES) { + app.on(route.method, route.path, (c) => { + const body: ApiError = { code: 'gone', message: `已随统一鉴权中心退役:${hint}` }; + return c.json(body, 410); + }); + } + + registerWorkbenchRoutes(app, container); + registerGovernanceRoutes(app, container); + registerDeskRoutes(app, container); + registerDeskElementRoutes(app, container); + registerDeskApprovalRoutes(app, container); + registerRegistrationRoutes(app, container); + registerGovernanceOpsRoutes(app, container); + registerIngestRoutes(app, container); + registerStreamRoute(app, container.broadcaster); + + return app; +} +``` + +#### B5 · `apps/api/src/app/routes/workbench.ts`(整文件) + +- [ ] **Step B5** + +```ts +/** + * 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。 + * 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { CONTRACTS_VERSION, authTicketRoute, healthRoute, meRoute } from '@contracts'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; +import { systemClock } from '../runtime.ts'; + +export function registerWorkbenchRoutes(app: Hono, container: Container): void { + app.get(healthRoute.path, async (c) => { + let db: 'up' | 'down' = 'down'; + try { + await container.pool.query('select 1'); + db = 'up'; + } catch { + db = 'down'; + } + return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db }); + }); + + const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' }; + + /** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */ + app.get(meRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + return c.json(principal); + }); + + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + app.get(authTicketRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + const issued = container.tickets.issue(principal.staff.id, systemClock.now()); + return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() }); + }); +} +``` + +#### B6 · `apps/api/src/app/routes/governance.ts`(整文件) + +- [ ] **Step B6** + +```ts +/** + * 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 —— + * 每个功能是对应模块的管理用例 + 本文件的路由组装)。 + * 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { + deleteLlmInstanceRoute, + deleteRoleAccessRoute, + listLlmInstancesRoute, + listLlmProvidersRoute, + listRoleAccessRoute, + listStaffRoute, + listWorkGroupsRoute, + LLM_PROVIDERS, + probeLlmDraftRoute, + probeLlmInstanceRoute, + saveWorkGroupRoute, + setRoleAccessRoute, + setStaffPositionRoute, + staffIdSchema, + staffInFlightRoute, + syncStaffRoute, + upsertLlmInstanceRoute, +} from '@contracts'; +import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org'; +import { PROVIDER_PROFILES } from '@modules/llm'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +export function registerGovernanceRoutes(app: Hono, container: Container): void { + /* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */ + + app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff())); + + /** 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎) */ + app.post(syncStaffRoute.path, async (c) => c.json(await container.org.syncRoster())); + + app.put(setStaffPositionRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + const parsed = setStaffPositionRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + return c.json(await container.org.setPosition(staffId, parsed.data.positionId)); + } catch (error) { + if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } + }); + + /** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */ + app.get(staffInFlightRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + try { + return c.json(await container.org.inFlightOf(staffId)); + } catch (error) { + if (error instanceof StaffNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } + }); + + /* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */ + + app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess())); + + /** 锁死不变量不过(改完没人能进运营中心)→ 409 */ + app.put(setRoleAccessRoute.path, async (c) => { + const role = c.req.param('role'); + const parsed = setRoleAccessRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + return c.json(await container.org.setRoleAccess(role, parsed.data.ends)); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } + }); + + app.delete(deleteRoleAccessRoute.path, async (c) => { + try { + await container.org.deleteRoleAccess(c.req.param('role')); + return c.json({ ok: true }); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } + }); + + /* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */ + + /** + * 全部工作组 —— 一人至多一组,按员工遍历取。 + * `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。 + */ + app.get(listWorkGroupsRoute.path, async (c) => { + const { staff } = await container.org.listStaff(); + const found = await Promise.all( + staff.map(async (s) => { + const group = await container.org.workGroupOf(s.id); + return group === undefined ? undefined : { ...group, ownerName: s.name }; + }), + ); + return c.json({ groups: found.filter((g) => g !== undefined) }); + }); + + /** + * 建组 / 改组 —— 按 `ownerId` 落:已有即更新(复用 id),没有即新建。 + * 与 `workGroupOf` 的取组口径一致(一人一组),不给同一个人建出两个组来。 + * 改组**不影响在途**:装进工作流的是起单时的快照。 + */ + app.put(saveWorkGroupRoute.path, async (c) => { + const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues }; + return c.json(body, 400); + } + const existing = await container.org.workGroupOf(parsed.data.ownerId); + const group = await container.org.saveWorkGroup({ + ...(existing === undefined ? {} : { id: existing.id }), + name: parsed.data.name, + ownerId: parsed.data.ownerId, + members: parsed.data.members, + }); + return c.json({ group }); + }); + + /* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */ + + /** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */ + app.get(listLlmProvidersRoute.path, (c) => + c.json({ + providers: LLM_PROVIDERS.map((provider) => { + const profile = PROVIDER_PROFILES[provider]; + return { + provider, + label: profile.label, + defaultBaseUrl: profile.defaultBaseUrl, + structuredOutput: profile.structuredOutput, + }; + }), + }), + ); + + app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() })); + + app.post(upsertLlmInstanceRoute.path, async (c) => { + const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + return c.json(await container.llm.upsert(parsed.data)); + }); + + /** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */ + app.delete(deleteLlmInstanceRoute.path, async (c) => { + const id = c.req.param('id'); + const target = (await container.llm.list()).find((i) => i.id === id); + if (target === undefined) { + const body: ApiError = { code: 'not-found', message: '实例不存在' }; + return c.json(body, 404); + } + const cards = await container.registry.list(); + const manifests = await Promise.all(cards.map((a) => container.registry.manifestOf(a.activityId))); + const users = manifests.filter((m) => m.declarations.models.includes(target.name)); + if (users.length > 0) { + const body: ApiError = { + code: 'conflict', + message: `还有 ${users.length} 个活动声明着用途名「${target.name}」—— 先改那些活动的声明再删`, + }; + return c.json(body, 409); + } + await container.llm.remove(id); + return c.json({ ok: true }); + }); + + /** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */ + app.post(probeLlmDraftRoute.path, async (c) => { + const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + return c.json(await container.llm.probeDraft(parsed.data)); + }); + + app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id')))); +} +``` + +#### B7 · `apps/api/src/app/routes/governance-ops.ts`(脚本化删守卫) + +- [ ] **Step B7** + +```bash +python3 - <<'EOF' +import re +p = 'apps/api/src/app/routes/governance-ops.ts' +s = open(p, encoding='utf-8').read() + +# ① 守卫工厂 + 14 个 const + denyStatus(含其上的 doc 注释)整块删掉 +start = s.index(' /**\n * 岗位授权:客体 = 治理功能') +end_marker = " const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);\n" +end = s.index(end_marker) + len(end_marker) +s = s[:start] + s[end:] + +# ② 45 处两行守卫 +s, n = re.subn( + r"\n[ \t]*const denied = await \w+\(c\.get\('account'\)\);\n[ \t]*if \(denied\) return c\.json\(denied, denyStatus\(denied\)\);\n", + "\n", + s, +) +assert n == 45, f'守卫两行应有 45 处,实际 {n}' + +# ③ 取当前人 → 读 principal +s = s.replace("await container.org.findByAccount(c.get('account'))", 'staffOf(c)') + +# ④ import:去掉退役类型,引入 staffOf +s = s.replace( + "import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';", + "import type { ApiError } from '@contracts';", +) +s = s.replace( + "import type { AppEnv } from '../http-env.ts';", + "import { staffOf } from '../http-env.ts';\nimport type { AppEnv } from '../http-env.ts';", +) +open(p, 'w', encoding='utf-8').write(s) +print('governance-ops.ts: 守卫已删,findByAccount 已换') +EOF +grep -n "denyStatus\|denied\|GovernanceFunction\|GrantAction\|findByAccount" apps/api/src/app/routes/governance-ops.ts || echo "(clean)" +``` +Expected:脚本打印「守卫已删」;grep 无输出(clean)。文件头的 doc 注释若还提「岗位授权」,改成「端级授权由 server.ts 的中间件按路由归属统一判(ADR-012)」。 + +#### B8 · desk / desk-elements / desk-approval / registration(取当前人改读 principal) + +- [ ] **Step B8** + +```bash +perl -0pi -e "s/await me\(c\.get\('account'\)\)/staffOf(c)/g; s/await container\.org\.findByAccount\(c\.get\('account'\)\)/staffOf(c)/g; s/import type \{ AppEnv \} from '\.\.\/http-env\.ts';/import { staffOf } from '..\/http-env.ts';\nimport type { AppEnv } from '..\/http-env.ts';/" \ + apps/api/src/app/routes/desk.ts apps/api/src/app/routes/desk-elements.ts apps/api/src/app/routes/desk-approval.ts apps/api/src/app/routes/registration.ts +grep -c "staffOf(c)" apps/api/src/app/routes/desk.ts apps/api/src/app/routes/desk-elements.ts apps/api/src/app/routes/desk-approval.ts apps/api/src/app/routes/registration.ts +``` +Expected 计数:desk.ts 12 · desk-elements.ts 6 · desk-approval.ts 2 · registration.ts 2。 + +再删掉两个已无人调用的 helper: + +`apps/api/src/app/routes/desk.ts` 删 + +```ts + /** 当前登录者;缺会话即 401 */ + async function me(account: string) { + const staff = await container.org.findByAccount(account); + return staff; + } + +``` + +`apps/api/src/app/routes/desk-elements.ts` 删 + +```ts + async function me(account: string) { + return container.org.findByAccount(account); + } + +``` + +各路由里紧跟着的 `if (staff === undefined) …401` 判断**原样保留**(见 `staffOf` 的注释)。 + +#### B9 · `apps/api/src/app/wiring/engines.ts`(两处) + +- [ ] **Step B9** + +(1)`positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)),` 换成 + +```ts + // 哨兵岗 NONE(未分配)不是流程包能声明的岗位 —— 不进候选集(ADR-012) + positions: async () => + new Set( + (await deps.org.listStaff()).positions.filter((p) => p.code !== NONE_POSITION_CODE).map((p) => p.code), + ), +``` + +并把文件头 `import type { OrgService } from '@modules/org';` 之后加 `import { NONE_POSITION_CODE } from '@modules/org';`。 + +(2)死信通知那段 + +```ts + const { staff } = await deps.org.listStaff(); + for (const person of staff) { + const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern'); + if (!governs) continue; + await deps.message.notify({ +``` + +换成 + +```ts + // 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端) + for (const person of await deps.org.staffWithEnd('ops')) { + await deps.message.notify({ +``` + +(`notify({...})` 的正文与 `}` 闭合照旧。) + +#### B10 · `seed.ts` `tsup.config.ts` `package.json`、删旧文件 + +- [ ] **Step B10** + +`apps/api/src/app/seed.ts` 里 `process.stdout.write(`种子完成:…`)` 那两行换成: + +```ts + process.stdout.write(`种子完成:${positions.length} 个岗位;名册 ${staff.length} 人(人来自鉴权中心:首登自动建档 / 运营中心同步)\n`); + for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`); +``` + +`apps/api/tsup.config.ts` 删这两行: + +```ts + // 改密 CLI(ADR-010):治理台改密界面未做前的运维口子 + 'set-password': 'src/app/set-password.ts', +``` + +`apps/api/package.json` 的 scripts 删 `"set-password": "node dist/set-password.js",`。 + +```bash +git rm apps/api/src/app/session-cookie.ts apps/api/src/app/set-password.ts +``` + +#### B11 · 三份集成测试改「造人」 + +- [ ] **Step B11** + +三个文件(`new-package-flow.it.test.ts` `unified-quote-flow.it.test.ts` `testbed-trace.it.test.ts`)各做两处: + +(a)`createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }))` → 环境里加测试账号: + +```ts + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); +``` + +(b)把 + +```ts + const { staff } = await container.org.listStaff(); + chaihao = staff.find((s) => s.account === 'Chaihao')!.id; +``` + +换成 + +```ts + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; +``` + +`testbed-trace.it.test.ts` 还有一行 `ai = staff.find((s) => s.account === 'AI')!.id;`,换成 + +```ts + ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id; +``` + +- [ ] **Step B12: 全量门禁** + +```bash +pnpm typecheck && pnpm lint && pnpm depcruise && pnpm test +``` +Expected:全绿。常见红点与处置: +- `explicit-module-boundary-types`:模块里 `export function` 缺返回类型 → 补; +- 未用 import(`PositionId` / `ApiError` 之类)→ 删; +- `org-service.test.ts` 里 `Harness.org` 的类型:`ReturnType` 即 `OrgService`; +- 三份 it-test 若因没有库而跳过,属正常(它们自报「跳过」);有库时应全绿。 + +- [ ] **Step B13: 刷新契约快照、提交** + +```bash +pnpm api:check --update +git add -A apps/api packages db api-report +git status --short # 只应有本任务点名的文件;不该有 web / CLAUDE.md / docs +git commit -m "统一鉴权中心接入(ADR-012):org 重写为 Keycloak 身份 + 角色→端;中间件按路由归属统一判;口令/会话/岗位授权退役;迁移 0032" +``` + +--- + +### Task 9: 启动自检、配置测试、路由覆盖(fail-closed) + +**Files:** +- Create: `apps/api/src/app/keycloak-check.ts` +- Modify: `apps/api/src/app/main.ts` +- Test: `apps/api/src/app/__tests__/keycloak-check.test.ts` `config-keycloak.test.ts` `route-coverage.it.test.ts` + +**Interfaces:** +- Consumes:`KeycloakConfig`(Task 8 B1)、`appRouteRegistry` `EXTRA_ROUTES`(Task 8 B4)、`createServer` `createContainer`。 +- Produces:`checkKeycloakConnection(kc, fetchImpl?): Promise<{ issuer, clientId }>`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/app/__tests__/config-keycloak.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { loadConfig } from '../config.ts'; + +describe('Keycloak 配置(ADR-012)', () => { + it('三样齐了才有 keycloak 段;尾斜杠去掉;issuer / jwks 拼好', () => { + const c = loadConfig({ KEYCLOAK_URL: 'https://kc.test/', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'api-gateway' }); + expect(c.keycloak).toEqual({ + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', + }); + }); + it('缺一样就没有 keycloak 段(main.ts 据此拒绝启动)', () => { + expect(loadConfig({ KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp' }).keycloak).toBeUndefined(); + expect(loadConfig({ KEYCLOAK_URL: ' ', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }).keycloak).toBeUndefined(); + }); + it('provisioning 两样都给才带', () => { + const base = { KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }; + expect( + loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning', KEYCLOAK_PROVISIONING_CLIENT_SECRET: 's3' }).keycloak + ?.provisioning, + ).toEqual({ clientId: 'provisioning', secret: 's3' }); + expect(loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning' }).keycloak?.provisioning).toBeUndefined(); + }); + it('测试账号逗号分隔、去空白、丢空项', () => { + expect(loadConfig({ JCAI_TEST_ACCOUNTS: ' ai, chaihao ,, ' }).testAccounts).toEqual(['ai', 'chaihao']); + expect(loadConfig({}).testAccounts).toEqual([]); + }); +}); +``` + +`apps/api/src/app/__tests__/keycloak-check.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { KeycloakConfig } from '../config.ts'; +import { checkKeycloakConnection } from '../keycloak-check.ts'; + +const kc: KeycloakConfig = { + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', +}; +const reply = (status: number, body: unknown): typeof fetch => async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('启动自检(手册第 5 步)', () => { + it('issuer 一致 → 回 issuer 与 clientId', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: kc.issuer }))).resolves.toEqual({ + issuer: kc.issuer, + clientId: 'api-gateway', + }); + }); + it('issuer 不一致 → 抛,话里带期望 / 实际 / KC_HOSTNAME', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: 'http://localhost:8080/realms/corp' }))).rejects.toThrow( + /KC_HOSTNAME/, + ); + }); + it('发现文档拉不到 → 抛带状态码', async () => { + await expect(checkKeycloakConnection(kc, reply(503, {}))).rejects.toThrow(/503/); + }); +}); +``` + +`apps/api/src/app/__tests__/route-coverage.it.test.ts`(真库;库的取法与其他 it-test 同规矩): + +```ts +/** + * 路由覆盖(fail-closed,ADR-012):Hono 里每一条注册了的路由都得在路由注册表里有归属。 + * 漏一条 = 一个没人管的口子,这里把它变成测试红。要真容器只是为了把路由都挂上,不发请求。 + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from '../config.ts'; +import { createContainer } from '../container.ts'; +import type { Container } from '../container.ts'; +import { appRouteRegistry, createServer } from '../server.ts'; + +const IT_DB = 'jcai_it_routes'; + +async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { + const explicit = process.env['JCAI_TEST_DATABASE_URL']; + if (explicit !== undefined) return { url: explicit, stop: async () => undefined }; + const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; + try { + const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); + const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); + if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); + await admin.end(); + return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; + } catch { + /* 开发实例连不上,试 Testcontainers */ + } + try { + const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); + const started = await new PostgreSqlContainer('postgres:16-alpine').start(); + return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; + } catch (error) { + process.stderr.write( + `⚠️ 路由覆盖 it 跳过:连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, + ); + return undefined; + } +} + +function migrationsFolder(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i += 1) { + const candidate = join(dir, 'db', 'migrations'); + if (existsSync(candidate)) return candidate; + const parent = resolve(dir, '..'); + if (parent === dir) break; + dir = parent; + } + throw new Error('找不到 db/migrations'); +} + +const database = await provisionDatabase(); +let pool: Pool | undefined; +let container: Container | undefined; + +beforeAll(async () => { + if (database === undefined) return; + pool = new Pool({ connectionString: database.url, max: 4 }); + for (const schema of ['sys', 'ledger', 'mastra', 'queue']) { + await pool.query(`drop schema if exists "${schema}" cascade`); + await pool.query(`create schema "${schema}"`); + } + await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); + container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); +}, 180_000); + +afterAll(async () => { + await container?.close(); + await pool?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('路由覆盖', () => { + it('Hono 里每条注册的路由都在归属注册表里', () => { + const app = createServer(container!); + const registry = appRouteRegistry(); + const missing = app.routes + .filter((r) => r.method !== 'ALL') + .filter((r) => !registry.has(r.method, r.path)) + .map((r) => `${r.method} ${r.path}`); + expect(missing, '这些路由没有声明归哪个端(契约加 access,或 EXTRA_ROUTES 补入)').toEqual([]); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/config-keycloak.test.ts apps/api/src/app/__tests__/keycloak-check.test.ts` +Expected:config 那份 PASS(B1 已做);keycloak-check 那份 FAIL(模块不存在)。 + +- [ ] **Step 3: 写 `apps/api/src/app/keycloak-check.ts`** + +```ts +/** + * 启动自检(手册第 5 步;ADR-012):拉 `.well-known/openid-configuration`, + * issuer 与本地拼出来的不一致就别起 —— 配错了每个请求都 401 还查不出原因,不如启动时就说清楚。 + */ +import type { KeycloakConfig } from './config.ts'; + +export async function checkKeycloakConnection( + kc: KeycloakConfig, + fetchImpl: typeof fetch = fetch, +): Promise<{ issuer: string; clientId: string }> { + const url = `${kc.issuer}/.well-known/openid-configuration`; + const resp = await fetchImpl(url, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); + const conf = (await resp.json()) as { issuer?: unknown }; + if (conf.issuer !== kc.issuer) { + throw new Error( + `issuer 不一致!\n 期望: ${kc.issuer}\n 实际: ${String(conf.issuer)}\n` + + ' → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了', + ); + } + return { issuer: kc.issuer, clientId: kc.clientId }; +} +``` + +- [ ] **Step 4: `main.ts` 的 `boot()` 开头改成** + +```ts +export async function boot(): Promise { + const config = loadConfig(); + if (config.keycloak === undefined) { + process.stderr.write( + '✗ 缺 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID —— 统一鉴权中心是唯一的登录途径,缺了谁也进不来(ADR-012)\n', + ); + process.exit(1); + } + try { + const info = await checkKeycloakConnection(config.keycloak); + process.stdout.write(`✓ Keycloak 连通 · issuer=${info.issuer} · client=${info.clientId}\n`); + } catch (error) { + // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因(手册第 5 步) + process.stderr.write(`✗ Keycloak 连不上或配置有误:${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + const container = await createContainer(config); + await container.org.ensureSeeded(); +``` + +并加 `import { checkKeycloakConnection } from './keycloak-check.ts';`。 + +- [ ] **Step 5: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/app/__tests__/keycloak-check.test.ts apps/api/src/app/__tests__/config-keycloak.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected:全 PASS(覆盖测试有库时跑、无库时自报跳过);绿。若覆盖测试报了 `missing`,按提示给那条契约加 `access` 或补进 `EXTRA_ROUTES`。 + +- [ ] **Step 6: 提交** + +```bash +git add apps/api/src/app/keycloak-check.ts apps/api/src/app/main.ts apps/api/src/app/__tests__/keycloak-check.test.ts apps/api/src/app/__tests__/config-keycloak.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts +git commit -m "启动自检 issuer 不一致拒绝启动;路由覆盖 fail-closed;Keycloak 配置解析测试(ADR-012)" +``` + +--- + +### Task 10: e2e 脚本改走服务账号 token + +**Files:** +- Modify: `tools/e2e-a2.mjs` + +**Interfaces:** +- Consumes:`GET /api/me`(`ends`)。 +- 环境变量:`KEYCLOAK_URL` `KEYCLOAK_REALM`(缺省 `corp`)`KEYCLOAK_E2E_CLIENT_ID` `KEYCLOAK_E2E_CLIENT_SECRET`(client_credentials;该服务账号需 realm 角色 `admin`,且发出的 token `aud` 含本服务 clientId —— Keycloak 侧前置 4)。 + +- [ ] **Step 1: 常量** + +把 + +```js +const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026'; +``` + +换成 + +```js +/** 身份归统一鉴权中心(ADR-012):e2e 用服务账号的 client_credentials 拿 token,不用人的账号 */ +const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, ''); +const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp'; +const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? ''; +const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? ''; +/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */ +const TOKEN_REFRESH_MS = 4 * 60 * 1000; +``` + +- [ ] **Step 2: `api()` 与 `login()`** + +把 + +```js +let cookie = ''; + +async function api(path, init = {}) { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) }, + }); +``` + +换成 + +```js +let bearer = ''; +let bearerAt = 0; + +async function api(path, init = {}) { + if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login(); + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) }, + }); +``` + +把整个 `async function login() { … }` 换成 + +```js +async function login() { + if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') { + throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)'); + } + const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'client_credentials', client_id: E2E_CLIENT_ID, client_secret: E2E_CLIENT_SECRET }), + }); + if (!res.ok) throw new Error(`取 token 失败:${res.status} ${await res.text()}`); + const { access_token: token } = await res.json(); + bearer = `Bearer ${token}`; + bearerAt = Date.now(); + // 试跑要进设计端(测试台)、答卡要进工作台 —— 服务账号的角色不够,这里就先说清楚 + const me = await api('/api/me'); + for (const end of ['design', 'workbench']) { + if (!me.body.ends.includes(end)) throw new Error(`e2e 服务账号进不了 ${end}:ends=${JSON.stringify(me.body.ends)} —— 给它 realm 角色 admin`); + } +} +``` + +- [ ] **Step 3: 两处裸 fetch 的头** + +两处 `{ headers: { cookie } }`(下载原件的 `…&raw=1`)都改成 `{ headers: { authorization: bearer } }`。 + +- [ ] **Step 4: 语法与 lint** + +Run: `node --check tools/e2e-a2.mjs && pnpm lint` +Expected:无输出、lint 绿。真跑 `node tools/e2e-a2.mjs …` 需要 Keycloak 侧前置 1 与 4 就位 —— 就位后跑任一套件应 `released`;未就位时脚本在 `login()` 就把缺什么说清楚。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/e2e-a2.mjs +git commit -m "e2e:改用 Keycloak 服务账号 client_credentials 拿 token,4 分钟一换;断言进得了设计端与工作台" +``` + +--- + +### Task 11: 文档、契约快照、五道门禁 + +**Files:** +- Create: `docs/adr/ADR-012-keycloak-identity.md` +- Modify: `apps/api/src/modules/org/CONTRACT.md`(整文件)、`docs/编码规范.md`(三处)、`docs/debt.md`(鉴权段)、`CLAUDE.md`(铁规矩 5 / 6 + 现状快照一条) +- `api-report/`(`pnpm api:check --update`) + +- [ ] **Step 1: ADR-012** + +`docs/adr/ADR-012-keycloak-identity.md`: + +```markdown +# ADR-012 统一鉴权中心(Keycloak):身份、名册与「角色 → 端」 + +状态:已接受(2026-08-25) +背景出处:`docs/keycloak-backend-guide.md`(Keycloak 26.7.1 / jose,核实日期 2026-08-12); +设计稿 `docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`。 +取代:ADR-010(口令 + 服务端会话)与编码规范 §4 原「不引 OAuth/OIDC」的定调。 + +## 问题 + +公司有了统一鉴权中心,本系统还在自建账号口令、服务端会话表与岗位授权(客体三类 × 动作三种)。 +人要在两处维护,权限模型也比产品实际需要的细太多 —— 用户只要「不同角色可以动态配置能进哪几个端」。 + +## 决策 + +1. **资源服务器,不做 BFF**:前端(`web-portal` / `web-console`,PKCE)自取 token;后端只本地验签 + Bearer JWT(`jose`:RS256 写死、校 iss 与 aud、30s 容差、远程 JWKS 缓存 10 分钟 / 冷却 30 秒), + 不回源、不 introspect、**不配 client secret**。 +2. **身份在 token 里**:`Staff.id` = `sub`;`account` = `preferred_username` 只作显示; + 首次见到即建档(JIT),账号 / 姓名 / 邮箱 / 角色变了才写,`last_seen_at` 60 秒内不重写。 +3. **权限只到端**:三个端 design / ops / workbench;`sys.role_access` 一行一个 realm 角色, + 运营中心可改;一个人的端 = 其角色命中行的并集。缺省 `admin → 三端`,`user → workbench`。 + **锁死不变量**:改完至少一名在册活跃员工仍能进运营中心,否则 409。 +4. **路由归属进契约**:`RouteContract.access` 必填(public / any / 端 / 端数组);app 层一处中间件 + 按注册表判 401 / 403;治理路由里约 100 处逐条守卫整体删除;覆盖测试 fail-closed。 +5. **名册**:岗位仍是本系统的表(哨兵岗 `NONE / 未分配`,运营中心指派);名册经 Admin API 同步 + (master realm 的 provisioning 服务账号,只要 `view-users`)+ 首登即建。`state` 只由同步置 + `disabled`(Keycloak 停用或删除),有效 token 不改它 —— 撤权在同步之后即时 403。 +6. **票据**:`EventSource` 与 `` 带不了 Authorization 头 —— `GET /api/auth/ticket` + 换一枚 60 秒 HMAC 票据(`HKDF(JCAI_SECRET_KEY, 'jcai-ticket')`),只在 GET 的 `?ticket=` 上认; + 票据里没有权限,中间件拿 sub 回库现算。无状态、不单次:60 秒重放窗换零状态表。 +7. **退役**:`login` / `logout` / `session` / 建人 / 停用 / 岗位授权八条路由回 410(新错误码 `gone`), + 契约标 `@deprecated` 留一轮(§12.4 三步);`sys.session` `sys.position_grant` `staff.password_hash` drop。 +8. **测试账号**:`JCAI_TEST_ACCOUNTS`(Keycloak 用户名)命中者落 TEST 岗 + 独立工作组(ADR-011 原语义); + e2e 与试跑用服务账号(client_credentials),不用人的账号。 + +## 后果 + +- 撤角色的时效 ≤ 5 分钟(token 寿命);停用经同步后即时。 +- web 需按《前端接入手册》改造(登录 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读 `/api/me` 的 `ends`), + 改造前登不进 —— 这是「只做后端」的既定后果。 +- 迁移 0032 清掉种子人员与工作组;运行数据由 `reset-runs` 清。 +- Keycloak 侧前置(管理员):本服务 client(建议复用 `api-gateway`)、`web-console` 补 audience mapper、 + provisioning 服务账号、e2e 服务账号 client(`bpos-e2e`,realm 角色 admin,firstName「AI」)。 +- 欠账见 `docs/debt.md`「鉴权」段。 +``` + +- [ ] **Step 2: `apps/api/src/modules/org/CONTRACT.md`(整文件)** + +```markdown +# module: org + +职责: 员工与岗位(ADR-012):验签后的人是谁(名册即时更新)、他能进哪几个端(角色→端)、 + 岗位指派与在岗人员查询(负责人四策略解析的候选集来源)、名册同步(Admin API)、 + 在途工作(只读)、工作组(ADR-011)、种子(岗位 + 缺省角色→端) +不负责: 验签之外的 HTTP 语义(401/403 由 app 层中间件按路由归属判)、 + 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 由 app 注入) + +公开出口: index.ts + - 类型:`OrgService` `StaffWithPosition` `Identity` 与 @contracts 的 `Staff` / `Position` / `Principal` / + `RoleAccess` / `RosterSyncOutcome` / `InFlightWork` / `Surface` / `WorkGroup` / `WorkGroupBinding` 转出 + - 错误:`TokenRejectedError`(→401)`AccountDisabledError`(→403)`AccessLockoutError`(→409) + `StaffNotFoundError` `PositionNotFoundError` + - 端口类型:`TokenVerifier` `VerifiedClaims` `RosterSource` `RosterUser` `IdentityPatch` + `PositionRepository` `StaffRepository` `StaffRecord` `RoleAccessRepository` + `WorkGroupRepository` `InFlightWorkProbe` `Clock` `IdGenerator` + - 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService` + - 纯函数:`identityOf` `isNoiseRole` `normalizeRoles` `endsOf` `assertNotLockedOut` `normalizeEnds` `buildInFlightWork` + - 常量:`POSITION_SEED` `NONE_POSITION_CODE` `TEST_POSITION_CODE` `DEFAULT_ROLE_ACCESS` + - adapter 工厂(供 app 装配):`createKeycloakTokenVerifier`(jose)`createKeycloakRoster`(Admin API) + `createPositionRepository` `createStaffRepository` `createRoleAccessRepository` `createWorkGroupRepository` + - Drizzle 表:`positionTable` `staffTable` `roleAccessTable` `workGroupTable` + +层级: L1(编码规范 §4) +依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema);jose(仅 adapter) + port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`、 + `TokenVerifier`(app 给 jose 远程 JWKS;测试给本地密钥对)、`RosterSource`(可缺省) +被依赖: process(负责人解析的候选集)、message(治理台收件人 = staffWithEnd('ops'))、 + app(鉴权中间件与 /api/gov/staff、/api/gov/access 路由组装) + +不变量: + - `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键 + - `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError, + 有效 token 不能把它翻回 active(撤权在同步之后即时生效) + - 名册即时更新只写变了的列;`last_seen_at` 60 秒内不重写 + - 角色→端:`setRoleAccess` / `deleteRoleAccess` 先算新表再验锁死不变量(名册为空不算锁死) + - `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 [] + - `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖 + - 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核 + +影响半径(2026-08-25,ADR-012):contract / port / index 全面重写 —— 退役 `login` `logout` `resolveSession` + `setPassword` `authorize` `loadSession` `listGrants` `setGrants` `createStaff` `checkDisable` + `disableStaff` `findByAccount` 与 `SessionRepository` `PasswordHasher` `GrantRepository`; + 下游 app(中间件 / 装配 / 路由)与 message 装配同批改,depcruise 已反查确认。 +``` + +- [ ] **Step 3: `docs/编码规范.md` 三处** + +(1)§1 表在 `| 代码契约校验 | zod | 仓库内 API/DTO |` 之后加一行: + +```markdown +| 鉴权 | Keycloak(统一鉴权中心)+ `jose` 本地验签 | 资源服务器:RS256 / iss / aud 本地验,不回源;权限只到端(ADR-012) | +``` + +(2)§4 约束里那一行 + +```markdown +- 鉴权定调:单组织 cookie session——登录、会话与岗位权限校验中间件在 apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制 +``` + +换成 + +```markdown +- 鉴权定调(ADR-012):统一鉴权中心(Keycloak)资源服务器——后端只验 Bearer JWT;鉴权中间件在 apps/api 的 app 层实现,身份判定读 `org` 出口;**权限只到端**(设计中心 / 运营中心 / 工作台),角色→端由运营中心的表定,路由归属写在契约的 `access` 上;不做多租户 +``` + +(3)§14 在 `- **ADR-006** …` 之后加: + +```markdown +- **ADR-012** 统一鉴权中心(Keycloak):资源服务器本地验签(`jose`),`Staff.id` = `sub`;权限只到端,`sys.role_access` 可改;名册 Admin API 同步 + 首登即建;60 秒票据给 SSE 与下载;口令 / 会话 / 岗位授权退役(取代 ADR-010) +``` + +- [ ] **Step 4: `docs/debt.md` —— 把「## 鉴权(ADR-010 落地后的已知欠账)」整段(到下一个 `## ` 之前)换成** + +```markdown +## 鉴权(ADR-012 落地后的已知欠账) + +- **撤角色时效 ≤ 5 分钟**:改了 Keycloak 里的角色,现有 access token 到期前仍按旧角色算(手册 13.4); + 要立即生效在 Keycloak 上 `POST /users/{id}/logout`。停用经名册同步后即时 403。 +- **票据 60 秒可重放窗**(无状态换来的):绑定 sub、只认 GET;要单次得加一张已用票据表。 +- **名册同步是手动按钮**(运营中心 · 员工 · 同步),没有定时;没配 provisioning 就只有首登即建。 +- **web 待按《Keycloak 前端接入手册》接入**:登录页 / Bearer / 401 跳转 / SSE 与下载改走 `GET /api/auth/ticket` / + 入口亮灯改读 `GET /api/me` 的 `ends`;接入前登不进。那一刻一并清掉 `SURFACE_FNS` 与退役契约。 +- **退役契约下个迭代删**(§12.4 三步之第三步):`loginRoute` `logoutRoute` `sessionRoute` `createStaffRoute` + `disableStaff*Route` `listGrantsRoute` `setGrantsRoute` 与 `sessionSchema` / `grant*` / `governanceFunction*` / `disableCheckSchema`。 +- **`groups` 未用**:日后要按部门过滤数据,需 Keycloak 配 group membership mapper。 +- **aud mapper 未配即全线 401**:401 的 message 会点名「给发 token 的那个 client 加 audience mapper」,但没有启动期探测。 +``` + +- [ ] **Step 5: `CLAUDE.md`** + +铁规矩 5 与 6 换成: + +```markdown +5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、 + `KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选); + 不入源码、不入包、不入留痕;E2E 永不回退开发库。 +6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名), + 绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。 +``` + +现状快照列表末尾(`## 文档权威地图` 之前)加一条: + +```markdown +- **统一鉴权中心(Keycloak)接入,只做后端**(2026-08-25,ADR-012):资源服务器 —— 后端只验 Bearer JWT + (`jose`,RS256 / iss / aud 本地验,不回源);`Staff.id` = `sub`,首登即建、Admin API 同步名册; + **权限只到端**:`sys.role_access` 一行一个 realm 角色,运营中心可改(锁死不变量:改完得有人还能进运营中心); + 路由归属写在契约 `access` 上,app 层一处中间件统一判 401/403,治理路由里约 100 处守卫整体删除, + 覆盖测试 fail-closed;SSE 与下载走 60 秒票据;口令 / 会话表 / 岗位授权退役(八条路由回 410,契约留一轮)。 + web 待按前端手册接入,接入前登不进。 +``` + +- [ ] **Step 6: 五道门禁** + +```bash +pnpm api:check --update && git add api-report +pnpm gates +``` +Expected:五道全绿(退出码 0)。 + +- [ ] **Step 7: 提交** + +```bash +git add docs/adr/ADR-012-keycloak-identity.md apps/api/src/modules/org/CONTRACT.md docs/编码规范.md docs/debt.md CLAUDE.md api-report +git commit -m "文档:ADR-012 统一鉴权中心;编码规范鉴权定调改为资源服务器 + 权限只到端;org CONTRACT.md 重写;debt 鉴权段;CLAUDE.md 铁规矩 5/6" +``` + +--- + +## 自审记录(写完计划后对着 spec 过的一遍) + +- **spec 覆盖**:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。 +- **类型一致性**:`OrgService` 的方法名在 Task 8 A2(契约)、A7(实现)、B3–B9(调用方)一致:`authenticate` `principalOf` `registerIdentity` `listStaff` `getStaff` `staffOfPosition` `setPosition` `syncRoster` `inFlightOf` `listRoleAccess` `setRoleAccess` `deleteRoleAccess` `staffWithEnd` `saveWorkGroup` `workGroupOf` `bindingOf` `ensureSeeded`;`Authenticator`(Task 7)是它的子集;`StaffRepository` 无 `findByAccount`(A3 / A6 / A7 / A1 的内存实现一致);`createContainer(config, overrides)`(B3)与 Task 9 的 it-test 调用一致;`EXTRA_ROUTES` / `appRouteRegistry`(B4)与 Task 9 覆盖测试一致。 +- **占位扫描**:无 TBD / TODO;每个改动步骤都给了代码或精确的替换文本;脚本化改写(B7 / B8)带断言与验收 grep。 +- **已知取舍**:Task 8 是一次提交(中间态 typecheck 必红,不拆);it-tests 与路由覆盖测试需要 PG,没库时自报跳过。