# 统一鉴权中心(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 12 核对) | `docs/前端对接说明-统一鉴权中心.md`;`docs/API文档-鉴权与人员.md` | 前端要改什么、后端给了什么;接口人话版 + 全部路由归属总表 | --- ### 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` 清运行数据(清跑出来的,留配置好的)。 ``` 「文档权威地图」表在 `docs/debt.md` 那一行之前加两行: ```markdown | | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 | | | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 | ``` 现状快照列表末尾(`## 文档权威地图` 之前)加一条: ```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" ``` --- ### Task 12: 前端对接说明与 API 文档 —— 与真实契约核对 两份文档是设计阶段按 spec 写好的(`docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md`), 实现落地后要用**契约本身**把它们核一遍:路由归属总表由契约重新生成、schema 字段名逐个对、示例尽量换成真实响应。 **Files:** - Modify: `docs/API文档-鉴权与人员.md`(§6 总表整段重生成;§7 字段核对;§8 示例) - Modify: `docs/前端对接说明-统一鉴权中心.md`(§2 文件指针、§7 接口名核对) **Interfaces:** - Consumes:`packages/contracts`(Task 1 之后每条路由都有 `access`)、`GET /api/me` 等已实现的路由(Task 8)。 - [ ] **Step 1: 用契约重新生成路由归属总表** ```bash node --experimental-strip-types --input-type=module -e " const m = await import('./packages/contracts/src/index.ts'); const isRoute = (v) => v !== null && typeof v === 'object' && typeof v.method === 'string' && typeof v.path === 'string' && 'access' in v && 'output' in v; const order = ['public', 'any', 'workbench', 'design', 'ops', 'design|ops']; const rows = Object.entries(m).filter(([, v]) => isRoute(v)).map(([name, r]) => ({ access: Array.isArray(r.access) ? r.access.join('|') : r.access, method: r.method, path: r.path, name })); rows.sort((a, b) => order.indexOf(a.access) - order.indexOf(b.access) || a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); console.log('| access | 方法 | 路径 |'); console.log('|---|---|---|'); for (const r of rows) console.log('| \`' + r.access.replace('|', '\\\\|') + '\` | ' + r.method + ' | \`' + r.path + '\` |'); console.log('\n计数:' + rows.length + ' 条契约路由'); " > /tmp/route-access-table.md wc -l /tmp/route-access-table.md ``` Expected:打印 95 行路由 + 计数 `95 条契约路由`。若 `node --experimental-strip-types` 在本机版本上不认 `satisfies`(Node < 22.7),改用 `pnpm vitest run` 跑一个临时测试文件把同一段代码 `console.log` 出来,跑完删掉临时文件。 - [ ] **Step 2: 对 §6 总表** 把生成表与 `docs/API文档-鉴权与人员.md` §6 逐行比:方法 / 路径 / access 三者任一不同 → 以生成表为准改文档; 文档里手写的备注(「定义归设计」「可用票据」等)保留。SSE `GET /api/stream` 不在契约里(`EXTRA_ROUTES`),文档单列一行。 最后一行「计数」按生成结果改。 - [ ] **Step 3: 对 §7 字段名** ```bash grep -n "^export const \(staffSchema\|principalSchema\|roleAccessSchema\|rosterSyncOutcomeSchema\|inFlightWorkSchema\|workGroupSchema\|positionSchema\)" packages/contracts/src/org.ts grep -n "API_ERROR_CODES" -A 10 packages/contracts/src/api/http.ts | head -12 ``` 逐个 schema 打开看字段名与可选性,与文档 §7 的 TS 形状对齐(字段多了补、少了删、可选性以契约为准)。 - [ ] **Step 4: 示例换成真实响应(Keycloak 侧前置 1 与 4 就位时做;未就位就跳过并在文档末尾注明「示例按契约构造」)** ```bash export KEYCLOAK_URL=https://keycloak.akhr-ai.com KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=api-gateway pnpm dev:api & # 另开终端亦可;启动日志须见「✓ Keycloak 连通」 TOKEN=$(curl -s -X POST "$KEYCLOAK_URL/realms/corp/protocol/openid-connect/token" -d grant_type=client_credentials -d client_id="$KEYCLOAK_E2E_CLIENT_ID" -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token) curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq . curl -s http://localhost:8787/api/me | jq . curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq . curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq . curl -s -X POST http://localhost:8787/api/login | jq . ``` 把上面五个响应原样贴进文档 §1 / §4 / §5 / §8(sub、邮箱按真实值;secret 一个字都别贴)。 - [ ] **Step 5: 核前端对接说明里的文件指针** ```bash for f in shared/typed-fetch.ts app/main.tsx features/auth/internal/LoginPage.tsx shared/gov/surfaces.ts app/gov-shell.tsx app/app-shell.tsx app/subscribe.ts features/desk-task/internal/ViewRegion.tsx features/desk-task/internal/FileViewer.tsx shared/gov/gov-api.ts; do test -f "apps/web/src/$f" && echo "ok $f" || echo "MISSING $f"; done ``` Expected:全部 `ok`;有 `MISSING` 就改文档 §2 的指针(web 本轮没动,理论上不会)。§7 里提到的接口名与 §6 总表逐个对一遍。 - [ ] **Step 6: 提交** ```bash git add 'docs/API文档-鉴权与人员.md' 'docs/前端对接说明-统一鉴权中心.md' git commit -m "文档:前端对接说明与 API 文档按真实契约核对(路由归属总表由契约生成,示例换真实响应)" ``` --- ## 自审记录(写完计划后对着 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;用户追加的前端对接说明与 API 文档(设计阶段已写)→ Task 12 用契约核对。§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,没库时自报跳过。