From a5c8d4786db560057cbc2c374b6cc7edceabf745 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 15:54:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A5=91=E7=BA=A6:=E4=B8=89=E4=B8=AA=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=E8=B7=AF=E7=94=B1=E5=BD=92=E5=B1=9E(access=20?= =?UTF-8?q?=E5=BF=85=E5=A1=AB)=E3=80=81Principal=20/=20=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E2=86=92=E7=AB=AF=20/=20=E5=90=8D=E5=86=8C=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20schema=E3=80=81=E9=80=80=E5=BD=B9=E8=B7=AF=E7=94=B1=E6=A0=87?= =?UTF-8?q?=20@deprecated(ADR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../src/__tests__/route-access.test.d.ts | 1 + .../contracts/src/api/governance.d.ts | 190 ++++++++++++++++++ .../packages/contracts/src/api/http.d.ts | 27 ++- .../packages/contracts/src/api/ingest.d.ts | 1 + .../packages/contracts/src/api/workbench.d.ts | 85 +++++++- api-report/packages/contracts/src/org.d.ts | 130 +++++++++--- .../org/internal/usecase/org-service.ts | 2 + .../src/__tests__/route-access.test.ts | 81 ++++++++ packages/contracts/src/api/governance.ts | 136 ++++++++++++- packages/contracts/src/api/http.ts | 29 ++- packages/contracts/src/api/ingest.ts | 1 + packages/contracts/src/api/workbench.ts | 57 +++++- packages/contracts/src/org.ts | 96 +++++++-- 13 files changed, 771 insertions(+), 65 deletions(-) create mode 100644 api-report/packages/contracts/src/__tests__/route-access.test.d.ts create mode 100644 packages/contracts/src/__tests__/route-access.test.ts diff --git a/api-report/packages/contracts/src/__tests__/route-access.test.d.ts b/api-report/packages/contracts/src/__tests__/route-access.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/api-report/packages/contracts/src/__tests__/route-access.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index b34c11b..4d46002 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -6,16 +6,19 @@ import { z } from 'zod'; export declare const listStaffRoute: { readonly method: "GET"; readonly path: "/api/gov/staff"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ staff: z.ZodArray; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; position: z.ZodObject<{ id: z.core.$ZodBranded; code: z.ZodString; @@ -31,9 +34,11 @@ export declare const listStaffRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */ export declare const createStaffRoute: { readonly method: "POST"; readonly path: "/api/gov/staff"; + readonly access: "public"; readonly body: z.ZodObject<{ account: z.ZodString; name: z.ZodString; @@ -43,16 +48,20 @@ export declare const createStaffRoute: { id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */ export declare const disableStaffCheckRoute: { readonly method: "GET"; readonly path: "/api/gov/staff/:staffId/disable-check"; + readonly access: "public"; readonly params: z.ZodObject<{ staffId: z.core.$ZodBranded; }, z.core.$strip>; @@ -73,9 +82,11 @@ export declare const disableStaffCheckRoute: { }, z.core.$strip>>>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */ export declare const disableStaffRoute: { readonly method: "POST"; readonly path: "/api/gov/staff/:staffId/disable"; + readonly access: "public"; readonly params: z.ZodObject<{ staffId: z.core.$ZodBranded; }, z.core.$strip>; @@ -99,9 +110,11 @@ export declare const disableStaffRoute: { }, z.core.$strip>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */ export declare const listGrantsRoute: { readonly method: "GET"; readonly path: "/api/gov/positions/:positionId/grants"; + readonly access: "public"; readonly params: z.ZodObject<{ positionId: z.core.$ZodBranded; }, z.core.$strip>; @@ -138,9 +151,11 @@ export declare const listGrantsRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */ export declare const setGrantsRoute: { readonly method: "POST"; readonly path: "/api/gov/positions/:positionId/grants"; + readonly access: "public"; readonly params: z.ZodObject<{ positionId: z.core.$ZodBranded; }, z.core.$strip>; @@ -209,6 +224,128 @@ export declare const setGrantsRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */ +export declare const syncStaffRoute: { + readonly method: "POST"; + readonly path: "/api/gov/staff/sync"; + readonly access: "ops"; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + configured: z.ZodLiteral; + message: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + configured: z.ZodLiteral; + inserted: z.ZodNumber; + updated: z.ZodNumber; + disabled: z.ZodNumber; + total: z.ZodNumber; + }, z.core.$strip>], "configured">; +}; +/** 指派岗位;指回哨兵岗 NONE = 取消指派 */ +export declare const setStaffPositionRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/staff/:staffId/position"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + staffId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + positionId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + email: z.ZodOptional; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + roles: z.ZodDefault>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}; +/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */ +export declare const staffInFlightRoute: { + readonly method: "GET"; + readonly path: "/api/gov/staff/:staffId/in-flight"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + staffId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + staffId: z.core.$ZodBranded; + blocked: z.ZodBoolean; + openTasks: z.ZodDefault>>; + openCards: z.ZodDefault>>; + openApprovals: z.ZodDefault>>; + }, z.core.$strip>; +}; +export declare const listRoleAccessRoute: { + readonly method: "GET"; + readonly path: "/api/gov/access/roles"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + roles: z.ZodArray>; + }, z.core.$strip>>; + knownRoles: z.ZodArray; + }, z.core.$strip>; +}; +/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */ +export declare const setRoleAccessRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/access/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + role: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + ends: z.ZodArray>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + role: z.ZodString; + ends: z.ZodArray>; + }, z.core.$strip>; +}; +export declare const deleteRoleAccessRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/access/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + role: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; export declare const LLM_PROVIDERS: readonly ["openai-compatible", "openai", "deepseek", "anthropic", "qwen"]; export declare const llmProviderSchema: z.ZodEnum<{ "openai-compatible": "openai-compatible"; @@ -243,6 +380,7 @@ export type LlmInstanceView = z.infer; export declare const listLlmProvidersRoute: { readonly method: "GET"; readonly path: "/api/gov/llm/providers"; + readonly access: "design"; readonly output: z.ZodObject<{ providers: z.ZodArray; name: z.ZodString; @@ -331,6 +471,7 @@ export declare const upsertLlmInstanceRoute: { export declare const deleteLlmInstanceRoute: { readonly method: "DELETE"; readonly path: "/api/gov/llm/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -341,6 +482,7 @@ export declare const deleteLlmInstanceRoute: { export declare const probeLlmDraftRoute: { readonly method: "POST"; readonly path: "/api/gov/llm/probe"; + readonly access: "design"; readonly body: z.ZodObject<{ id: z.ZodOptional; name: z.ZodOptional; @@ -364,6 +506,7 @@ export declare const probeLlmDraftRoute: { export declare const probeLlmInstanceRoute: { readonly method: "POST"; readonly path: "/api/gov/llm/:id/probe"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -402,6 +545,7 @@ export type GovTaskRow = z.infer; export declare const listGovTasksRoute: { readonly method: "GET"; readonly path: "/api/gov/tasks"; + readonly access: "ops"; readonly output: z.ZodObject<{ tasks: z.ZodArray; @@ -447,6 +591,7 @@ export declare const listGovTasksRoute: { export declare const controlTaskRoute: { readonly method: "POST"; readonly path: "/api/gov/tasks/:taskId/control"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -466,6 +611,7 @@ export declare const controlTaskRoute: { export declare const resumeTaskRoute: { readonly method: "POST"; readonly path: "/api/gov/tasks/:taskId/resume"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -485,6 +631,7 @@ export declare const resumeTaskRoute: { export declare const reassignTaskRoute: { readonly method: "POST"; readonly path: "/api/gov/tasks/:taskId/reassign"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -500,6 +647,7 @@ export declare const reassignTaskRoute: { export declare const govTaskLogRoute: { readonly method: "GET"; readonly path: "/api/gov/tasks/:taskId/log"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -516,6 +664,7 @@ export declare const govTaskLogRoute: { export declare const listProcessPackagesRoute: { readonly method: "GET"; readonly path: "/api/gov/process"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ packages: z.ZodArray; @@ -635,6 +784,7 @@ export declare const listProcessPackagesRoute: { export declare const saveProcessRoute: { readonly method: "PUT"; readonly path: "/api/gov/process"; + readonly access: "design"; /** * 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。 * `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。 @@ -879,6 +1029,7 @@ export declare const saveProcessRoute: { export declare const setProcessStateRoute: { readonly method: "POST"; readonly path: "/api/gov/process/:id/:version/state"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; version: z.ZodString; @@ -1005,6 +1156,7 @@ export declare const setProcessStateRoute: { export declare const deleteProcessVersionRoute: { readonly method: "DELETE"; readonly path: "/api/gov/process/:id/:version"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; version: z.ZodString; @@ -1121,6 +1273,7 @@ export declare const deleteProcessVersionRoute: { export declare const listApprovalTemplatesRoute: { readonly method: "GET"; readonly path: "/api/gov/approval-templates"; + readonly access: "design"; readonly output: z.ZodObject<{ templates: z.ZodArray; @@ -1213,6 +1368,7 @@ export declare const deleteApprovalTemplateRoute: { export declare const deleteProcessFlowRoute: { readonly method: "DELETE"; readonly path: "/api/gov/process/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -1335,6 +1491,7 @@ export declare const deleteProcessFlowRoute: { export declare const editProcessVersionRoute: { readonly method: "POST"; readonly path: "/api/gov/process/:id/:version/edit"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; version: z.ZodString; @@ -1455,6 +1612,7 @@ export declare const editProcessVersionRoute: { export declare const validateProcessRoute: { readonly method: "POST"; readonly path: "/api/gov/process/validate"; + readonly access: "design"; readonly body: z.ZodObject<{ pkg: z.ZodObject<{ id: z.core.$ZodBranded; @@ -1580,6 +1738,7 @@ export declare const validateProcessRoute: { export declare const listTriggersRoute: { readonly method: "GET"; readonly path: "/api/gov/triggers"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ triggers: z.ZodArray; @@ -1639,6 +1798,7 @@ export declare const listTriggersRoute: { export declare const toggleTriggerRoute: { readonly method: "POST"; readonly path: "/api/gov/triggers/:id/toggle"; + readonly access: "ops"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -1659,6 +1819,7 @@ export declare const toggleTriggerRoute: { export declare const upsertTriggerRoute: { readonly method: "PUT"; readonly path: "/api/gov/triggers"; + readonly access: "design"; readonly body: z.ZodObject<{ trigger: z.ZodObject<{ id: z.core.$ZodBranded; @@ -1779,6 +1940,7 @@ export declare const upsertTriggerRoute: { export declare const listWorkGroupsRoute: { readonly method: "GET"; readonly path: "/api/gov/work-groups"; + readonly access: "ops"; /** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */ readonly output: z.ZodObject<{ groups: z.ZodArray; name: z.ZodString; @@ -1818,6 +1981,7 @@ export declare const saveWorkGroupRoute: { export declare const installActivityRoute: { readonly method: "POST"; readonly path: "/api/gov/activities"; + readonly access: "design"; readonly body: z.ZodObject<{ manifest: z.ZodObject<{ identity: z.ZodObject<{ @@ -2126,6 +2290,7 @@ export declare const installActivityRoute: { export declare const registerToolRoute: { readonly method: "POST"; readonly path: "/api/gov/tools"; + readonly access: "design"; readonly body: z.ZodObject<{ contract: z.ZodObject<{ name: z.ZodString; @@ -2170,6 +2335,7 @@ export declare const registerToolRoute: { export declare const listActivitiesRoute: { readonly method: "GET"; readonly path: "/api/gov/activities"; + readonly access: "design"; readonly output: z.ZodObject<{ activities: z.ZodArray; @@ -2350,6 +2521,7 @@ export declare const listActivityMemoryRoute: { export declare const listChannelsRoute: { readonly method: "GET"; readonly path: "/api/gov/channels"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ channels: z.ZodArray; @@ -2424,6 +2598,7 @@ export declare const setTriggerConfirmRoute: { export declare const listNodeOwnersRoute: { readonly method: "GET"; readonly path: "/api/gov/packages/:id/owners"; + readonly access: "ops"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2437,6 +2612,7 @@ export declare const listNodeOwnersRoute: { export declare const setNodeOwnerRoute: { readonly method: "PUT"; readonly path: "/api/gov/packages/:id/owners/:nodeId"; + readonly access: "ops"; readonly params: z.ZodObject<{ id: z.ZodString; nodeId: z.ZodString; @@ -2452,6 +2628,7 @@ export declare const setNodeOwnerRoute: { export declare const testRunRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/run"; + readonly access: "design"; readonly body: z.ZodObject<{ packageId: z.ZodString; mail: z.ZodObject<{ @@ -2491,6 +2668,7 @@ export declare const testRunRoute: { export declare const testRunActivityRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/run-activity"; + readonly access: "design"; readonly body: z.ZodObject<{ activityId: z.ZodString; data: z.ZodDefault>; @@ -2510,6 +2688,7 @@ export declare const testRunActivityRoute: { export declare const testStopRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/stop"; + readonly access: "design"; /** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */ readonly body: z.ZodObject<{ workflowId: z.ZodDefault; @@ -2532,6 +2711,7 @@ export declare const testStopRoute: { export declare const testCurrentRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/current"; + readonly access: "design"; readonly output: z.ZodObject<{ workflowId: z.ZodOptional; subject: z.ZodOptional; @@ -2551,6 +2731,7 @@ export declare const testCurrentRoute: { export declare const testActivityStateRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/activity/:taskId"; + readonly access: "design"; readonly params: z.ZodObject<{ taskId: z.ZodString; }, z.core.$strip>; @@ -2617,6 +2798,7 @@ export declare const testActivityStateRoute: { export declare const uploadFileRoute: { readonly method: "POST"; readonly path: "/api/gov/upload"; + readonly access: "design"; readonly body: z.ZodObject<{ name: z.ZodString; contentType: z.ZodDefault; @@ -2636,6 +2818,7 @@ export type UploadedFile = z.infer; export declare const downloadFileRoute: { readonly method: "GET"; readonly path: "/api/gov/upload/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2685,6 +2868,7 @@ export type TestLogRow = z.infer; export declare const listTestLogsRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/logs"; + readonly access: "design"; readonly output: z.ZodObject<{ runs: z.ZodArray; @@ -2774,6 +2959,7 @@ export declare const testLogDetailRoute: { export declare const deleteTestLogRoute: { readonly method: "DELETE"; readonly path: "/api/gov/testbed/logs/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2784,6 +2970,7 @@ export declare const deleteTestLogRoute: { export declare const clearTestLogsRoute: { readonly method: "DELETE"; readonly path: "/api/gov/testbed/logs"; + readonly access: "design"; readonly output: z.ZodObject<{ deleted: z.ZodNumber; }, z.core.$strip>; @@ -2792,6 +2979,7 @@ export declare const clearTestLogsRoute: { export declare const traceOfWorkflowRoute: { readonly method: "GET"; readonly path: "/api/gov/workflows/:id/trace"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2825,6 +3013,7 @@ export declare const traceOfWorkflowRoute: { export declare const govWorkflowRoute: { readonly method: "GET"; readonly path: "/api/gov/workflows/:id"; + readonly access: readonly ["design", "ops"]; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2986,6 +3175,7 @@ export declare const govWorkflowRoute: { export declare const govMetricsRoute: { readonly method: "GET"; readonly path: "/api/gov/metrics"; + readonly access: "ops"; /** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */ readonly query: z.ZodObject<{ period: z.ZodOptional; +export type Surface = z.infer; +export declare const SURFACE_LABEL: Readonly>; +/** + * 一条路由归哪个端(ADR-012): + * `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。 + * 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。 + */ +export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[]; /** * 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。 - * body 只在 POST/PATCH 上有意义;GET 用 query。 + * body 只在 POST/PUT/PATCH 上有意义;GET 用 query。 */ export type RouteContract = { readonly method: HttpMethod; readonly path: string; + readonly access: RouteAccess; readonly params?: TParams; readonly query?: TQuery; readonly body?: TBody; @@ -21,8 +40,8 @@ export type RouteContract = z.infer; export type RouteBody = R['body'] extends z.ZodTypeAny ? z.infer : never; -/** 结构化错误:前端按 code 分辨,不解析文案 */ -export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal"]; +/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ +export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone"]; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; export type ApiError = { readonly code: ApiErrorCode; diff --git a/api-report/packages/contracts/src/api/ingest.d.ts b/api-report/packages/contracts/src/api/ingest.d.ts index d15629a..ab07272 100644 --- a/api-report/packages/contracts/src/api/ingest.d.ts +++ b/api-report/packages/contracts/src/api/ingest.d.ts @@ -26,6 +26,7 @@ export type InboundMail = z.infer; export declare const ingestMailRoute: { readonly method: "POST"; readonly path: "/api/ingest/channel/:channelId/mail"; + readonly access: "public"; readonly params: z.ZodObject<{ channelId: z.core.$ZodBranded; }, z.core.$strip>; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index 43ae242..b120b40 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -3,20 +3,23 @@ * 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。 */ import { z } from 'zod'; -/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export declare const sessionRoute: { readonly method: "GET"; readonly path: "/api/session"; + readonly access: "public"; readonly output: z.ZodObject<{ staff: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -56,13 +59,11 @@ export declare const sessionRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; -/** - * 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。 - * 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。 - */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export declare const loginRoute: { readonly method: "POST"; readonly path: "/api/login"; + readonly access: "public"; readonly body: z.ZodObject<{ account: z.ZodString; password: z.ZodString; @@ -72,11 +73,13 @@ export declare const loginRoute: { id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -116,10 +119,11 @@ export declare const loginRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; -/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export declare const logoutRoute: { readonly method: "POST"; readonly path: "/api/logout"; + readonly access: "public"; readonly output: z.ZodObject<{ ok: z.ZodBoolean; }, z.core.$strip>; @@ -127,6 +131,7 @@ export declare const logoutRoute: { export declare const healthRoute: { readonly method: "GET"; readonly path: "/api/health"; + readonly access: "public"; readonly output: z.ZodObject<{ ok: z.ZodBoolean; version: z.ZodString; @@ -136,6 +141,51 @@ export declare const healthRoute: { }>; }, z.core.$strip>; }; +/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */ +export declare const meRoute: { + readonly method: "GET"; + readonly path: "/api/me"; + readonly access: "any"; + readonly output: z.ZodObject<{ + staff: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + email: z.ZodOptional; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + roles: z.ZodDefault>; + }, z.core.$strip>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + roles: z.ZodArray; + ends: z.ZodArray>; + }, z.core.$strip>; +}; +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。GET 上 `?ticket=` 认它。 + */ +export declare const authTicketRoute: { + readonly method: "GET"; + readonly path: "/api/auth/ticket"; + readonly access: "any"; + readonly output: z.ZodObject<{ + ticket: z.ZodString; + expiresAt: z.ZodString; + }, z.core.$strip>; +}; /** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */ export declare const taskCardSchema: z.ZodObject<{ id: z.core.$ZodBranded; @@ -177,6 +227,7 @@ export type TaskCard = z.infer; export declare const listTasksRoute: { readonly method: "GET"; readonly path: "/api/desk/tasks"; + readonly access: "workbench"; readonly output: z.ZodObject<{ tasks: z.ZodArray; @@ -2138,6 +2189,7 @@ export type TaskDetail = z.infer; export declare const taskDetailRoute: { readonly method: "GET"; readonly path: "/api/desk/tasks/:taskId"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4043,6 +4095,7 @@ export declare const taskDetailRoute: { export declare const replyWorkOrderRoute: { readonly method: "POST"; readonly path: "/api/desk/work-orders/reply"; + readonly access: "workbench"; readonly body: z.ZodObject<{ reply: z.ZodRecord; }, z.core.$strip>; @@ -4058,6 +4111,7 @@ export declare const replyWorkOrderRoute: { export declare const replyChatCardRoute: { readonly method: "POST"; readonly path: "/api/desk/chat-cards/reply"; + readonly access: "workbench"; readonly body: z.ZodObject<{ reply: z.ZodRecord; }, z.core.$strip>; @@ -4072,6 +4126,7 @@ export declare const replyChatCardRoute: { export declare const markCommReadRoute: { readonly method: "POST"; readonly path: "/api/desk/comms/:commId/read"; + readonly access: "workbench"; readonly params: z.ZodObject<{ commId: z.ZodString; }, z.core.$strip>; @@ -4120,6 +4175,7 @@ export declare const markCommReadRoute: { export declare const saveTableRoute: { readonly method: "POST"; readonly path: "/api/desk/tasks/:taskId/table"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4149,6 +4205,7 @@ export declare const saveTableRoute: { export declare const openAssetFileRoute: { readonly method: "GET"; readonly path: "/api/desk/tasks/:taskId/file"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4199,6 +4256,7 @@ export declare const openAssetFileRoute: { export declare const sayRoute: { readonly method: "POST"; readonly path: "/api/desk/tasks/:taskId/say"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4393,6 +4451,7 @@ export type SystemMessageView = z.infer; export declare const listMessagesRoute: { readonly method: "GET"; readonly path: "/api/desk/messages"; + readonly access: "workbench"; readonly output: z.ZodObject<{ messages: z.ZodArray; @@ -4459,6 +4519,7 @@ export declare const markMessageReadRoute: { export declare const answerMessageRoute: { readonly method: "POST"; readonly path: "/api/desk/messages/:messageId/answer"; + readonly access: "workbench"; readonly params: z.ZodObject<{ messageId: z.ZodString; }, z.core.$strip>; @@ -4473,6 +4534,7 @@ export declare const answerMessageRoute: { export declare const confirmTriggerPendingRoute: { readonly method: "POST"; readonly path: "/api/desk/trigger-pending/:id/confirm"; + readonly access: "workbench"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -4521,6 +4583,7 @@ export type MinuteView = z.infer; export declare const listMinutesRoute: { readonly method: "GET"; readonly path: "/api/minutes"; + readonly access: "workbench"; readonly output: z.ZodObject<{ minutes: z.ZodArray; @@ -4622,6 +4687,7 @@ export declare const appendResolutionRoute: { export declare const registerResolutionRoute: { readonly method: "POST"; readonly path: "/api/minutes/resolutions/:resolutionId/register"; + readonly access: "workbench"; readonly params: z.ZodObject<{ resolutionId: z.ZodString; }, z.core.$strip>; @@ -4704,6 +4770,7 @@ export type ReportView = z.infer; export declare const draftReportRoute: { readonly method: "GET"; readonly path: "/api/reports/:period"; + readonly access: "workbench"; readonly params: z.ZodObject<{ period: z.ZodEnum<{ day: "day"; @@ -4759,6 +4826,7 @@ export declare const draftReportRoute: { export declare const listReportsRoute: { readonly method: "GET"; readonly path: "/api/reports"; + readonly access: "workbench"; readonly output: z.ZodObject<{ reports: z.ZodArray; @@ -4884,6 +4953,7 @@ export type ApprovalItem = z.infer; export declare const listApprovalsForMeRoute: { readonly method: "GET"; readonly path: "/api/approvals"; + readonly access: "workbench"; readonly output: z.ZodObject<{ approvals: z.ZodArray; diff --git a/api-report/packages/contracts/src/org.d.ts b/api-report/packages/contracts/src/org.d.ts index 78f519c..3496b79 100644 --- a/api-report/packages/contracts/src/org.d.ts +++ b/api-report/packages/contracts/src/org.d.ts @@ -1,9 +1,9 @@ /** - * 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。 + * 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。 * - * 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。 - * 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。 - * 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。 + * 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench), + * 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。 + * 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。 */ import { z } from 'zod'; export declare const positionSchema: z.ZodObject<{ @@ -23,23 +23,115 @@ export declare const staffSchema: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; export type Staff = z.infer; +export declare const principalSchema: z.ZodObject<{ + staff: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + email: z.ZodOptional; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + roles: z.ZodDefault>; + }, z.core.$strip>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + roles: z.ZodArray; + ends: z.ZodArray>; +}, z.core.$strip>; +export type Principal = z.infer; +/** 角色 → 端:一行一个 realm 角色 */ +export declare const roleAccessSchema: z.ZodObject<{ + role: z.ZodString; + ends: z.ZodArray>; +}, z.core.$strip>; +export type RoleAccess = z.infer; +/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */ +export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + configured: z.ZodLiteral; + message: z.ZodString; +}, z.core.$strip>, z.ZodObject<{ + configured: z.ZodLiteral; + inserted: z.ZodNumber; + updated: z.ZodNumber; + disabled: z.ZodNumber; + total: z.ZodNumber; +}, z.core.$strip>], "configured">; +export type RosterSyncOutcome = z.infer; +export declare const inFlightWorkSchema: z.ZodObject<{ + staffId: z.core.$ZodBranded; + blocked: z.ZodBoolean; + openTasks: z.ZodDefault>>; + openCards: z.ZodDefault>>; + openApprovals: z.ZodDefault>>; +}, z.core.$strip>; +export type InFlightWork = z.infer; +/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */ +export declare const disableCheckSchema: z.ZodObject<{ + staffId: z.core.$ZodBranded; + blocked: z.ZodBoolean; + openTasks: z.ZodDefault>>; + openCards: z.ZodDefault>>; + openApprovals: z.ZodDefault>>; +}, z.core.$strip>; +/** @deprecated 改用 `InFlightWork` */ +export type DisableCheck = InFlightWork; +/** @deprecated 权限只到端,动作三分已退役 */ export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"]; +/** @deprecated */ export declare const grantActionSchema: z.ZodEnum<{ read: "read"; write: "write"; govern: "govern"; }>; +/** @deprecated */ export type GrantAction = z.infer; +/** @deprecated */ export declare const GRANT_ACTION_LABEL: Readonly>; -/** 治理台九功能 —— 授权客体之一 */ +/** @deprecated 治理台九功能不再是授权客体 */ export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"]; +/** @deprecated */ export declare const governanceFunctionSchema: z.ZodEnum<{ trigger: "trigger"; data: "data"; @@ -51,8 +143,11 @@ export declare const governanceFunctionSchema: z.ZodEnum<{ process: "process"; memory: "memory"; }>; +/** @deprecated */ export type GovernanceFunction = z.infer; +/** @deprecated */ export declare const GOVERNANCE_FUNCTION_LABEL: Readonly>; +/** @deprecated */ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"data">; domain: z.ZodString; @@ -75,7 +170,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"task">; activityId: z.ZodOptional; }, z.core.$strip>], "kind">; +/** @deprecated */ export type GrantObject = z.infer; +/** @deprecated */ export declare const grantSchema: z.ZodObject<{ positionId: z.core.$ZodBranded; object: z.ZodDiscriminatedUnion<[z.ZodObject<{ @@ -106,35 +203,21 @@ export declare const grantSchema: z.ZodObject<{ govern: "govern"; }>; }, z.core.$strip>; +/** @deprecated */ export type Grant = z.infer; -/** 员工停用前,其在途任务与待答卡须先改派完毕 */ -export declare const disableCheckSchema: z.ZodObject<{ - staffId: z.core.$ZodBranded; - blocked: z.ZodBoolean; - openTasks: z.ZodDefault>>; - openCards: z.ZodDefault>>; - openApprovals: z.ZodDefault>>; -}, z.core.$strip>; -export type DisableCheck = z.infer; +/** @deprecated 改用 `principalSchema` / `GET /api/me` */ export declare const sessionSchema: z.ZodObject<{ staff: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -173,6 +256,7 @@ export declare const sessionSchema: z.ZodObject<{ }>; }, z.core.$strip>>; }, z.core.$strip>; +/** @deprecated 改用 `Principal` */ export type Session = z.infer; /** * 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。 diff --git a/apps/api/src/modules/org/internal/usecase/org-service.ts b/apps/api/src/modules/org/internal/usecase/org-service.ts index 75f89f2..68f0d3e 100644 --- a/apps/api/src/modules/org/internal/usecase/org-service.ts +++ b/apps/api/src/modules/org/internal/usecase/org-service.ts @@ -98,6 +98,7 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { name: input.name, positionId, state: 'active', + roles: [], }); // 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品 await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword)); @@ -254,6 +255,7 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { name: seed.name, positionId, state: 'active', + roles: [], }); // 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口 await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword)); diff --git a/packages/contracts/src/__tests__/route-access.test.ts b/packages/contracts/src/__tests__/route-access.test.ts new file mode 100644 index 0000000..ac16a4c --- /dev/null +++ b/packages/contracts/src/__tests__/route-access.test.ts @@ -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(), + ); + }); +}); diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 1a401e6..91f3777 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -4,7 +4,17 @@ */ import { z } from 'zod'; import type { RouteContract } from './http.ts'; -import { disableCheckSchema, grantSchema, positionSchema, staffSchema, workGroupSchema } from '../org.ts'; +import { + disableCheckSchema, + grantSchema, + inFlightWorkSchema, + positionSchema, + roleAccessSchema, + rosterSyncOutcomeSchema, + staffSchema, + workGroupSchema, +} from '../org.ts'; +import { surfaceSchema } from './http.ts'; import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts'; import { ledgerObjectKindSchema } from '../ledger.ts'; import { approvalTemplateSchema, closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts'; @@ -17,15 +27,18 @@ import { reportMetricsSchema, reportPeriodSchema } from './workbench.ts'; export const listStaffRoute = { method: 'GET', path: '/api/gov/staff', + access: ['design', 'ops'], output: z.object({ staff: z.array(staffSchema.extend({ position: positionSchema })), positions: z.array(positionSchema), }), } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */ export const createStaffRoute = { method: 'POST', path: '/api/gov/staff', + access: 'public', body: z.object({ account: z.string().min(1), name: z.string().min(1), @@ -34,35 +47,103 @@ export const createStaffRoute = { output: staffSchema, } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */ export const disableStaffCheckRoute = { method: 'GET', path: '/api/gov/staff/:staffId/disable-check', + access: 'public', params: z.object({ staffId: staffIdSchema }), output: disableCheckSchema, } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */ export const disableStaffRoute = { method: 'POST', path: '/api/gov/staff/:staffId/disable', + access: 'public', params: z.object({ staffId: staffIdSchema }), output: z.object({ ok: z.boolean(), check: disableCheckSchema }), } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */ export const listGrantsRoute = { method: 'GET', path: '/api/gov/positions/:positionId/grants', + access: 'public', params: z.object({ positionId: positionIdSchema }), output: z.object({ grants: z.array(grantSchema) }), } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */ export const setGrantsRoute = { method: 'POST', path: '/api/gov/positions/:positionId/grants', + access: 'public', params: z.object({ positionId: positionIdSchema }), body: z.object({ grants: z.array(grantSchema) }), output: z.object({ grants: z.array(grantSchema) }), } as const satisfies RouteContract; +/* ── 员工管理 · 名册与岗位(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; + /* ── 系统管理 · LLM 实例(四元绑定) ────────────────────────────────── */ export const LLM_PROVIDERS = ['openai-compatible', 'openai', 'deepseek', 'anthropic', 'qwen'] as const; @@ -92,6 +173,7 @@ export type LlmInstanceView = z.infer; export const listLlmProvidersRoute = { method: 'GET', path: '/api/gov/llm/providers', + access: 'design', output: z.object({ providers: z.array( z.object({ @@ -109,12 +191,14 @@ export const listLlmProvidersRoute = { export const listLlmInstancesRoute = { method: 'GET', path: '/api/gov/llm', + access: 'design', output: z.object({ instances: z.array(llmInstanceViewSchema) }), } as const satisfies RouteContract; export const upsertLlmInstanceRoute = { method: 'POST', path: '/api/gov/llm', + access: 'design', body: z.object({ id: z.string().optional(), name: z.string().min(1), @@ -142,6 +226,7 @@ export const upsertLlmInstanceRoute = { export const deleteLlmInstanceRoute = { method: 'DELETE', path: '/api/gov/llm/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; @@ -149,6 +234,7 @@ export const deleteLlmInstanceRoute = { export const probeLlmDraftRoute = { method: 'POST', path: '/api/gov/llm/probe', + access: 'design', body: z.object({ id: z.string().optional(), name: z.string().optional(), @@ -163,6 +249,7 @@ export const probeLlmDraftRoute = { export const probeLlmInstanceRoute = { method: 'POST', path: '/api/gov/llm/:id/probe', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean(), latencyMs: z.number().int().optional(), error: z.string().optional() }), } as const satisfies RouteContract; @@ -186,6 +273,7 @@ export type GovTaskRow = z.infer; export const listGovTasksRoute = { method: 'GET', path: '/api/gov/tasks', + access: 'ops', output: z.object({ tasks: z.array(govTaskRowSchema), workflows: z.array( @@ -204,6 +292,7 @@ export const listGovTasksRoute = { export const controlTaskRoute = { method: 'POST', path: '/api/gov/tasks/:taskId/control', + access: 'ops', params: z.object({ taskId: taskIdSchema }), body: z.object({ semantic: controlSemanticSchema, reason: z.string().optional() }), output: z.object({ ok: z.boolean(), resumable: z.boolean() }), @@ -212,6 +301,7 @@ export const controlTaskRoute = { export const resumeTaskRoute = { method: 'POST', path: '/api/gov/tasks/:taskId/resume', + access: 'ops', params: z.object({ taskId: taskIdSchema }), output: z.object({ ok: z.boolean(), runtime: taskRuntimeStateSchema }), } as const satisfies RouteContract; @@ -220,6 +310,7 @@ export const resumeTaskRoute = { export const reassignTaskRoute = { method: 'POST', path: '/api/gov/tasks/:taskId/reassign', + access: 'ops', params: z.object({ taskId: taskIdSchema }), body: z.object({ to: staffIdSchema }), output: z.object({ ok: z.boolean(), ownerId: staffIdSchema }), @@ -229,6 +320,7 @@ export const reassignTaskRoute = { export const govTaskLogRoute = { method: 'GET', path: '/api/gov/tasks/:taskId/log', + access: 'ops', params: z.object({ taskId: taskIdSchema }), output: z.object({ // 与工作台同一份日志:章是四字动作,内容带主语(见 workbench 的 log 项) @@ -243,6 +335,7 @@ export const govTaskLogRoute = { export const listProcessPackagesRoute = { method: 'GET', path: '/api/gov/process', + access: ['design', 'ops'], output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; @@ -257,6 +350,7 @@ export const listProcessPackagesRoute = { export const saveProcessRoute = { method: 'PUT', path: '/api/gov/process', + access: 'design', /** * 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。 * `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。 @@ -275,6 +369,7 @@ export const saveProcessRoute = { export const setProcessStateRoute = { method: 'POST', path: '/api/gov/process/:id/:version/state', + access: 'design', params: z.object({ id: z.string(), version: z.string() }), body: z.object({ state: z.enum(['active', 'retired']) }), output: z.object({ packages: z.array(processPackageSchema) }), @@ -288,6 +383,7 @@ export const setProcessStateRoute = { export const deleteProcessVersionRoute = { method: 'DELETE', path: '/api/gov/process/:id/:version', + access: 'design', params: z.object({ id: z.string(), version: z.string() }), output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; @@ -298,6 +394,7 @@ export const deleteProcessVersionRoute = { export const listApprovalTemplatesRoute = { method: 'GET', path: '/api/gov/approval-templates', + access: 'design', output: z.object({ templates: z.array( approvalTemplateSchema.extend({ @@ -312,6 +409,7 @@ export const listApprovalTemplatesRoute = { export const upsertApprovalTemplateRoute = { method: 'PUT', path: '/api/gov/approval-templates', + access: 'design', body: z.object({ template: approvalTemplateSchema }), output: z.object({ template: approvalTemplateSchema }), } as const satisfies RouteContract; @@ -320,6 +418,7 @@ export const upsertApprovalTemplateRoute = { export const deleteApprovalTemplateRoute = { method: 'DELETE', path: '/api/gov/approval-templates/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; @@ -332,6 +431,7 @@ export const deleteApprovalTemplateRoute = { export const deleteProcessFlowRoute = { method: 'DELETE', path: '/api/gov/process/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; @@ -347,6 +447,7 @@ export const deleteProcessFlowRoute = { export const editProcessVersionRoute = { method: 'POST', path: '/api/gov/process/:id/:version/edit', + access: 'design', params: z.object({ id: z.string(), version: z.string() }), body: z.object({ version: z.number().int().min(1).max(9999).optional(), @@ -360,6 +461,7 @@ export const editProcessVersionRoute = { export const validateProcessRoute = { method: 'POST', path: '/api/gov/process/validate', + access: 'design', body: z.object({ pkg: processPackageSchema }), output: closureReportSchema, } as const satisfies RouteContract; @@ -369,12 +471,14 @@ export const validateProcessRoute = { export const listTriggersRoute = { method: 'GET', path: '/api/gov/triggers', + access: ['design', 'ops'], output: z.object({ triggers: z.array(triggerConfigSchema) }), } as const satisfies RouteContract; export const toggleTriggerRoute = { method: 'POST', path: '/api/gov/triggers/:id/toggle', + access: 'ops', params: z.object({ id: z.string() }), body: z.object({ enabled: z.boolean() }), output: z.object({ ok: z.boolean() }), @@ -390,6 +494,7 @@ export const toggleTriggerRoute = { export const upsertTriggerRoute = { method: 'PUT', path: '/api/gov/triggers', + access: 'design', body: z.object({ trigger: triggerConfigSchema }), output: z.object({ trigger: triggerConfigSchema }), } as const satisfies RouteContract; @@ -405,6 +510,7 @@ export const upsertTriggerRoute = { export const listWorkGroupsRoute = { method: 'GET', path: '/api/gov/work-groups', + access: 'ops', /** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */ output: z.object({ groups: z.array(workGroupSchema.extend({ ownerName: z.string() })) }), } as const satisfies RouteContract; @@ -413,6 +519,7 @@ export const listWorkGroupsRoute = { export const saveWorkGroupRoute = { method: 'PUT', path: '/api/gov/work-groups', + access: 'ops', body: z.object({ ownerId: staffIdSchema, name: z.string().min(1), @@ -434,6 +541,7 @@ export const saveWorkGroupRoute = { export const installActivityRoute = { method: 'POST', path: '/api/gov/activities', + access: 'design', body: z.object({ /** 清单六区:身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口 */ manifest: activityManifestSchema, @@ -465,6 +573,7 @@ export const installActivityRoute = { export const registerToolRoute = { method: 'POST', path: '/api/gov/tools', + access: 'design', body: z.object({ contract: z.object({ name: z.string().min(1), @@ -500,6 +609,7 @@ export const registerToolRoute = { export const listActivitiesRoute = { method: 'GET', path: '/api/gov/activities', + access: 'design', output: z.object({ activities: z.array( z.object({ @@ -562,6 +672,7 @@ export const listActivitiesRoute = { export const listToolsRoute = { method: 'GET', path: '/api/gov/tools', + access: 'design', output: z.object({ tools: z.array( z.object({ @@ -583,6 +694,7 @@ export const listToolsRoute = { export const listLedgerObjectsRoute = { method: 'GET', path: '/api/gov/data/objects', + access: ['design', 'ops'], /** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */ output: z.object({ objects: z.array( @@ -623,6 +735,7 @@ export const listLedgerObjectsRoute = { export const ledgerByActivityRoute = { method: 'GET', path: '/api/gov/data/by-activity', + access: ['design', 'ops'], output: z.object({ activities: z.array( z.object({ @@ -667,6 +780,7 @@ export const ledgerByActivityRoute = { export const browseLedgerRoute = { method: 'GET', path: '/api/gov/data/:domain/:object', + access: ['design', 'ops'], params: z.object({ domain: z.string(), object: z.string() }), output: z.object({ label: z.string().optional(), @@ -682,6 +796,7 @@ export const browseLedgerRoute = { export const listActivityMemoryRoute = { method: 'GET', path: '/api/gov/memory/:activityId', + access: 'design', params: z.object({ activityId: z.string() }), output: z.object({ entries: z.array( @@ -703,6 +818,7 @@ export const listActivityMemoryRoute = { export const listChannelsRoute = { method: 'GET', path: '/api/gov/channels', + access: ['design', 'ops'], output: z.object({ channels: z.array( z.object({ @@ -733,6 +849,7 @@ export const listChannelsRoute = { export const listApprovalsRoute = { method: 'GET', path: '/api/gov/approvals', + access: 'ops', output: z.object({ approvals: z.array( z.object({ @@ -763,6 +880,7 @@ export const listApprovalsRoute = { export const setTriggerConfirmRoute = { method: 'POST', path: '/api/gov/triggers/:id/confirm', + access: 'ops', params: z.object({ id: z.string() }), body: z.object({ required: z.boolean(), staffId: z.string().optional() }), output: z.object({ ok: z.boolean() }), @@ -772,6 +890,7 @@ export const setTriggerConfirmRoute = { export const listNodeOwnersRoute = { method: 'GET', path: '/api/gov/packages/:id/owners', + access: 'ops', params: z.object({ id: z.string() }), output: z.object({ owners: z.array(z.object({ nodeId: z.string(), staffId: z.string() })) }), } as const satisfies RouteContract; @@ -779,6 +898,7 @@ export const listNodeOwnersRoute = { export const setNodeOwnerRoute = { method: 'PUT', path: '/api/gov/packages/:id/owners/:nodeId', + access: 'ops', params: z.object({ id: z.string(), nodeId: z.string() }), /** null = 撤掉运营指定,回到轮值 */ body: z.object({ staffId: z.string().nullable() }), @@ -788,6 +908,7 @@ export const setNodeOwnerRoute = { export const testRunRoute = { method: 'POST', path: '/api/gov/testbed/run', + access: 'design', body: z.object({ packageId: z.string(), /** @@ -827,6 +948,7 @@ export const testRunRoute = { export const testRunActivityRoute = { method: 'POST', path: '/api/gov/testbed/run-activity', + access: 'design', body: z.object({ activityId: z.string().min(1), /** 起单入参 —— 按该活动契约的 `input.data` 填;可用包自带样例预填 */ @@ -860,6 +982,7 @@ export const testRunActivityRoute = { export const testStopRoute = { method: 'POST', path: '/api/gov/testbed/stop', + access: 'design', /** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */ body: z.object({ workflowId: z.string().default(''), taskId: z.string().default('') }), output: z.object({ destroyed: z.boolean(), tasks: z.number().int() }), @@ -877,6 +1000,7 @@ export const testStopRoute = { export const testCurrentRoute = { method: 'GET', path: '/api/gov/testbed/current', + access: 'design', output: z.object({ workflowId: z.string().optional(), subject: z.string().optional(), @@ -900,6 +1024,7 @@ export const testCurrentRoute = { export const testActivityStateRoute = { method: 'GET', path: '/api/gov/testbed/activity/:taskId', + access: 'design', params: z.object({ taskId: z.string() }), output: z.object({ taskId: z.string(), @@ -954,6 +1079,7 @@ export const testActivityStateRoute = { export const uploadFileRoute = { method: 'POST', path: '/api/gov/upload', + access: 'design', body: z.object({ name: z.string().min(1), contentType: z.string().default(''), @@ -976,6 +1102,7 @@ export type UploadedFile = z.infer; export const downloadFileRoute = { method: 'GET', path: '/api/gov/upload/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }), } as const satisfies RouteContract; @@ -1013,6 +1140,7 @@ export type TestLogRow = z.infer; export const listTestLogsRoute = { method: 'GET', path: '/api/gov/testbed/logs', + access: 'design', output: z.object({ runs: z.array(testLogRowSchema) }), } as const satisfies RouteContract; @@ -1020,6 +1148,7 @@ export const listTestLogsRoute = { export const testLogDetailRoute = { method: 'GET', path: '/api/gov/testbed/logs/:id', + access: 'design', params: z.object({ id: z.string() }), output: testLogRowSchema.extend({ trail: z.array( @@ -1053,6 +1182,7 @@ export const testLogDetailRoute = { export const deleteTestLogRoute = { method: 'DELETE', path: '/api/gov/testbed/logs/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ deleted: z.number().int() }), } as const satisfies RouteContract; @@ -1060,6 +1190,7 @@ export const deleteTestLogRoute = { export const clearTestLogsRoute = { method: 'DELETE', path: '/api/gov/testbed/logs', + access: 'design', output: z.object({ deleted: z.number().int() }), } as const satisfies RouteContract; @@ -1067,6 +1198,7 @@ export const clearTestLogsRoute = { export const traceOfWorkflowRoute = { method: 'GET', path: '/api/gov/workflows/:id/trace', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ spans: z.array( @@ -1092,6 +1224,7 @@ export const traceOfWorkflowRoute = { export const govWorkflowRoute = { method: 'GET', path: '/api/gov/workflows/:id', + access: ['design', 'ops'], params: z.object({ id: z.string() }), output: z.object({ workflow: z.object({ @@ -1131,6 +1264,7 @@ export const govWorkflowRoute = { export const govMetricsRoute = { method: 'GET', path: '/api/gov/metrics', + access: 'ops', /** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */ query: z.object({ period: reportPeriodSchema.optional() }), /** `period` 原样回声:客户端不传时,由它知道服务端用的是哪个窗口 */ diff --git a/packages/contracts/src/api/http.ts b/packages/contracts/src/api/http.ts index 2f9528c..1e86c24 100644 --- a/packages/contracts/src/api/http.ts +++ b/packages/contracts/src/api/http.ts @@ -4,14 +4,35 @@ * 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。 * **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。 */ -import type { z } from 'zod'; +import { z } from 'zod'; export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; export type HttpMethod = (typeof HTTP_METHODS)[number]; +/** + * 三个端(ADR-012):设计中心 / 运营中心 / 工作台。 + * 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。 + */ +export const SURFACES = ['design', 'ops', 'workbench'] as const; +export const surfaceSchema = z.enum(SURFACES); +export type Surface = z.infer; + +export const SURFACE_LABEL: Readonly> = { + design: '设计中心', + ops: '运营中心', + workbench: '工作台', +}; + +/** + * 一条路由归哪个端(ADR-012): + * `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。 + * 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。 + */ +export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[]; + /** * 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。 - * body 只在 POST/PATCH 上有意义;GET 用 query。 + * body 只在 POST/PUT/PATCH 上有意义;GET 用 query。 */ export type RouteContract< TParams extends z.ZodTypeAny = z.ZodTypeAny, @@ -21,6 +42,7 @@ export type RouteContract< > = { readonly method: HttpMethod; readonly path: string; + readonly access: RouteAccess; readonly params?: TParams; readonly query?: TQuery; readonly body?: TBody; @@ -32,7 +54,7 @@ export type RouteBody = R['body'] extends z.ZodTypeAny ? z.infer : never; -/** 结构化错误:前端按 code 分辨,不解析文案 */ +/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ export const API_ERROR_CODES = [ 'unauthenticated', 'forbidden', @@ -41,6 +63,7 @@ export const API_ERROR_CODES = [ 'conflict', 'precondition-failed', 'internal', + 'gone', ] as const; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; diff --git a/packages/contracts/src/api/ingest.ts b/packages/contracts/src/api/ingest.ts index d320a44..3241557 100644 --- a/packages/contracts/src/api/ingest.ts +++ b/packages/contracts/src/api/ingest.ts @@ -28,6 +28,7 @@ export type InboundMail = z.infer; export const ingestMailRoute = { method: 'POST', path: '/api/ingest/channel/:channelId/mail', + access: 'public', params: z.object({ channelId: channelIdSchema }), body: inboundMailSchema, output: z.object({ diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts index 5261bfd..6bcc2f5 100644 --- a/packages/contracts/src/api/workbench.ts +++ b/packages/contracts/src/api/workbench.ts @@ -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')` 与 ``。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; 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; 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; 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; 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; 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; 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; export const staffSchema = z.object({ + /** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */ id: staffIdSchema, + /** = `preferred_username`,只作显示 —— 用户名允许改,别拿它当键 */ account: z.string().min(1), + /** = `name` claim,缺则回退 account */ name: z.string().min(1), + email: z.string().optional(), + /** 未指派 = 哨兵岗 `NONE / 未分配` 的 id */ positionId: positionIdSchema, + /** 只由名册同步置 `disabled`(Keycloak 停用或删除);有效 token 不改它 */ state: staffStateSchema.default('active'), + /** realm 角色快照(token 签发时;噪声角色已滤) */ + roles: z.array(z.string()).default([]), }); export type Staff = z.infer; -/* ── 岗位授权:客体三类 × 动作三种 ─────────────────────────────────── */ +/* ── 我是谁、能进哪几个端(ADR-012) ───────────────────────────────── */ +export const principalSchema = z.object({ + staff: staffSchema, + position: positionSchema, + roles: z.array(z.string()), + ends: z.array(surfaceSchema), +}); +export type Principal = z.infer; + +/** 角色 → 端:一行一个 realm 角色 */ +export const roleAccessSchema = z.object({ + role: z.string().min(1), + ends: z.array(surfaceSchema), +}); +export type RoleAccess = z.infer; + +/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */ +export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [ + z.object({ configured: z.literal(false), message: z.string() }), + z.object({ + configured: z.literal(true), + inserted: z.number().int().min(0), + updated: z.number().int().min(0), + disabled: z.number().int().min(0), + total: z.number().int().min(0), + }), +]); +export type RosterSyncOutcome = z.infer; + +/* ── 在途工作(只读;OS 02:改派先于停用,停用本身已归鉴权中心) ───── */ + +export const inFlightWorkSchema = z.object({ + staffId: staffIdSchema, + blocked: z.boolean(), + openTasks: z.array(z.object({ taskId: z.string(), title: z.string() })).default([]), + openCards: z.array(z.object({ cardId: z.string(), title: z.string() })).default([]), + openApprovals: z.array(z.object({ approvalId: z.string(), title: z.string() })).default([]), +}); +export type InFlightWork = z.infer; + +/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */ +export const disableCheckSchema = inFlightWorkSchema; +/** @deprecated 改用 `InFlightWork` */ +export type DisableCheck = InFlightWork; + +/* ── 岗位授权(已退役,ADR-012;类型留一轮给 web 编译,下个迭代删) ──── */ + +/** @deprecated 权限只到端,动作三分已退役 */ export const GRANT_ACTIONS = ['read', 'write', 'govern'] as const; +/** @deprecated */ export const grantActionSchema = z.enum(GRANT_ACTIONS); +/** @deprecated */ export type GrantAction = z.infer; - +/** @deprecated */ export const GRANT_ACTION_LABEL: Readonly> = { read: '读', write: '写', govern: '治理', }; -/** 治理台九功能 —— 授权客体之一 */ +/** @deprecated 治理台九功能不再是授权客体 */ export const GOVERNANCE_FUNCTIONS = [ 'task', 'staff', @@ -53,9 +111,11 @@ export const GOVERNANCE_FUNCTIONS = [ 'memory', 'system', ] as const; +/** @deprecated */ export const governanceFunctionSchema = z.enum(GOVERNANCE_FUNCTIONS); +/** @deprecated */ export type GovernanceFunction = z.infer; - +/** @deprecated */ export const GOVERNANCE_FUNCTION_LABEL: Readonly> = { task: '任务管理', staff: '员工管理', @@ -68,6 +128,7 @@ export const GOVERNANCE_FUNCTION_LABEL: Readonly; +/** @deprecated */ export const grantSchema = z.object({ positionId: positionIdSchema, object: grantObjectSchema, action: grantActionSchema, }); +/** @deprecated */ export type Grant = z.infer; -/* ── 停用前置检查(OS 02) ─────────────────────────────────────────── */ - -/** 员工停用前,其在途任务与待答卡须先改派完毕 */ -export const disableCheckSchema = 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 DisableCheck = z.infer; - -/* ── 会话 ─────────────────────────────────────────────────────────────── */ +/* ── 会话(已退役:身份在 token 里,ADR-012) ──────────────────────── */ +/** @deprecated 改用 `principalSchema` / `GET /api/me` */ export const sessionSchema = z.object({ staff: staffSchema, position: positionSchema, grants: z.array(grantSchema), }); +/** @deprecated 改用 `Principal` */ export type Session = z.infer; /* ── 工作组(ADR-011) ────────────────────────────────────────────────── */