契约:三个端与路由归属(access 必填)、Principal / 角色→端 / 名册同步 schema、退役路由标 @deprecated(ADR-012)

- packages/contracts/src/api/http.ts:新增 SURFACES / surfaceSchema / Surface / SURFACE_LABEL /
  RouteAccess;RouteContract.access 必填;API_ERROR_CODES 增 'gone'。
- packages/contracts/src/org.ts:staffSchema 补 email? / roles(realm 角色快照);新增
  principalSchema/Principal、roleAccessSchema/RoleAccess、rosterSyncOutcomeSchema/RosterSyncOutcome、
  inFlightWorkSchema/InFlightWork;旧岗位授权(grant* / governanceFunction*)与 sessionSchema/Session
  全部标 @deprecated 留一轮(web 编译不受影响,下个迭代删)。
- workbench.ts / governance.ts / ingest.ts:每条既有路由补 access(按所属端/两端共用/退役 public);
  新增 meRoute、authTicketRoute(access:'any')与 syncStaffRoute、setStaffPositionRoute、
  staffInFlightRoute、listRoleAccessRoute、setRoleAccessRoute、deleteRoleAccessRoute(access:'ops')。
- apps/api 的 org-service.ts 两处 staff.insert 补 roles: [](staffSchema 新必填字段,该文件下个
  迭代会整份替换,这里只是让本次 typecheck 过)。
- 新增 packages/contracts/src/__tests__/route-access.test.ts:95 条路由的 access 归属断言。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
xcs
2026-08-25 15:54:20 +08:00
co-authored by Claude Opus 5
parent 61f96d5ab2
commit a5c8d4786d
13 changed files with 771 additions and 65 deletions
@@ -0,0 +1,81 @@
/** 路由契约:每条都得说清归哪个端(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;
}
/**
* `contracts` 是异质的命名空间(schema / 常量 / 路由混杂),`Object.entries` 推出的元素类型
* 因而是一个几百项的大联合;`entry is [string, RouteContract]` 直接对着那个联合做型别谓词,
* TS 判不出谓词类型与参数类型互相兼容(TS2677)。先收窄成 `[string, unknown][]` 再筛 ——
* 语义不变(照旧靠 `isRoute` 的运行时判断筛路由),只是把关系判断让给 `unknown`。
*/
const routes = (Object.entries(contracts) as [string, unknown][]).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(),
);
});
});