Archived
前端对接说明:要改哪些文件、/api/me 与端、401/403/410 处置、SSE 与下载的 60s 票据、 运营中心员工页的新接口、接入完成判据。API 文档:通则与状态码、每条新接口的出入参与示例、 退役八条的替代、全部 95 条契约路由的端归属总表、schema 附录、curl 实录。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
4976 lines
210 KiB
Markdown
4976 lines
210 KiB
Markdown
# 统一鉴权中心(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 与 `<a href>` 下载走 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.<sub-b64url>.<expUnix>.<hmac-b64url>`,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<typeof surfaceSchema>;
|
||
|
||
export const SURFACE_LABEL: Readonly<Record<Surface, string>> = {
|
||
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<R extends RouteContract> = z.infer<R['output']>;
|
||
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny
|
||
? z.infer<R['body']>
|
||
: 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<typeof positionSchema>;
|
||
|
||
export const STAFF_STATES = ['active', 'disabled'] as const;
|
||
export const staffStateSchema = z.enum(STAFF_STATES);
|
||
export type StaffState = z.infer<typeof staffStateSchema>;
|
||
|
||
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<typeof staffSchema>;
|
||
|
||
/* ── 我是谁、能进哪几个端(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<typeof principalSchema>;
|
||
|
||
/** 角色 → 端:一行一个 realm 角色 */
|
||
export const roleAccessSchema = z.object({
|
||
role: z.string().min(1),
|
||
ends: z.array(surfaceSchema),
|
||
});
|
||
export type RoleAccess = z.infer<typeof roleAccessSchema>;
|
||
|
||
/** 名册同步的结果 —— 没配 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<typeof rosterSyncOutcomeSchema>;
|
||
|
||
/* ── 在途工作(只读;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<typeof inFlightWorkSchema>;
|
||
|
||
/** @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<typeof grantActionSchema>;
|
||
/** @deprecated */
|
||
export const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>> = {
|
||
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<typeof governanceFunctionSchema>;
|
||
/** @deprecated */
|
||
export const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>> = {
|
||
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<typeof grantObjectSchema>;
|
||
|
||
/** @deprecated */
|
||
export const grantSchema = z.object({
|
||
positionId: positionIdSchema,
|
||
object: grantObjectSchema,
|
||
action: grantActionSchema,
|
||
});
|
||
/** @deprecated */
|
||
export type Grant = z.infer<typeof grantSchema>;
|
||
|
||
/* ── 会话(已退役:身份在 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<typeof sessionSchema>;
|
||
|
||
/* ── 工作组(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<typeof workGroupSchema>;
|
||
|
||
/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */
|
||
export const workGroupBindingSchema = z.record(z.string().min(1), staffIdSchema);
|
||
export type WorkGroupBinding = z.infer<typeof workGroupBindingSchema>;
|
||
```
|
||
|
||
- [ ] **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')` 与 `<a href="…&raw=1" download>`。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> = {}): Staff => ({
|
||
id: staffIdSchema.parse(sub),
|
||
account: sub,
|
||
name: sub,
|
||
positionId: none,
|
||
state: 'active',
|
||
roles: ['user'],
|
||
...over,
|
||
});
|
||
const remote = (sub: string, over: Partial<RosterUser> = {}): 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<VerifiedClaims> };
|
||
|
||
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
|
||
export type RosterUser = Identity & { readonly enabled: boolean };
|
||
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
|
||
|
||
/** 名册即时更新 / 同步要改的那几列 */
|
||
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-<realm>` / `offline_access` / `uma_authorization`。
|
||
*/
|
||
import type { Identity } from '../../contract.ts';
|
||
import type { VerifiedClaims } from '../../port.ts';
|
||
|
||
const NOISE_ROLES: ReadonlySet<string> = 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<Surface>();
|
||
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<readonly string[]>,
|
||
): 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<string, Staff>(local.map((s) => [s.id, s]));
|
||
const diff: RosterDiff = { insert: [], update: [], setState: [] };
|
||
const seen = new Set<string>();
|
||
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<Staff, 'id' | 'account' | 'name'>): 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<ReturnType<typeof generateKeyPair>>['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<string, unknown>;
|
||
};
|
||
|
||
const nowSec = (): number => Math.floor(Date.now() / 1000);
|
||
|
||
async function mint(o: Mint = {}): Promise<string> {
|
||
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<VerifiedClaims> {
|
||
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<string> {
|
||
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<T>(path: string): Promise<T> {
|
||
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<readonly RosterUser[]> {
|
||
const users: KcUser[] = [];
|
||
for (let first = 0; ; first += PAGE) {
|
||
const page = await adminGet<KcUser[]>(`/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<KcRole[]>(`/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<string, RouteAccess>();
|
||
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.<sub>.<exp>.<mac>;到期时刻 = 签发 + 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')` 与 `<a href="…&raw=1" download>`。
|
||
*
|
||
* 形状 `v1.<sub-b64url>.<expUnix>.<hmac-b64url>`;密钥由 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 与 <a href> 下载这两条带不了头的路(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<Principal>; principalOf(staffId: StaffId): Promise<Principal | undefined> }`;`AuthMiddlewareDeps{registry, org: Authenticator, tickets, clock}`;`createAuthMiddleware(deps): MiddlewareHandler<AppEnv>`;`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<AppEnv> {
|
||
const app = new Hono<AppEnv>();
|
||
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<string, string> = {}): Promise<Response> => app.request(path, { headers });
|
||
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||
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 / <a href> 的路)。
|
||
* ③ 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<Principal>;
|
||
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||
};
|
||
|
||
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<AppEnv> {
|
||
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<typeof createOrgService>;
|
||
tokens: Map<string, VerifiedClaims>;
|
||
staff: Map<string, StaffRecord>;
|
||
roleAccess: Map<string, RoleAccess>;
|
||
groups: Map<string, WorkGroup>;
|
||
positions: Map<string, Position>;
|
||
calls: { updateIdentity: number };
|
||
advance(ms: number): void;
|
||
};
|
||
|
||
function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness {
|
||
const positions = new Map<string, Position>();
|
||
const staff = new Map<string, StaffRecord>();
|
||
const roleAccess = new Map<string, RoleAccess>();
|
||
const groups = new Map<string, WorkGroup>();
|
||
const tokens = new Map<string, VerifiedClaims>();
|
||
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<VerifiedClaims> & { 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<Principal>;
|
||
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
|
||
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
|
||
registerIdentity(identity: Identity): Promise<Staff>;
|
||
|
||
/* ── 名册与岗位 ───────────────────────────────────────────────────── */
|
||
|
||
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
|
||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
||
/** Admin API 同步;未配 provisioning 回 configured:false */
|
||
syncRoster(): Promise<RosterSyncOutcome>;
|
||
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
|
||
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
||
|
||
/* ── 角色 → 端 ───────────────────────────────────────────────────── */
|
||
|
||
listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>;
|
||
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
|
||
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
|
||
deleteRoleAccess(role: string): Promise<void>;
|
||
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||
|
||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
||
|
||
/** 建组 / 改组;id 留空即新建 */
|
||
saveWorkGroup(input: {
|
||
id?: string;
|
||
name: string;
|
||
ownerId: StaffId;
|
||
members: Readonly<Record<string, StaffId>>;
|
||
}): Promise<WorkGroup>;
|
||
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
||
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
||
/** 装进工作流的快照:改组不改已起的单 */
|
||
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
||
|
||
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
|
||
ensureSeeded(): Promise<void>;
|
||
};
|
||
```
|
||
|
||
#### 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<VerifiedClaims> };
|
||
|
||
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
|
||
export type RosterUser = Identity & { readonly enabled: boolean };
|
||
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
|
||
|
||
/** 名册即时更新 / 同步要改的那几列 */
|
||
export type IdentityPatch = {
|
||
readonly account: string;
|
||
readonly name: string;
|
||
readonly email?: string;
|
||
readonly roles: readonly string[];
|
||
readonly lastSeenAt?: Date;
|
||
};
|
||
|
||
/* ── 仓储 ─────────────────────────────────────────────────────────────── */
|
||
|
||
export type PositionRepository = {
|
||
list(): Promise<Position[]>;
|
||
findByCode(code: string): Promise<Position | undefined>;
|
||
findById(id: PositionId): Promise<Position | undefined>;
|
||
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
|
||
};
|
||
|
||
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||
export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
|
||
|
||
export type StaffRepository = {
|
||
list(): Promise<StaffRecord[]>;
|
||
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
|
||
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||
};
|
||
|
||
export type RoleAccessRepository = {
|
||
list(): Promise<RoleAccess[]>;
|
||
upsert(row: RoleAccess): Promise<RoleAccess>;
|
||
remove(role: string): Promise<void>;
|
||
};
|
||
|
||
/**
|
||
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
|
||
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
||
*/
|
||
export type InFlightWorkProbe = {
|
||
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
|
||
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
|
||
};
|
||
|
||
/** 工作组仓储(ADR-011) */
|
||
export type WorkGroupRepository = {
|
||
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
|
||
upsert(group: WorkGroup): Promise<WorkGroup>;
|
||
};
|
||
|
||
/** 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<string[]>().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<Map<string, Position>> {
|
||
const all = await deps.positions.list();
|
||
return new Map(all.map((p) => [p.id, p]));
|
||
}
|
||
|
||
async function withPosition(staff: Staff, positions?: Map<string, Position>): Promise<StaffWithPosition> {
|
||
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<Position> {
|
||
const position = await deps.positions.findByCode(code);
|
||
if (!position) throw new PositionNotFoundError(code);
|
||
return position;
|
||
}
|
||
|
||
async function principalFor(record: StaffRecord): Promise<Principal> {
|
||
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<void> {
|
||
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<string[][]> {
|
||
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<Staff> {
|
||
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<RosterSyncOutcome> {
|
||
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<string>(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<AppEnv>): (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 与 <a href> 下载这两条带不了 Authorization 头的路 */
|
||
readonly tickets: TicketIssuer;
|
||
```
|
||
|
||
(3)`export async function createContainer(config: AppConfig): Promise<Container> {` 之前加类型、并改签名:
|
||
|
||
```ts
|
||
/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */
|
||
export type ContainerOverrides = {
|
||
readonly tokenVerifier?: TokenVerifier;
|
||
readonly roster?: RosterSource;
|
||
};
|
||
|
||
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
|
||
```
|
||
|
||
(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 <token>' },
|
||
{ 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<AppEnv> {
|
||
const app = new Hono<AppEnv>();
|
||
|
||
/**
|
||
* 鉴权中间件(编码规范 §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<AppEnv>, 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 与 <a href> 下载这两条带不了 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<AppEnv>, 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<typeof createOrgService>` 即 `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<void> } | 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<void> {
|
||
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` 与 `<a href download>` 带不了 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,没库时自报跳过。
|