M1: 底座 —— @contracts 首版 + event / ledger / org / llm

@contracts 首版(§8.1 九个文件):
- id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件
- card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿)
- contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类
- manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约

模块(L1):
- event   信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定
- ledger  schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查
- org     岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种
- llm     四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一

app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封

ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密
(ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误)

门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 00:59:14 +08:00
co-authored by Claude Opus 5
parent 7cd664af3a
commit 14d4fa046a
120 changed files with 15236 additions and 78 deletions
+137
View File
@@ -0,0 +1,137 @@
/**
* DI 装配 —— `app/` 是**唯一组装层**(编码规范 §5.2)。
* 只有这里能同时认识多个模块:new 出 adapter、注入 port、把用例接起来。
*
* 跨层协作一律经端口注入:`ledger` 要发数据事件 → 注入 `event` 的出口;
* `event` 的三级消费下游(trigger L7 / message L2)在其**上**,同样经端口注入。
*/
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import type { Db } from '@shared/db.ts';
import type { EventService, MessageFallback, TriggerMatcher } from '@modules/event';
import { createEventService, createEventStore } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
import type { OrgService, InFlightWorkProbe } from '@modules/org';
import {
createGrantRepository,
createOrgService,
createPositionRepository,
createStaffRepository,
} from '@modules/org';
import type { LlmService, ModelGateway } from '@modules/llm';
import {
createLlmInstanceRepository,
createLlmService,
createModelGateway,
createProviderInvoker,
createReplayStore,
} from '@modules/llm';
import type { AppConfig } from './config.ts';
import { createIdGenerator, systemClock } from './runtime.ts';
import { createSecretBox, loadSecretKey } from './secret-box.ts';
export type Container = {
readonly config: AppConfig;
readonly pool: Pool;
readonly db: Db;
readonly org: OrgService;
readonly ledger: LedgerService;
readonly event: EventService;
readonly llm: LlmService;
readonly modelGateway: ModelGateway;
close(): Promise<void>;
};
/**
* M1 的占位实现 —— 在途工作探针要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。
* 占位返回空集,停用检查因此在 M1 一律放行;M4 起接真实用例。
*/
const emptyInFlightProbe: InFlightWorkProbe = {
openTasksOf: async () => [],
openCardsOf: async () => [],
openApprovalsOf: async () => [],
};
/** M1 的占位实现 —— 触发器(L7)随 M5 落地,当下恒不命中 */
const noTriggerMatcher: TriggerMatcher = { match: async () => [] };
/** M1 的占位实现 —— 消息(L2)随 M2 落地;在此之前无主事件只落日志,不静默丢弃 */
function createConsoleMessageFallback(): MessageFallback {
return {
async deadLetter(envelope, error) {
process.stderr.write(`[死信] event=${envelope.id} type=${envelope.type} error=${error}\n`);
},
async unowned(envelope, fallbackPosition) {
process.stderr.write(
`[无主兜底] event=${envelope.id} type=${envelope.type} 认领岗位=${fallbackPosition ?? '未配置'}\n`,
);
},
};
}
/** S-FEEL 求值 —— @sfeel 随 M3 落地;M1 的条件监听一律按「无条件」处理 */
const passthroughEvaluator = (): boolean => true;
export async function createContainer(config: AppConfig): Promise<Container> {
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
const db = drizzle(pool);
const ids = createIdGenerator();
const secrets = createSecretBox(loadSecretKey());
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
await pool.query(DRAFT_DDL);
const event = createEventService({
store: createEventStore(db, ids),
clock: systemClock,
ids,
evaluate: passthroughEvaluator,
triggers: noTriggerMatcher,
messages: createConsoleMessageFallback(),
});
const ledger = createLedgerService({
store: createLedgerStore(pool),
drafts: createDraftStore(pool),
events: { publish: async (envelope) => void (await event.publish(envelope)) },
clock: systemClock,
ids,
});
const org = createOrgService({
positions: createPositionRepository(db),
staff: createStaffRepository(db),
grants: createGrantRepository(db),
inFlight: emptyInFlightProbe,
ids,
});
const instances = createLlmInstanceRepository(db);
const provider = createProviderInvoker();
const llm = createLlmService({ instances, provider, secrets, clock: systemClock, ids });
const modelGateway = createModelGateway({
instances,
provider,
replay: createReplayStore(db),
secrets,
clock: systemClock,
mode: 'live',
});
return {
config,
pool,
db,
org,
ledger,
event,
llm,
modelGateway,
async close() {
await pool.end();
},
};
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
*/
export type AppEnv = { Variables: { account: string } };
+17 -5
View File
@@ -1,16 +1,28 @@
/**
* apps/api 唯一组装层入口(编码规范 §5.2)。
* 只有 app/ 能同时认识多个模块:new adapter、注入 port、注册路由 / worker / Mastra 实例。
*
* M0:骨架 —— 门禁跑通即可,业务装配自 M1 起逐层填入。
*/
import { serve } from '@hono/node-server';
import { loadConfig } from './config.ts';
import { createContainer } from './container.ts';
import { createServer } from './server.ts';
export async function boot(): Promise<void> {
const config = loadConfig();
process.stdout.write(
`JCAI api · M0 骨架 · env=${config.nodeEnv} · port=${config.port} · db=${config.databaseUrl.replace(/:[^:@]*@/, ':***@')}\n`,
);
const container = await createContainer(config);
await container.org.ensureSeeded();
const app = createServer(container);
serve({ fetch: app.fetch, port: config.port }, (info) => {
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
});
const shutdown = async (): Promise<void> => {
await container.close();
process.exit(0);
};
process.on('SIGINT', () => void shutdown());
process.on('SIGTERM', () => void shutdown());
}
if (import.meta.url === `file://${process.argv[1]}`) {
+39
View File
@@ -0,0 +1,39 @@
/**
* sys schema 迁移执行器 —— drizzle-kit 生成 SQL,本文件应用(ADR-002:迁移随系统版本)。
*/
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.ts';
/** 从本文件位置向上找 db/migrations —— 源码跑与 dist 跑同一套逻辑 */
function findMigrationsFolder(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 8; i += 1) {
const candidate = join(dir, 'db', 'migrations');
if (existsSync(candidate)) return candidate;
const parent = resolve(dir, '..');
if (parent === dir) break;
dir = parent;
}
throw new Error('找不到 db/migrations —— 先跑 pnpm db:generate');
}
export async function runMigrations(): Promise<void> {
const config = loadConfig();
const pool = new Pool({ connectionString: config.databaseUrl, max: 2 });
try {
const db = drizzle(pool);
await migrate(db, { migrationsFolder: findMigrationsFolder(), migrationsSchema: 'sys' });
process.stdout.write('sys schema 迁移完成\n');
} finally {
await pool.end();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
await runMigrations();
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
*/
import type { Hono } from 'hono';
import type { ApiError, GovernanceFunction } from '@contracts';
import {
createStaffRoute,
disableStaffCheckRoute,
disableStaffRoute,
listGrantsRoute,
listLlmInstancesRoute,
listStaffRoute,
positionIdSchema,
probeLlmInstanceRoute,
setGrantsRoute,
staffIdSchema,
upsertLlmInstanceRoute,
} from '@contracts';
import { DisableBlockedError } from '@modules/org';
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');
/* ── 员工管理 ─────────────────────────────────────────────────────── */
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());
});
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());
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) });
} 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);
}
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) });
});
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());
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) });
});
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
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.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 };
return c.json(body, 400);
}
return c.json(await container.llm.upsert(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')));
});
}
+30
View File
@@ -0,0 +1,30 @@
/**
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
*/
import type { Hono } from 'hono';
import type { ApiError } from '@contracts';
import { CONTRACTS_VERSION, healthRoute, sessionRoute } from '@contracts';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
app.get(healthRoute.path, async (c) => {
let db: 'up' | 'down' = 'down';
try {
await container.pool.query('select 1');
db = 'up';
} catch {
db = 'down';
}
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
});
app.get(sessionRoute.path, async (c) => {
const session = await container.org.loadSession(c.get('account'));
if (session === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效或员工已停用' };
return c.json(body, 401);
}
return c.json(session);
});
}
+14
View File
@@ -0,0 +1,14 @@
/**
* 运行时基础件 —— 时钟与 ID 生成器。
* domain 零 `Date.now` / `Math.random`(§5.3),二者一律经 port 注入;实现在此。
*/
import { randomUUID } from 'node:crypto';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export const systemClock: Clock = { now: () => new Date() };
export function createIdGenerator(prefix = ''): IdGenerator {
return { next: () => `${prefix}${randomUUID()}` };
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 凭证密封 —— ADR-009。app 层持有主密钥,模块只见 `SecretBox` 端口。
*
* 密文格式:`v1.<iv>.<tag>.<cipher>`(均 base64url)。
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import type { SecretBox } from '@modules/llm';
const DEV_SALT = 'jcai-dev-only-not-for-production';
export function loadSecretKey(env: NodeJS.ProcessEnv = process.env): Buffer {
const raw = env['JCAI_SECRET_KEY'];
if (raw !== undefined && raw !== '') {
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) throw new Error('JCAI_SECRET_KEY 必须是 32 字节的 base64');
return key;
}
if (env['NODE_ENV'] === 'production') {
throw new Error('生产环境必须设置 JCAI_SECRET_KEY(ADR-009)');
}
process.stderr.write('⚠️ JCAI_SECRET_KEY 未设置,使用开发派生密钥 —— 切勿用于生产(ADR-009)\n');
return createHash('sha256').update(DEV_SALT).digest();
}
export function createSecretBox(key: Buffer): SecretBox {
return {
seal(plaintext) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), enc.toString('base64url')].join('.');
},
open(text) {
const [version, ivRaw, tagRaw, cipherRaw] = text.split('.');
if (version !== 'v1' || ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) {
throw new Error('密文格式非法');
}
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64url'));
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
return Buffer.concat([decipher.update(Buffer.from(cipherRaw, 'base64url')), decipher.final()]).toString('utf8');
},
};
}
+15 -42
View File
@@ -1,49 +1,22 @@
/**
* 种子数据与播种入口(编码规范 §15 · M0)。
*
* 数据是常量、写入是端口 —— M0 立骨架:数据在此冻结,写入实现随 M1 的 `org` 模块落地。
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
* 播种入口(编码规范 §15 · M0/M1)。
* 种子数据本体在 `org` 模块的 domain(它是组织主数据,归 org 管);此处只做装配与运行。
*/
import { loadConfig } from './config.ts';
import { createContainer } from './container.ts';
/** 岗位:编排层声明岗位,起单时按四策略解析出人(OS 03) */
export const POSITION_SEED = [
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
{ code: 'FAE', name: '现场应用', note: '替代选型' },
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
{ code: 'OP', name: '运营', note: '运营支持' },
] as const;
/** 员工五人;登录者 = 冯兵(BD) */
export const STAFF_SEED = [
{ account: 'dingziqiao', name: '丁子乔', position: 'PM' },
{ account: 'fengbing', name: '冯兵', position: 'BD' },
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
] as const;
/** 默认登录者(MVP 单组织 cookie session,§4 鉴权定调) */
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
export type PositionSeed = (typeof POSITION_SEED)[number];
export type StaffSeed = (typeof STAFF_SEED)[number];
/** 播种端口 —— 实现由 app/ 在 M1 注入(org 模块出口) */
export type OrgSeeder = {
upsertPosition(position: PositionSeed): Promise<void>;
upsertStaff(staff: StaffSeed): Promise<void>;
};
export async function runSeed(seeder: OrgSeeder): Promise<void> {
for (const position of POSITION_SEED) await seeder.upsertPosition(position);
for (const staff of STAFF_SEED) await seeder.upsertStaff(staff);
export async function seed(): Promise<void> {
const container = await createContainer(loadConfig());
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`);
} finally {
await container.close();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
process.stdout.write(
`种子数据:${POSITION_SEED.length} 个岗位 / ${STAFF_SEED.length} 名员工;` +
`登录者=${DEFAULT_LOGIN_ACCOUNT}\n` +
'写入实现随 M1 `org` 模块落地(docs/debt.md · M0-1)。\n',
);
await seed();
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
*/
import { Hono } from 'hono';
import type { ApiError } from '@contracts';
import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
import type { Container } from './container.ts';
import type { AppEnv } from './http-env.ts';
import { registerGovernanceRoutes } from './routes/governance.ts';
import { registerWorkbenchRoutes } from './routes/workbench.ts';
export type { AppEnv };
export function createServer(container: Container): Hono<AppEnv> {
const app = new Hono<AppEnv>();
// 单组织 cookie session(编码规范 §4 鉴权定调)—— MVP 单登录者,cookie 缺省即种子登录者
app.use('*', async (c, next) => {
const cookie = c.req.header('cookie') ?? '';
const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie);
c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT);
await next();
});
app.onError((error, c) => {
const body: ApiError = {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
};
process.stderr.write(`[api] ${body.message}\n`);
return c.json(body, 500);
});
app.notFound((c) => {
const body: ApiError = { code: 'not-found', message: `无此路由:${c.req.path}` };
return c.json(body, 404);
});
registerWorkbenchRoutes(app, container);
registerGovernanceRoutes(app, container);
return app;
}