This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/plans/2026-08-25-keycloak-auth.md
T
xcsandClaude Fable 5 8ba4ab339e 实施计划:统一鉴权中心(Keycloak)后端接入 —— 11 个任务,每任务自带测试与门禁
按 spec 拆:契约归属 → org 纯函数 → jose 验签 / Admin 名册两个 adapter → 注册表 / 票据 /
中间件三件 app 小件 → 一次提交的大切换(org 重写 + app 重接线 + 迁移 0032)→ 启动自检与
路由覆盖 fail-closed → e2e 改服务账号 → ADR-012 与文档。执行前置:用户先处理工作树里未提交的改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
2026-08-25 15:15:56 +08:00

204 KiB
Raw Blame History

统一鉴权中心(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 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:

/** 路由契约:每条都得说清归哪个端(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
/**
 * 路由契约的形状(编码规范 §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
/**
 * 员工、岗位与端(编码规范 §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 之后加:

/** 我是谁、我能进哪几个端(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 改成:

import {
  disableCheckSchema,
  grantSchema,
  inFlightWorkSchema,
  positionSchema,
  roleAccessSchema,
  rosterSyncOutcomeSchema,
  staffSchema,
  workGroupSchema,
} from '../org.ts';
import { surfaceSchema } from './http.ts';

在「员工管理」段末尾(setGrantsRoute 之后)加六条:

/* ── 员工管理 · 名册与岗位(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: 刷新契约快照并提交
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:

/** 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:

/** 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:

/** 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:

/** 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 末尾追加
/* ── 统一鉴权中心(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';)
/* ── 统一鉴权中心(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 项之后加一项:

  /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */
  { code: 'NONE', name: '未分配', note: '未指派岗位;流程包不得声明它' },

文件末尾追加:

/* ── 统一鉴权中心(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:

/**
 * 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:

/**
 * 角色 → 端(纯函数,零 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:

/**
 * 名册同步的差异(纯函数,零 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:

/**
 * 测试账号策略(纯函数,零 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 删):

/**
 * 在途工作(纯逻辑,零 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 追加导出(放在既有导出之后)
/* ── 统一鉴权中心(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: 刷新契约快照并提交
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: 装依赖

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:

/**
 * 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:

/**
 * 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 追加:

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: 刷新契约快照并提交
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:

/** 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:

/**
 * 名册来源: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 追加:

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: 刷新契约快照并提交
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:

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
/**
 * 路由注册表(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: 提交
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:

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
/**
 * 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: 提交
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

/**
 * 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:

/** 小 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
/**
 * 鉴权中间件(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: 提交
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
/**
 * 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
/**
 * 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
/**
 * 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
/**
 * 种子数据 —— 纯常量,零 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
/**
 * 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
/**
 * 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
/**
 * 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
/**
 * 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
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:

-- 身份归统一鉴权中心(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:

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
/**
 * 环境配置 —— 只在 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
/**
 * 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 段

import type { OrgService, InFlightWorkProbe } from '@modules/org';
import {
  createGrantRepository,
  createOrgService,
  createPositionRepository,
  createSessionRepository,
  createStaffRepository,
  createWorkGroupRepository,
  hashPassword,
  newSessionToken,
  verifyPassword,
} from '@modules/org';

换成

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; 之后加一行:

  /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
  readonly tickets: TicketIssuer;

(3)export async function createContainer(config: AppConfig): Promise<Container> { 之前加类型、并改签名:

/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */
export type ContainerOverrides = {
  readonly tokenVerifier?: TokenVerifier;
  readonly roster?: RosterSource;
};

export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {

(4)const secrets = createSecretBox(loadSecretKey()); 换成

  const masterKey = loadSecretKey();
  const secrets = createSecretBox(masterKey);
  const tickets = createTicketIssuer(masterKey);

(5)把 const org = createOrgService({ … seedPassword: config.seedPassword, ids, }); 整块换成

  /*
   * 身份归统一鉴权中心(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 整块

      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 }));
      },

换成

      // 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;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
/**
 * 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
/**
 * 工作台路由组装 —— 路由契约在 @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
/**
 * 治理台路由组装(编码规范 §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
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
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 删

  /** 当前登录者;缺会话即 401 */
  async function me(account: string) {
    const staff = await container.org.findByAccount(account);
    return staff;
  }

apps/api/src/app/routes/desk-elements.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)), 换成

      // 哨兵岗 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)死信通知那段

        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({

换成

        // 收件人 = 能进运营中心的人(触发器的启停归运营;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(种子完成:…) 那两行换成:

    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 删这两行:

    // 改密 CLI(ADR-010):治理台改密界面未做前的运维口子
    'set-password': 'src/app/set-password.ts',

apps/api/package.json 的 scripts 删 "set-password": "node dist/set-password.js",。

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 })) → 环境里加测试账号:

  container = await createContainer(
    loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
  );

(b)把

  const { staff } = await container.org.listStaff();
  chaihao = staff.find((s) => s.account === 'Chaihao')!.id;

换成

  // 人来自鉴权中心(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;,换成

  ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id;
  • Step B12: 全量门禁
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: 刷新契约快照、提交

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:

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:

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 同规矩):

/**
 * 路由覆盖(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
/**
 * 启动自检(手册第 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() 开头改成
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: 提交
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: 常量

把

const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026';

换成

/** 身份归统一鉴权中心(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()

把

let cookie = '';

async function api(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) },
  });

换成

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() { … } 换成

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: 提交
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:

# 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(整文件)
# 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 | 之后加一行:

| 鉴权 | Keycloak(统一鉴权中心)+ `jose` 本地验签 | 资源服务器:RS256 / iss / aud 本地验,不回源;权限只到端(ADR-012) |

(2)§4 约束里那一行

- 鉴权定调:单组织 cookie session——登录、会话与岗位权限校验中间件在 apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制

换成

- 鉴权定调(ADR-012):统一鉴权中心(Keycloak)资源服务器——后端只验 Bearer JWT;鉴权中间件在 apps/api 的 app 层实现,身份判定读 `org` 出口;**权限只到端**(设计中心 / 运营中心 / 工作台),角色→端由运营中心的表定,路由归属写在契约的 `access` 上;不做多租户

(3)§14 在 - **ADR-006** … 之后加:

- **ADR-012** 统一鉴权中心(Keycloak):资源服务器本地验签(`jose`),`Staff.id` = `sub`;权限只到端,`sys.role_access` 可改;名册 Admin API 同步 + 首登即建;60 秒票据给 SSE 与下载;口令 / 会话 / 岗位授权退役(取代 ADR-010)
  • Step 4: docs/debt.md —— 把「## 鉴权(ADR-010 落地后的已知欠账)」整段(到下一个 ## 之前)换成
## 鉴权(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 换成:

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` 清运行数据(清跑出来的,留配置好的)。

现状快照列表末尾(## 文档权威地图 之前)加一条:

- **统一鉴权中心(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: 五道门禁
pnpm api:check --update && git add api-report
pnpm gates

Expected:五道全绿(退出码 0)。

  • Step 7: 提交
git add docs/adr/ADR-012-keycloak-identity.md apps/api/src/modules/org/CONTRACT.md docs/编码规范.md docs/debt.md CLAUDE.md api-report
git commit -m "文档:ADR-012 统一鉴权中心;编码规范鉴权定调改为资源服务器 + 权限只到端;org CONTRACT.md 重写;debt 鉴权段;CLAUDE.md 铁规矩 5/6"

自审记录(写完计划后对着 spec 过的一遍)

  • spec 覆盖:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。
  • 类型一致性:OrgService 的方法名在 Task 8 A2(契约)、A7(实现)、B3–B9(调用方)一致:authenticate principalOf registerIdentity listStaff getStaff staffOfPosition setPosition syncRoster inFlightOf listRoleAccess setRoleAccess deleteRoleAccess staffWithEnd saveWorkGroup workGroupOf bindingOf ensureSeeded;Authenticator(Task 7)是它的子集;StaffRepository 无 findByAccount(A3 / A6 / A7 / A1 的内存实现一致);createContainer(config, overrides)(B3)与 Task 9 的 it-test 调用一致;EXTRA_ROUTES / appRouteRegistry(B4)与 Task 9 覆盖测试一致。
  • 占位扫描:无 TBD / TODO;每个改动步骤都给了代码或精确的替换文本;脚本化改写(B7 / B8)带断言与验收 grep。
  • 已知取舍:Task 8 是一次提交(中间态 typecheck 必红,不拆);it-tests 与路由覆盖测试需要 PG,没库时自报跳过。