Archived
统一鉴权中心接入(ADR-012):org 重写为 Keycloak 身份 + 角色→端;中间件按路由归属统一判;口令/会话/岗位授权退役;迁移 0032
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
@@ -80,12 +80,14 @@ beforeAll(async () => {
|
||||
await pool.query(`create schema "${schema}"`);
|
||||
}
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
container = await createContainer(
|
||||
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
|
||||
);
|
||||
await container.org.ensureSeeded();
|
||||
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
|
||||
await seedMvp(container);
|
||||
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;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -77,12 +77,14 @@ beforeAll(async () => {
|
||||
await pool.query(`create schema "${schema}"`);
|
||||
}
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
container = await createContainer(
|
||||
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
|
||||
);
|
||||
await container.org.ensureSeeded();
|
||||
await seedMvp(container);
|
||||
const { staff } = await container.org.listStaff();
|
||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
||||
ai = staff.find((s) => s.account === 'AI')!.id;
|
||||
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||
ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -88,12 +88,14 @@ beforeAll(async () => {
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
|
||||
// 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
container = await createContainer(
|
||||
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
|
||||
);
|
||||
await container.org.ensureSeeded();
|
||||
await seedMvp(container);
|
||||
|
||||
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;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
*/
|
||||
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;
|
||||
@@ -11,25 +23,52 @@ export type AppConfig = {
|
||||
readonly attachmentRoot: string;
|
||||
/** 派发器轮询间隔(毫秒) */
|
||||
readonly dispatchIntervalMs: number;
|
||||
/**
|
||||
* 播种口令(ADR-010)—— 新建员工的初始口令。
|
||||
* 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。
|
||||
*/
|
||||
readonly seedPassword: string;
|
||||
/** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */
|
||||
readonly keycloak?: KeycloakConfig;
|
||||
/** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */
|
||||
readonly testAccounts: readonly string[];
|
||||
};
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
|
||||
/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */
|
||||
const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026';
|
||||
|
||||
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),
|
||||
seedPassword: env['JCAI_SEED_PASSWORD'] ?? DEFAULT_SEED_PASSWORD,
|
||||
...(keycloak === undefined ? {} : { keycloak }),
|
||||
testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== ''),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,18 +16,20 @@ import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
||||
import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org';
|
||||
import {
|
||||
createGrantRepository,
|
||||
createKeycloakRoster,
|
||||
createKeycloakTokenVerifier,
|
||||
createOrgService,
|
||||
createPositionRepository,
|
||||
createSessionRepository,
|
||||
createRoleAccessRepository,
|
||||
createStaffRepository,
|
||||
createWorkGroupRepository,
|
||||
hashPassword,
|
||||
newSessionToken,
|
||||
verifyPassword,
|
||||
TokenRejectedError,
|
||||
} from '@modules/org';
|
||||
import { createRemoteJWKSet } from 'jose';
|
||||
import { createTicketIssuer } from './ticket.ts';
|
||||
import type { TicketIssuer } from './ticket.ts';
|
||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||
import {
|
||||
createLlmInstanceRepository,
|
||||
@@ -94,6 +96,8 @@ export type Container = {
|
||||
readonly pool: Pool;
|
||||
readonly db: Db;
|
||||
readonly org: OrgService;
|
||||
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
|
||||
readonly tickets: TicketIssuer;
|
||||
readonly ledger: LedgerService;
|
||||
readonly event: EventService;
|
||||
readonly llm: LlmService;
|
||||
@@ -171,7 +175,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
||||
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
|
||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
||||
|
||||
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> {
|
||||
/*
|
||||
* 广播器**先建** —— 装配期就要往它上面接线。
|
||||
*
|
||||
@@ -197,25 +207,60 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
*/
|
||||
const bridge = bridgeBroadcaster(pool, broadcaster);
|
||||
const ids = createIdGenerator();
|
||||
const secrets = createSecretBox(loadSecretKey());
|
||||
const masterKey = loadSecretKey();
|
||||
const secrets = createSecretBox(masterKey);
|
||||
const tickets = createTicketIssuer(masterKey);
|
||||
|
||||
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
||||
await pool.query(DRAFT_DDL);
|
||||
|
||||
const inFlightProbe = createDeferredInFlightProbe();
|
||||
|
||||
/*
|
||||
* 身份归统一鉴权中心(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),
|
||||
grants: createGrantRepository(db),
|
||||
sessions: createSessionRepository(db),
|
||||
roleAccess: createRoleAccessRepository(db),
|
||||
workGroups: createWorkGroupRepository(db),
|
||||
// 散列算法在 adapter,usecase 只认这三个动作(ADR-010)
|
||||
passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken },
|
||||
inFlight: inFlightProbe,
|
||||
clock: systemClock,
|
||||
seedPassword: config.seedPassword,
|
||||
ids,
|
||||
testAccounts: config.testAccounts,
|
||||
});
|
||||
|
||||
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
|
||||
@@ -226,16 +271,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
const { staff } = await org.listStaff();
|
||||
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
|
||||
},
|
||||
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 })),
|
||||
},
|
||||
clock: systemClock,
|
||||
ids,
|
||||
@@ -715,6 +752,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
tickets,
|
||||
ledger,
|
||||
event,
|
||||
llm,
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
|
||||
*/
|
||||
import type { Principal, Session } from '@contracts';
|
||||
import type { Context } from 'hono';
|
||||
import type { Position, Principal, Staff } from '@contracts';
|
||||
|
||||
/**
|
||||
* 请求上下文。`principal` 由鉴权中间件从 Bearer / 票据解出(ADR-012);
|
||||
* `account` / `session` 是 ADR-010 的旧物,随 Task 8 一并删除。
|
||||
* 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 ——
|
||||
* 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。
|
||||
*/
|
||||
export type AppEnv = {
|
||||
Variables: { account: string; session: Session | undefined; principal: Principal | undefined };
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Hono } from 'hono';
|
||||
import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
|
||||
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import { staffOf } from '../http-env.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */
|
||||
@@ -23,7 +24,7 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
|
||||
|
||||
/** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */
|
||||
app.get(listApprovalsForMeRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
@@ -72,7 +73,7 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
|
||||
});
|
||||
|
||||
app.post(decideApprovalRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -23,13 +23,10 @@ import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/
|
||||
import type { Report } from '@modules/report';
|
||||
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
||||
import type { Container } from '../container.ts';
|
||||
import { staffOf } from '../http-env.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
async function me(account: string) {
|
||||
return container.org.findByAccount(account);
|
||||
}
|
||||
|
||||
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
|
||||
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
||||
@@ -85,7 +82,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
});
|
||||
|
||||
app.post(createMinuteRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
@@ -104,7 +101,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
});
|
||||
|
||||
app.post(appendResolutionRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
@@ -129,7 +126,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
|
||||
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
||||
app.post(registerResolutionRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
@@ -167,14 +164,14 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listReportsRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const reports = await container.report.list(staff.id);
|
||||
return c.json({ reports: reports.map(toReportView) });
|
||||
});
|
||||
|
||||
app.get(draftReportRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
||||
if (!period.success) {
|
||||
@@ -186,7 +183,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
});
|
||||
|
||||
app.post(submitReportRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
||||
try {
|
||||
|
||||
@@ -41,6 +41,7 @@ function rowsOf<T>(result: unknown): T[] {
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
import { staffOf } from '../http-env.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
||||
import { orderChat } from '../chat-order.ts';
|
||||
@@ -846,12 +847,6 @@ export function gateOf(
|
||||
}
|
||||
|
||||
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/** 当前登录者;缺会话即 401 */
|
||||
async function me(account: string) {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
return staff;
|
||||
}
|
||||
|
||||
/** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */
|
||||
async function toCard(task: Awaited<ReturnType<Container['task']['get']>>): Promise<TaskCard> {
|
||||
let position = 1;
|
||||
@@ -949,7 +944,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
|
||||
app.get(listTasksRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -960,7 +955,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
app.get(taskDetailRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1192,7 +1187,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
||||
*/
|
||||
app.get(openAssetFileRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1298,7 +1293,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
app.post(saveTableRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1337,7 +1332,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
|
||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||
app.post(replyWorkOrderRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1431,7 +1426,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
||||
*/
|
||||
app.post(replyChatCardRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1556,7 +1551,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
||||
*/
|
||||
app.post(markCommReadRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1615,7 +1610,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
app.post(sayRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1630,7 +1625,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
app.get(listMessagesRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1686,7 +1681,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
|
||||
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
|
||||
app.post(confirmTriggerPendingRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1710,7 +1705,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
app.post(markMessageReadRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
@@ -1725,7 +1720,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
||||
*/
|
||||
app.post(answerMessageRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { extractText } from '../upload-extract.ts';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
||||
import type { ApiError } from '@contracts';
|
||||
import {
|
||||
browseLedgerRoute,
|
||||
controlTaskRoute,
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
VersionTakenError,
|
||||
} from '@modules/process';
|
||||
import type { Container } from '../container.ts';
|
||||
import { staffOf } from '../http-env.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
|
||||
@@ -366,46 +367,9 @@ async function assembleFromMail(
|
||||
}
|
||||
|
||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
||||
*
|
||||
* 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件;
|
||||
* PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。
|
||||
* 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。
|
||||
*/
|
||||
function guard(fn: GovernanceFunction, action: GrantAction) {
|
||||
return async (account: string): Promise<ApiError | undefined> => {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
||||
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action);
|
||||
if (ok) return undefined;
|
||||
const need = action === 'govern' ? '治理权' : '查看权';
|
||||
return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` };
|
||||
};
|
||||
}
|
||||
|
||||
const readTask = guard('task', 'read');
|
||||
const governTask = guard('task', 'govern');
|
||||
const readProcess = guard('process', 'read');
|
||||
const governProcess = guard('process', 'govern');
|
||||
const readTrigger = guard('trigger', 'read');
|
||||
const governTrigger = guard('trigger', 'govern');
|
||||
const readActivity = guard('activity', 'read');
|
||||
const governActivity = guard('activity', 'govern');
|
||||
const readTool = guard('tool', 'read');
|
||||
const governTool = guard('tool', 'govern');
|
||||
const readData = guard('data', 'read');
|
||||
const readMemory = guard('memory', 'read');
|
||||
|
||||
/** 拒绝的 HTTP 语义:无权 403,无会话 401 */
|
||||
const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);
|
||||
|
||||
/* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */
|
||||
|
||||
app.get(listGovTasksRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
||||
const rows = await Promise.all(
|
||||
@@ -438,9 +402,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
||||
app.post(controlTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success || staff === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
@@ -456,18 +418,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.post(resumeTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
|
||||
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
|
||||
return c.json({ ok: true, runtime: task.runtime });
|
||||
});
|
||||
|
||||
app.post(reassignTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const staff = staffOf(c);
|
||||
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success || staff === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
@@ -482,8 +440,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(govTaskLogRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||
return c.json({
|
||||
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
|
||||
@@ -502,9 +458,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
||||
*/
|
||||
app.get(listApprovalsRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
const perWorkflow = await Promise.all(
|
||||
workflows.map(async (w) => {
|
||||
@@ -551,14 +504,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
|
||||
*/
|
||||
app.post(testRunRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = testRunRoute.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);
|
||||
}
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
const { packageId, mail, assignments } = parsed.data;
|
||||
try {
|
||||
const data = await assembleFromMail(container, packageId, mail);
|
||||
@@ -592,8 +543,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。
|
||||
*/
|
||||
app.post(testStopRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = testStopRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
||||
@@ -620,8 +569,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
|
||||
app.get(testCurrentRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
/*
|
||||
* **在跑与跑完要分得开**(`live`)。
|
||||
*
|
||||
@@ -668,14 +615,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* `origin='testbed'`,好让人随时连根拔掉。
|
||||
*/
|
||||
app.post(testRunActivityRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = testRunActivityRoute.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);
|
||||
}
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
const { activityId, data, message, ownerId } = parsed.data;
|
||||
/*
|
||||
* 派给谁:显式指定优先,否则**派给操作者自己**。
|
||||
@@ -729,8 +674,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
||||
*/
|
||||
app.get(testActivityStateRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||
/*
|
||||
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
|
||||
@@ -776,16 +719,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
|
||||
|
||||
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
|
||||
const UPLOAD_CAP = 8 * 1024 * 1024;
|
||||
|
||||
app.post(uploadFileRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
||||
@@ -806,7 +745,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
}
|
||||
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
||||
const got = extractText(name, contentType, bytes);
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
const ref = crypto.randomUUID();
|
||||
await container.db.execute(sql`
|
||||
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
|
||||
@@ -823,8 +762,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(downloadFileRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>(
|
||||
sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`,
|
||||
);
|
||||
@@ -844,8 +781,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
|
||||
app.get(listTestLogsRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<TestLogDbRow>(sql`
|
||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||
jsonb_array_length(trail) as trail_count,
|
||||
@@ -857,8 +792,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(testLogDetailRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
|
||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||
jsonb_array_length(trail) as trail_count,
|
||||
@@ -875,8 +808,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.delete(deleteTestLogRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ id: string }>(
|
||||
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
|
||||
);
|
||||
@@ -884,25 +815,18 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.delete(clearTestLogsRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`);
|
||||
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
|
||||
});
|
||||
|
||||
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
||||
app.get(traceOfWorkflowRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const spans = await container.trace.ofWorkflow(c.req.param('id'));
|
||||
return c.json({ spans });
|
||||
});
|
||||
|
||||
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
||||
app.get(govWorkflowRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const workflow = await container.process.getWorkflow(c.req.param('id'));
|
||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||
if (pkg === undefined) {
|
||||
@@ -945,8 +869,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listProcessPackagesRoute.path, async (c) => {
|
||||
const denied = await readProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ packages: await container.process.listPackages() });
|
||||
});
|
||||
|
||||
@@ -955,8 +877,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
|
||||
*/
|
||||
app.put(saveProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = saveProcessRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||
@@ -986,8 +906,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
||||
/** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */
|
||||
app.delete(deleteProcessVersionRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
try {
|
||||
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
|
||||
return c.json({ packages });
|
||||
@@ -1004,14 +922,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
|
||||
|
||||
app.get(listApprovalTemplatesRoute.path, async (c) => {
|
||||
const denied = await readProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ templates: await container.process.approvalTemplates() });
|
||||
});
|
||||
|
||||
app.put(upsertApprovalTemplateRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' };
|
||||
@@ -1021,8 +935,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.delete(deleteApprovalTemplateRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
try {
|
||||
await container.process.deleteApprovalTemplate(c.req.param('id'));
|
||||
return c.json({ ok: true });
|
||||
@@ -1042,8 +954,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
|
||||
*/
|
||||
app.delete(deleteProcessFlowRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const id = c.req.param('id');
|
||||
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
|
||||
if (rules.length > 0) {
|
||||
@@ -1067,8 +977,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
|
||||
app.post(editProcessVersionRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = editProcessVersionRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
|
||||
@@ -1096,8 +1004,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.post(setProcessStateRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = setProcessStateRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
||||
@@ -1122,8 +1028,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/** 画图即校验 —— 与前端画布同源 */
|
||||
app.post(validateProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||
@@ -1135,14 +1039,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listTriggersRoute.path, async (c) => {
|
||||
const denied = await readTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ triggers: await container.trigger.list() });
|
||||
});
|
||||
|
||||
app.post(toggleTriggerRoute.path, async (c) => {
|
||||
const denied = await governTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
@@ -1158,8 +1058,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 设计动规则不碰这两项,运营动这两项不碰规则。
|
||||
*/
|
||||
app.post(setTriggerConfirmRoute.path, async (c) => {
|
||||
const denied = await governTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
@@ -1178,15 +1076,11 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */
|
||||
app.get(listNodeOwnersRoute.path, async (c) => {
|
||||
const denied = await readProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const owners = await container.process.nodeOwners(c.req.param('id'));
|
||||
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
|
||||
});
|
||||
|
||||
app.put(setNodeOwnerRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
@@ -1201,8 +1095,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
|
||||
*/
|
||||
app.put(upsertTriggerRoute.path, async (c) => {
|
||||
const denied = await governTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = upsertTriggerRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
|
||||
@@ -1219,8 +1111,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
|
||||
*/
|
||||
app.post(installActivityRoute.path, async (c) => {
|
||||
const denied = await governActivity(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = installActivityRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
|
||||
@@ -1259,8 +1149,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(listActivitiesRoute.path, async (c) => {
|
||||
const denied = await readActivity(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const cards = await container.registry.list();
|
||||
/*
|
||||
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
||||
@@ -1374,8 +1262,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
||||
*/
|
||||
app.post(registerToolRoute.path, async (c) => {
|
||||
const denied = await governTool(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = registerToolRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
||||
@@ -1397,8 +1283,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(listToolsRoute.path, async (c) => {
|
||||
const denied = await readTool(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const tools = await container.toolbox.list();
|
||||
return c.json({
|
||||
tools: tools.map((t) => ({
|
||||
@@ -1417,8 +1301,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
||||
|
||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
/*
|
||||
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
|
||||
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
|
||||
@@ -1438,9 +1320,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
||||
*/
|
||||
app.get(ledgerByActivityRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const [cards, materialized] = await Promise.all([
|
||||
container.registry.list(),
|
||||
container.ledger.listObjects(),
|
||||
@@ -1557,8 +1436,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(browseLedgerRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const address = { domain: c.req.param('domain'), object: c.req.param('object') };
|
||||
const described = await container.ledger.describe(address);
|
||||
if (described === undefined) {
|
||||
@@ -1595,8 +1472,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
|
||||
*/
|
||||
app.get(govMetricsRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = govMetricsRoute.query.safeParse(c.req.query());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues };
|
||||
@@ -1612,8 +1487,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
||||
|
||||
app.get(listChannelsRoute.path, async (c) => {
|
||||
const denied = await readTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const channels = await container.channel.list();
|
||||
return c.json({
|
||||
channels: channels.map((ch) => ({
|
||||
@@ -1632,8 +1505,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
});
|
||||
|
||||
app.get(listActivityMemoryRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
|
||||
return c.json({
|
||||
entries: entries.map((e) => ({
|
||||
|
||||
@@ -1,107 +1,108 @@
|
||||
/**
|
||||
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
||||
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
||||
* 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction } from '@contracts';
|
||||
import type { ApiError } from '@contracts';
|
||||
import {
|
||||
createStaffRoute,
|
||||
disableStaffCheckRoute,
|
||||
disableStaffRoute,
|
||||
listGrantsRoute,
|
||||
deleteLlmInstanceRoute,
|
||||
deleteRoleAccessRoute,
|
||||
listLlmInstancesRoute,
|
||||
listLlmProvidersRoute,
|
||||
listRoleAccessRoute,
|
||||
listStaffRoute,
|
||||
listWorkGroupsRoute,
|
||||
positionIdSchema,
|
||||
listLlmProvidersRoute,
|
||||
LLM_PROVIDERS,
|
||||
deleteLlmInstanceRoute,
|
||||
probeLlmDraftRoute,
|
||||
probeLlmInstanceRoute,
|
||||
saveWorkGroupRoute,
|
||||
setGrantsRoute,
|
||||
setRoleAccessRoute,
|
||||
setStaffPositionRoute,
|
||||
staffIdSchema,
|
||||
staffInFlightRoute,
|
||||
syncStaffRoute,
|
||||
upsertLlmInstanceRoute,
|
||||
} from '@contracts';
|
||||
import { DisableBlockedError } from '@modules/org';
|
||||
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';
|
||||
|
||||
/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */
|
||||
function requireGovern(container: Container, fn: GovernanceFunction) {
|
||||
return async (account: string): Promise<ApiError | undefined> => {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
||||
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern');
|
||||
return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` };
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
const guardStaff = requireGovern(container, 'staff');
|
||||
const guardSystem = requireGovern(container, 'system');
|
||||
/* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */
|
||||
|
||||
/* ── 员工管理 ─────────────────────────────────────────────────────── */
|
||||
app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff()));
|
||||
|
||||
app.get(listStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return 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.post(createStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = createStaffRoute.body.safeParse(await c.req.json());
|
||||
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);
|
||||
}
|
||||
return c.json(await container.org.createStaff(parsed.data));
|
||||
});
|
||||
|
||||
app.get(disableStaffCheckRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
return c.json(await container.org.checkDisable(staffId));
|
||||
});
|
||||
|
||||
app.post(disableStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
try {
|
||||
await container.org.disableStaff(staffId);
|
||||
return c.json({ ok: true, check: await container.org.checkDisable(staffId) });
|
||||
return c.json(await container.org.setPosition(staffId, parsed.data.positionId));
|
||||
} catch (error) {
|
||||
if (error instanceof DisableBlockedError) {
|
||||
// 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02)
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check };
|
||||
return c.json(body, 412);
|
||||
if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) {
|
||||
const body: ApiError = { code: 'not-found', message: error.message };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get(listGrantsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
||||
return c.json({ grants: await container.org.listGrants(positionId) });
|
||||
/** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 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;
|
||||
}
|
||||
});
|
||||
|
||||
app.post(setGrantsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
||||
const parsed = setGrantsRoute.body.safeParse(await c.req.json());
|
||||
/* ── 角色 → 端(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);
|
||||
}
|
||||
return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) });
|
||||
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) ──────────────────────────────────── */
|
||||
@@ -111,8 +112,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
|
||||
*/
|
||||
app.get(listWorkGroupsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const { staff } = await container.org.listStaff();
|
||||
const found = await Promise.all(
|
||||
staff.map(async (s) => {
|
||||
@@ -129,8 +128,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
* 改组**不影响在途**:装进工作流的是起单时的快照。
|
||||
*/
|
||||
app.put(saveWorkGroupRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
|
||||
@@ -149,10 +146,8 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
||||
|
||||
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
|
||||
app.get(listLlmProvidersRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json({
|
||||
app.get(listLlmProvidersRoute.path, (c) =>
|
||||
c.json({
|
||||
providers: LLM_PROVIDERS.map((provider) => {
|
||||
const profile = PROVIDER_PROFILES[provider];
|
||||
return {
|
||||
@@ -162,18 +157,12 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
structuredOutput: profile.structuredOutput,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(listLlmInstancesRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json({ instances: await container.llm.list() });
|
||||
});
|
||||
app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() }));
|
||||
|
||||
app.post(upsertLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
@@ -184,8 +173,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
|
||||
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
|
||||
app.delete(deleteLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const id = c.req.param('id');
|
||||
const target = (await container.llm.list()).find((i) => i.id === id);
|
||||
if (target === undefined) {
|
||||
@@ -208,8 +195,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
|
||||
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
||||
app.post(probeLlmDraftRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
@@ -218,9 +203,5 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
return c.json(await container.llm.probeDraft(parsed.data));
|
||||
});
|
||||
|
||||
app.post(probeLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json(await container.llm.probe(c.req.param('id')));
|
||||
});
|
||||
app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id'))));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import { staffOf } from '../http-env.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
@@ -17,7 +18,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
|
||||
|
||||
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
|
||||
app.get(listRegistrationTriggersRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
|
||||
const triggers = await container.trigger.list();
|
||||
@@ -38,7 +39,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
|
||||
});
|
||||
|
||||
app.post(fireRegistrationRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const me = staffOf(c);
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
||||
* 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。
|
||||
* 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { CONTRACTS_VERSION, healthRoute, loginRoute, logoutRoute, sessionRoute } from '@contracts';
|
||||
import { CONTRACTS_VERSION, authTicketRoute, healthRoute, meRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.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) => {
|
||||
@@ -21,44 +21,20 @@ export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container)
|
||||
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
|
||||
});
|
||||
|
||||
/** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */
|
||||
app.get(sessionRoute.path, (c) => {
|
||||
const session = c.get('session');
|
||||
if (session === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
return c.json(session);
|
||||
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);
|
||||
});
|
||||
|
||||
app.post(loginRoute.path, async (c) => {
|
||||
const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
const issued = await container.org.login(parsed.data.account, parsed.data.password);
|
||||
// 失败只有一句话:不区分账号不存在 / 已停用 / 口令错,不外泄账号是否存在
|
||||
if (issued === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '账号或口令不对' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
|
||||
const session = await container.org.resolveSession(issued.token);
|
||||
if (session === undefined) {
|
||||
const body: ApiError = { code: 'internal', message: '会话签发后立即失效' };
|
||||
return c.json(body, 500);
|
||||
}
|
||||
setSessionCookie(c, issued.token, new Date(issued.expiresAt));
|
||||
return c.json(session);
|
||||
});
|
||||
|
||||
app.post(logoutRoute.path, async (c) => {
|
||||
const token = c.req.header('cookie')?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))?.[1];
|
||||
// 服务端表先作废,再清 cookie —— 顺序反了会留下「cookie 没了但会话还活着」的窗口
|
||||
if (token !== undefined) await container.org.logout(token);
|
||||
clearSessionCookie(c);
|
||||
return c.json({ ok: true });
|
||||
/** 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() });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export async function seed(): Promise<void> {
|
||||
try {
|
||||
await container.org.ensureSeeded();
|
||||
const { staff, positions } = await container.org.listStaff();
|
||||
process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`);
|
||||
for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`);
|
||||
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`);
|
||||
|
||||
// MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条)
|
||||
process.stdout.write('\n── 治理台配置 ──\n');
|
||||
|
||||
+51
-31
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
||||
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
||||
* 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { healthRoute, ingestMailRoute, loginRoute } from '@contracts';
|
||||
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 { readSessionToken } from './session-cookie.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';
|
||||
@@ -20,41 +33,41 @@ import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
export type { AppEnv };
|
||||
|
||||
/**
|
||||
* 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。
|
||||
* 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。
|
||||
*/
|
||||
const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':'));
|
||||
/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */
|
||||
export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }];
|
||||
|
||||
function isPublic(method: string, path: string): boolean {
|
||||
if (method === 'GET' && path === healthRoute.path) return true;
|
||||
if (method === 'POST' && path === loginRoute.path) return true;
|
||||
if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true;
|
||||
return false;
|
||||
/** 全部路由的归属(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-010)。
|
||||
*
|
||||
* 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来,
|
||||
* 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。
|
||||
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。
|
||||
* 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判,
|
||||
* 路由里不再各写守卫 —— 一处拦住,新路由自动生效。
|
||||
*/
|
||||
app.use('*', async (c, next) => {
|
||||
const token = readSessionToken(c.req.header('cookie'));
|
||||
const session = token === undefined ? undefined : await container.org.resolveSession(token);
|
||||
c.set('session', session);
|
||||
c.set('account', session?.staff.account ?? '');
|
||||
|
||||
if (session === undefined && !isPublic(c.req.method, c.req.path)) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
await next();
|
||||
return undefined;
|
||||
});
|
||||
app.use(
|
||||
'*',
|
||||
createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }),
|
||||
);
|
||||
|
||||
/**
|
||||
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
|
||||
@@ -92,6 +105,13 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
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);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 会话 cookie 的读写(ADR-010)—— 只此一处拼 `Set-Cookie`,属性不许各写各的。
|
||||
*
|
||||
* `HttpOnly`:脚本读不到 token,XSS 拿不走会话。
|
||||
* `SameSite=Lax`:跨站表单 POST 不带 cookie,挡掉 CSRF 的常见形态;
|
||||
* 同站导航仍带,登录后跳转不受影响。
|
||||
* 开发期走 http://localhost,故 `Secure` 随部署环境开关(生产必开)。
|
||||
*/
|
||||
import type { Context } from 'hono';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
|
||||
export const SESSION_COOKIE = 'jcai_session';
|
||||
|
||||
/** 生产环境必带 Secure;开发期 localhost 是 http,带了浏览器就不存 */
|
||||
function secureAttr(): string {
|
||||
return process.env['NODE_ENV'] === 'production' ? '; Secure' : '';
|
||||
}
|
||||
|
||||
export function readSessionToken(cookieHeader: string | undefined): string | undefined {
|
||||
if (cookieHeader === undefined) return undefined;
|
||||
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookieHeader);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function setSessionCookie(c: Context<AppEnv>, token: string, expiresAt: Date): void {
|
||||
c.header(
|
||||
'Set-Cookie',
|
||||
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Expires=${expiresAt.toUTCString()}`,
|
||||
{ append: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function clearSessionCookie(c: Context<AppEnv>): void {
|
||||
c.header(
|
||||
'Set-Cookie',
|
||||
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`,
|
||||
{ append: true },
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 改密 CLI(ADR-010)—— 治理台的改密界面还没做,先给运维一条路。
|
||||
*
|
||||
* pnpm --filter @app/api exec node --experimental-strip-types src/app/set-password.ts <账号> <新口令>
|
||||
*
|
||||
* 口令从**命令行参数**取,不写进任何文件;落库前即散列,明文只在进程内存里停留一瞬。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
|
||||
export async function setPassword(account: string, password: string): Promise<void> {
|
||||
const container = await createContainer(loadConfig());
|
||||
try {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) throw new Error(`账号不存在:${account}`);
|
||||
await container.org.setPassword(staff.id, password);
|
||||
process.stdout.write(`已改密:${staff.name}(${account})\n`);
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const [account, password] = process.argv.slice(2);
|
||||
if (account === undefined || password === undefined) {
|
||||
process.stderr.write('用法:set-password.ts <账号> <新口令>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
await setPassword(account, password);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { renderView } from '@modules/interaction';
|
||||
import type { InteractionService } from '@modules/interaction';
|
||||
import type { MessageService } from '@modules/message';
|
||||
import type { OrgService } from '@modules/org';
|
||||
import { NONE_POSITION_CODE } from '@modules/org';
|
||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||
import type { ActivityRegistryService } from '@modules/activity-registry';
|
||||
import type { SkillEngineService } from '@modules/skill-engine';
|
||||
@@ -2453,7 +2454,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
},
|
||||
org: {
|
||||
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),
|
||||
),
|
||||
candidates: async (position) => {
|
||||
const staff = await deps.org.staffOfPosition(position);
|
||||
const withLoad = await Promise.all(
|
||||
@@ -2614,10 +2619,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
|
||||
report: async (input) => {
|
||||
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
||||
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;
|
||||
// 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端)
|
||||
for (const person of await deps.org.staffWithEnd('ops')) {
|
||||
await deps.message.notify({
|
||||
staffId: person.id,
|
||||
title: `触发器起单失败:${input.triggerId}`,
|
||||
|
||||
Reference in New Issue
Block a user