契约:三个端与路由归属(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
+50 -7
View File
@@ -4,39 +4,40 @@
*/
import { z } from 'zod';
import type { RouteContract } from './http.ts';
import { sessionSchema } from '../org.ts';
import { principalSchema, sessionSchema } from '../org.ts';
import { chatMessageSchema, deliverableSchema, fileDocSchema, formFieldSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts';
import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
import { staffIdSchema, taskIdSchema } from '../id.ts';
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
export const sessionRoute = {
method: 'GET',
path: '/api/session',
access: 'public',
output: sessionSchema,
} as const satisfies RouteContract;
/**
* 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。
* 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。
*/
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
export const loginRoute = {
method: 'POST',
path: '/api/login',
access: 'public',
body: z.object({ account: z.string().min(1), password: z.string().min(1) }),
output: sessionSchema,
} as const satisfies RouteContract;
/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
export const logoutRoute = {
method: 'POST',
path: '/api/logout',
access: 'public',
output: z.object({ ok: z.boolean() }),
} as const satisfies RouteContract;
export const healthRoute = {
method: 'GET',
path: '/api/health',
access: 'public',
output: z.object({
ok: z.boolean(),
version: z.string(),
@@ -44,6 +45,25 @@ export const healthRoute = {
}),
} as const satisfies RouteContract;
/** 我是谁、我能进哪几个端(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;
/* ── 任务要素:四区(任务列表 / 标题区 / 工作区 / 对话区) ─────────────── */
/** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */
@@ -77,6 +97,7 @@ export type TaskCard = z.infer<typeof taskCardSchema>;
export const listTasksRoute = {
method: 'GET',
path: '/api/desk/tasks',
access: 'workbench',
output: z.object({ tasks: z.array(taskCardSchema) }),
} as const satisfies RouteContract;
@@ -229,6 +250,7 @@ export type TaskDetail = z.infer<typeof taskDetailSchema>;
export const taskDetailRoute = {
method: 'GET',
path: '/api/desk/tasks/:taskId',
access: 'workbench',
params: z.object({ taskId: taskIdSchema }),
output: taskDetailSchema,
} as const satisfies RouteContract;
@@ -237,6 +259,7 @@ export const taskDetailRoute = {
export const replyWorkOrderRoute = {
method: 'POST',
path: '/api/desk/work-orders/reply',
access: 'workbench',
body: z.object({ reply: z.record(z.string(), z.unknown()) }),
output: z.object({ ok: z.boolean(), producesCompletion: z.boolean() }),
} as const satisfies RouteContract;
@@ -248,6 +271,7 @@ export const replyWorkOrderRoute = {
export const replyChatCardRoute = {
method: 'POST',
path: '/api/desk/chat-cards/reply',
access: 'workbench',
body: z.object({ reply: z.record(z.string(), z.unknown()) }),
output: z.object({ ok: z.boolean() }),
} as const satisfies RouteContract;
@@ -259,6 +283,7 @@ export const replyChatCardRoute = {
export const markCommReadRoute = {
method: 'POST',
path: '/api/desk/comms/:commId/read',
access: 'workbench',
params: z.object({ commId: z.string() }),
body: z.object({ read: z.boolean() }),
output: z.object({ ok: z.boolean(), unread: z.boolean() }),
@@ -301,6 +326,7 @@ export const markCommReadRoute = {
export const saveTableRoute = {
method: 'POST',
path: '/api/desk/tasks/:taskId/table',
access: 'workbench',
params: z.object({ taskId: taskIdSchema }),
body: z.object({
/** `{ '域.对象': [ { 台账列名: 值 } ] }` —— 主键列用来认行,不参与改行;台账里没有的行就是**新增** */
@@ -347,6 +373,7 @@ export const saveTableRoute = {
export const openAssetFileRoute = {
method: 'GET',
path: '/api/desk/tasks/:taskId/file',
access: 'workbench',
params: z.object({ taskId: taskIdSchema }),
query: z.object({
/** `域.对象` —— 必须是活动清单里声明过 `file` 段的那张表(授权面) */
@@ -362,6 +389,7 @@ export const openAssetFileRoute = {
export const sayRoute = {
method: 'POST',
path: '/api/desk/tasks/:taskId/say',
access: 'workbench',
params: z.object({ taskId: taskIdSchema }),
body: z.object({ text: z.string().min(1) }),
output: chatMessageSchema,
@@ -402,12 +430,14 @@ export type SystemMessageView = z.infer<typeof systemMessageSchema>;
export const listMessagesRoute = {
method: 'GET',
path: '/api/desk/messages',
access: 'workbench',
output: z.object({ messages: z.array(systemMessageSchema) }),
} as const satisfies RouteContract;
export const markMessageReadRoute = {
method: 'POST',
path: '/api/desk/messages/:messageId/read',
access: 'workbench',
params: z.object({ messageId: z.string() }),
output: z.object({ ok: z.boolean() }),
} as const satisfies RouteContract;
@@ -416,6 +446,7 @@ export const markMessageReadRoute = {
export const answerMessageRoute = {
method: 'POST',
path: '/api/desk/messages/:messageId/answer',
access: 'workbench',
params: z.object({ messageId: z.string() }),
body: z.object({ answer: z.record(z.string(), z.unknown()) }),
output: z.object({ ok: z.boolean() }),
@@ -425,6 +456,7 @@ export const answerMessageRoute = {
export const confirmTriggerPendingRoute = {
method: 'POST',
path: '/api/desk/trigger-pending/:id/confirm',
access: 'workbench',
params: z.object({ id: z.string() }),
output: z.object({ workflowId: z.string(), alreadyStarted: z.boolean() }),
} as const satisfies RouteContract;
@@ -460,12 +492,14 @@ export type MinuteView = z.infer<typeof minuteSchema>;
export const listMinutesRoute = {
method: 'GET',
path: '/api/minutes',
access: 'workbench',
output: z.object({ minutes: z.array(minuteSchema) }),
} as const satisfies RouteContract;
export const createMinuteRoute = {
method: 'POST',
path: '/api/minutes',
access: 'workbench',
body: z.object({
title: z.string().min(1),
body: z.string(),
@@ -479,6 +513,7 @@ export const createMinuteRoute = {
export const appendResolutionRoute = {
method: 'POST',
path: '/api/minutes/:minuteId/resolutions',
access: 'workbench',
params: z.object({ minuteId: z.string() }),
body: z.object({ text: z.string().min(1), ownerId: staffIdSchema.optional() }),
output: minuteSchema,
@@ -488,6 +523,7 @@ export const appendResolutionRoute = {
export const registerResolutionRoute = {
method: 'POST',
path: '/api/minutes/resolutions/:resolutionId/register',
access: 'workbench',
params: z.object({ resolutionId: z.string() }),
body: z.object({ processPackageId: z.string().min(1), data: z.record(z.string(), z.unknown()) }),
output: z.object({ workflowId: z.string(), resolutionId: z.string() }),
@@ -542,6 +578,7 @@ export type ReportView = z.infer<typeof reportSchema>;
export const draftReportRoute = {
method: 'GET',
path: '/api/reports/:period',
access: 'workbench',
params: z.object({ period: reportPeriodSchema }),
output: reportSchema,
} as const satisfies RouteContract;
@@ -549,6 +586,7 @@ export const draftReportRoute = {
export const listReportsRoute = {
method: 'GET',
path: '/api/reports',
access: 'workbench',
output: z.object({ reports: z.array(reportSchema) }),
} as const satisfies RouteContract;
@@ -556,6 +594,7 @@ export const listReportsRoute = {
export const submitReportRoute = {
method: 'POST',
path: '/api/reports/:reportId/submit',
access: 'workbench',
params: z.object({ reportId: z.string() }),
body: z.object({ note: z.string().optional() }),
output: reportSchema,
@@ -585,6 +624,7 @@ export type ApprovalItem = z.infer<typeof approvalItemSchema>;
export const listApprovalsForMeRoute = {
method: 'GET',
path: '/api/approvals',
access: 'workbench',
output: z.object({ approvals: z.array(approvalItemSchema) }),
} as const satisfies RouteContract;
@@ -592,6 +632,7 @@ export const listApprovalsForMeRoute = {
export const decideApprovalRoute = {
method: 'POST',
path: '/api/approvals/:workflowId/:nodeId/decide',
access: 'workbench',
params: z.object({ workflowId: z.string(), nodeId: z.string() }),
body: z.object({ verdict: z.enum(['pass', 'reject']), comment: z.string().optional() }),
output: z.object({ ok: z.boolean(), workflowState: workflowStateSchema }),
@@ -608,6 +649,7 @@ export type ListApprovalsForMeOutput = z.infer<typeof listApprovalsForMeRoute.ou
export const listRegistrationTriggersRoute = {
method: 'GET',
path: '/api/triggers/registration',
access: 'workbench',
output: z.object({
triggers: z.array(
z.object({
@@ -625,6 +667,7 @@ export const listRegistrationTriggersRoute = {
export const fireRegistrationRoute = {
method: 'POST',
path: '/api/triggers/:triggerId/register',
access: 'workbench',
params: z.object({ triggerId: z.string() }),
body: z.object({ data: z.record(z.string(), z.unknown()) }),
output: z.object({