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:
+56
-69
@@ -1,79 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
||||||
|
*
|
||||||
|
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
|
||||||
|
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
|
||||||
|
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
|
||||||
*/
|
*/
|
||||||
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
|
import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
|
||||||
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
|
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||||
export type StaffWithPosition = Staff & {
|
export type StaffWithPosition = Staff & {
|
||||||
readonly position: Position;
|
readonly position: Position;
|
||||||
};
|
};
|
||||||
export type CreateStaffInput = {
|
|
||||||
readonly account: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly positionId: string;
|
|
||||||
};
|
|
||||||
export declare class StaffNotFoundError extends Error {
|
|
||||||
readonly staffId: StaffId;
|
|
||||||
constructor(staffId: StaffId);
|
|
||||||
}
|
|
||||||
export declare class PositionNotFoundError extends Error {
|
|
||||||
readonly positionCode: string;
|
|
||||||
constructor(positionCode: string);
|
|
||||||
}
|
|
||||||
export declare class AccountTakenError extends Error {
|
|
||||||
readonly account: string;
|
|
||||||
constructor(account: string);
|
|
||||||
}
|
|
||||||
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
|
|
||||||
export declare class DisableBlockedError extends Error {
|
|
||||||
readonly check: DisableCheck;
|
|
||||||
constructor(check: DisableCheck);
|
|
||||||
}
|
|
||||||
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
|
|
||||||
export type IssuedSession = {
|
|
||||||
readonly token: string;
|
|
||||||
readonly expiresAt: string;
|
|
||||||
};
|
|
||||||
export type OrgService = {
|
|
||||||
listStaff(): Promise<{
|
|
||||||
staff: StaffWithPosition[];
|
|
||||||
positions: Position[];
|
|
||||||
}>;
|
|
||||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
|
||||||
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
|
|
||||||
createStaff(input: CreateStaffInput): Promise<Staff>;
|
|
||||||
/** 停用前置检查 —— 只读,治理台先看再点 */
|
|
||||||
checkDisable(staffId: StaffId): Promise<DisableCheck>;
|
|
||||||
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
|
|
||||||
disableStaff(staffId: StaffId): Promise<void>;
|
|
||||||
listGrants(positionId: string): Promise<Grant[]>;
|
|
||||||
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
|
|
||||||
/** 岗位权限校验 —— app 层鉴权中间件读它 */
|
|
||||||
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
|
|
||||||
loadSession(account: string): Promise<Session | undefined>;
|
|
||||||
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
|
|
||||||
login(account: string, password: string): Promise<IssuedSession | undefined>;
|
|
||||||
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
|
|
||||||
logout(token: string): Promise<void>;
|
|
||||||
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
|
|
||||||
resolveSession(token: string): Promise<Session | undefined>;
|
|
||||||
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
|
|
||||||
setPassword(staffId: StaffId, password: string): Promise<void>;
|
|
||||||
/** 建组 / 改组;id 留空即新建 */
|
|
||||||
saveWorkGroup(input: {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
ownerId: StaffId;
|
|
||||||
members: Readonly<Record<string, StaffId>>;
|
|
||||||
}): Promise<WorkGroup>;
|
|
||||||
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
|
||||||
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
|
||||||
/** 装进工作流的快照:改组不改已起的单 */
|
|
||||||
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
|
||||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
|
|
||||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
|
||||||
ensureSeeded(): Promise<void>;
|
|
||||||
};
|
|
||||||
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
||||||
export type Identity = {
|
export type Identity = {
|
||||||
readonly sub: string;
|
readonly sub: string;
|
||||||
@@ -85,6 +22,14 @@ export type Identity = {
|
|||||||
/** realm 角色,噪声角色已滤、已排序 */
|
/** realm 角色,噪声角色已滤、已排序 */
|
||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
};
|
};
|
||||||
|
export declare class StaffNotFoundError extends Error {
|
||||||
|
readonly staffId: StaffId;
|
||||||
|
constructor(staffId: StaffId);
|
||||||
|
}
|
||||||
|
export declare class PositionNotFoundError extends Error {
|
||||||
|
readonly positionCode: string;
|
||||||
|
constructor(positionCode: string);
|
||||||
|
}
|
||||||
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
|
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
|
||||||
export declare class TokenRejectedError extends Error {
|
export declare class TokenRejectedError extends Error {
|
||||||
constructor(message: string);
|
constructor(message: string);
|
||||||
@@ -98,3 +43,45 @@ export declare class AccountDisabledError extends Error {
|
|||||||
export declare class AccessLockoutError extends Error {
|
export declare class AccessLockoutError extends Error {
|
||||||
constructor();
|
constructor();
|
||||||
}
|
}
|
||||||
|
export type OrgService = {
|
||||||
|
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
||||||
|
authenticate(token: string): Promise<Principal>;
|
||||||
|
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
|
||||||
|
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||||||
|
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
|
||||||
|
registerIdentity(identity: Identity): Promise<Staff>;
|
||||||
|
listStaff(): Promise<{
|
||||||
|
staff: StaffWithPosition[];
|
||||||
|
positions: Position[];
|
||||||
|
}>;
|
||||||
|
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||||
|
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
|
||||||
|
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||||
|
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
||||||
|
/** Admin API 同步;未配 provisioning 回 configured:false */
|
||||||
|
syncRoster(): Promise<RosterSyncOutcome>;
|
||||||
|
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
|
||||||
|
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
||||||
|
listRoleAccess(): Promise<{
|
||||||
|
roles: RoleAccess[];
|
||||||
|
knownRoles: string[];
|
||||||
|
}>;
|
||||||
|
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
|
||||||
|
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
|
||||||
|
deleteRoleAccess(role: string): Promise<void>;
|
||||||
|
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||||||
|
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||||||
|
/** 建组 / 改组;id 留空即新建 */
|
||||||
|
saveWorkGroup(input: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: StaffId;
|
||||||
|
members: Readonly<Record<string, StaffId>>;
|
||||||
|
}): Promise<WorkGroup>;
|
||||||
|
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
||||||
|
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
||||||
|
/** 装进工作流的快照:改组不改已起的单 */
|
||||||
|
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
||||||
|
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
|
||||||
|
ensureSeeded(): Promise<void>;
|
||||||
|
};
|
||||||
|
|||||||
+6
-12
@@ -1,24 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
*/
|
*/
|
||||||
export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, IssuedSession, OrgService, Position, Session, Staff, StaffWithPosition, WorkGroup, WorkGroupBinding, } from './contract.ts';
|
export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
|
||||||
export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts';
|
export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
|
||||||
export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository, } from './port.ts';
|
export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts';
|
||||||
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
||||||
export { createOrgService } from './internal/usecase/org-service.ts';
|
export { createOrgService } from './internal/usecase/org-service.ts';
|
||||||
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
|
|
||||||
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
|
|
||||||
export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts';
|
|
||||||
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
|
|
||||||
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
|
|
||||||
export type { Identity } from './contract.ts';
|
|
||||||
export { AccessLockoutError, AccountDisabledError, TokenRejectedError } from './contract.ts';
|
|
||||||
export type { IdentityPatch, RosterSource, RosterUser, TokenVerifier, VerifiedClaims } from './port.ts';
|
|
||||||
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
||||||
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
||||||
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
||||||
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||||
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
export { createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts';
|
||||||
|
export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
|
||||||
|
|||||||
+58
-74
@@ -2,84 +2,11 @@
|
|||||||
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||||
*/
|
*/
|
||||||
import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||||
import type { Identity } from './contract.ts';
|
import type { Identity } from './contract.ts';
|
||||||
export type Clock = {
|
export type Clock = {
|
||||||
now(): Date;
|
now(): Date;
|
||||||
};
|
};
|
||||||
export type PositionRepository = {
|
|
||||||
list(): Promise<Position[]>;
|
|
||||||
findByCode(code: string): Promise<Position | undefined>;
|
|
||||||
findById(id: PositionId): Promise<Position | undefined>;
|
|
||||||
upsert(position: Omit<Position, 'id'> & {
|
|
||||||
id?: PositionId;
|
|
||||||
}): Promise<Position>;
|
|
||||||
};
|
|
||||||
export type StaffRepository = {
|
|
||||||
list(): Promise<Staff[]>;
|
|
||||||
findById(id: StaffId): Promise<Staff | undefined>;
|
|
||||||
findByAccount(account: string): Promise<Staff | undefined>;
|
|
||||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
|
||||||
insert(staff: Staff): Promise<Staff>;
|
|
||||||
setState(id: StaffId, state: Staff['state']): Promise<void>;
|
|
||||||
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
|
|
||||||
passwordHashOf(account: string): Promise<string | undefined>;
|
|
||||||
setPasswordHash(id: StaffId, hash: string): Promise<void>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
|
|
||||||
* token 即主键,不透明随机串,不编码任何身份信息。
|
|
||||||
*/
|
|
||||||
export type SessionRepository = {
|
|
||||||
issue(input: {
|
|
||||||
token: string;
|
|
||||||
staffId: StaffId;
|
|
||||||
expiresAt: Date;
|
|
||||||
}): Promise<void>;
|
|
||||||
/** 未过期才返回;命中同时滑动续期 */
|
|
||||||
touch(token: string, now: Date, expiresAt: Date): Promise<{
|
|
||||||
staffId: StaffId;
|
|
||||||
} | undefined>;
|
|
||||||
revoke(token: string): Promise<void>;
|
|
||||||
revokeAllOf(staffId: StaffId): Promise<void>;
|
|
||||||
};
|
|
||||||
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
|
|
||||||
export type PasswordHasher = {
|
|
||||||
hash(password: string): Promise<string>;
|
|
||||||
verify(password: string, stored: string | undefined): Promise<boolean>;
|
|
||||||
newToken(): string;
|
|
||||||
};
|
|
||||||
export type GrantRepository = {
|
|
||||||
listByPosition(positionId: PositionId): Promise<Grant[]>;
|
|
||||||
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
|
|
||||||
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
|
||||||
*/
|
|
||||||
export type InFlightWorkProbe = {
|
|
||||||
openTasksOf(staffId: StaffId): Promise<Array<{
|
|
||||||
taskId: string;
|
|
||||||
title: string;
|
|
||||||
}>>;
|
|
||||||
openCardsOf(staffId: StaffId): Promise<Array<{
|
|
||||||
cardId: string;
|
|
||||||
title: string;
|
|
||||||
}>>;
|
|
||||||
openApprovalsOf(staffId: StaffId): Promise<Array<{
|
|
||||||
approvalId: string;
|
|
||||||
title: string;
|
|
||||||
}>>;
|
|
||||||
};
|
|
||||||
/** 工作组仓储(ADR-011) */
|
|
||||||
export type WorkGroupRepository = {
|
|
||||||
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
|
|
||||||
upsert(group: WorkGroup): Promise<WorkGroup>;
|
|
||||||
};
|
|
||||||
/** ID 生成 —— domain 零随机(§5.3) */
|
|
||||||
export type IdGenerator = {
|
|
||||||
next(): string;
|
|
||||||
};
|
|
||||||
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
|
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
|
||||||
export type VerifiedClaims = {
|
export type VerifiedClaims = {
|
||||||
readonly sub: string;
|
readonly sub: string;
|
||||||
@@ -109,3 +36,60 @@ export type IdentityPatch = {
|
|||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
readonly lastSeenAt?: Date;
|
readonly lastSeenAt?: Date;
|
||||||
};
|
};
|
||||||
|
export type PositionRepository = {
|
||||||
|
list(): Promise<Position[]>;
|
||||||
|
findByCode(code: string): Promise<Position | undefined>;
|
||||||
|
findById(id: PositionId): Promise<Position | undefined>;
|
||||||
|
upsert(position: Omit<Position, 'id'> & {
|
||||||
|
id?: PositionId;
|
||||||
|
}): Promise<Position>;
|
||||||
|
};
|
||||||
|
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||||
|
export type StaffRecord = Staff & {
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
readonly syncedAt?: Date;
|
||||||
|
};
|
||||||
|
export type StaffRepository = {
|
||||||
|
list(): Promise<StaffRecord[]>;
|
||||||
|
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||||||
|
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||||
|
insert(staff: Staff, marks: {
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
readonly syncedAt?: Date;
|
||||||
|
}): Promise<StaffRecord>;
|
||||||
|
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||||||
|
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||||||
|
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||||||
|
};
|
||||||
|
export type RoleAccessRepository = {
|
||||||
|
list(): Promise<RoleAccess[]>;
|
||||||
|
upsert(row: RoleAccess): Promise<RoleAccess>;
|
||||||
|
remove(role: string): Promise<void>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
|
||||||
|
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
||||||
|
*/
|
||||||
|
export type InFlightWorkProbe = {
|
||||||
|
openTasksOf(staffId: StaffId): Promise<Array<{
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
}>>;
|
||||||
|
openCardsOf(staffId: StaffId): Promise<Array<{
|
||||||
|
cardId: string;
|
||||||
|
title: string;
|
||||||
|
}>>;
|
||||||
|
openApprovalsOf(staffId: StaffId): Promise<Array<{
|
||||||
|
approvalId: string;
|
||||||
|
title: string;
|
||||||
|
}>>;
|
||||||
|
};
|
||||||
|
/** 工作组仓储(ADR-011) */
|
||||||
|
export type WorkGroupRepository = {
|
||||||
|
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
|
||||||
|
upsert(group: WorkGroup): Promise<WorkGroup>;
|
||||||
|
};
|
||||||
|
/** ID 生成 —— domain 零随机(§5.3) */
|
||||||
|
export type IdGenerator = {
|
||||||
|
next(): string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"seed": "node dist/seed.js",
|
"seed": "node dist/seed.js",
|
||||||
"migrate": "node dist/migrate.js",
|
"migrate": "node dist/migrate.js",
|
||||||
"set-password": "node dist/set-password.js",
|
|
||||||
"reset-runs": "node dist/reset-runs.js",
|
"reset-runs": "node dist/reset-runs.js",
|
||||||
"install-activities": "node dist/install-activities.js"
|
"install-activities": "node dist/install-activities.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,12 +80,14 @@ beforeAll(async () => {
|
|||||||
await pool.query(`create schema "${schema}"`);
|
await pool.query(`create schema "${schema}"`);
|
||||||
}
|
}
|
||||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
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 container.org.ensureSeeded();
|
||||||
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
|
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
|
||||||
await seedMvp(container);
|
await seedMvp(container);
|
||||||
const { staff } = await container.org.listStaff();
|
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -77,12 +77,14 @@ beforeAll(async () => {
|
|||||||
await pool.query(`create schema "${schema}"`);
|
await pool.query(`create schema "${schema}"`);
|
||||||
}
|
}
|
||||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
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 container.org.ensureSeeded();
|
||||||
await seedMvp(container);
|
await seedMvp(container);
|
||||||
const { staff } = await container.org.listStaff();
|
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||||
ai = staff.find((s) => s.account === 'AI')!.id;
|
ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id;
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -88,12 +88,14 @@ beforeAll(async () => {
|
|||||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||||
|
|
||||||
// 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env
|
// 配置指向测试库 —— 经 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 container.org.ensureSeeded();
|
||||||
await seedMvp(container);
|
await seedMvp(container);
|
||||||
|
|
||||||
const { staff } = await container.org.listStaff();
|
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -3,6 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
import { join } from 'node:path';
|
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 = {
|
export type AppConfig = {
|
||||||
readonly nodeEnv: 'development' | 'test' | 'production';
|
readonly nodeEnv: 'development' | 'test' | 'production';
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
@@ -11,25 +23,52 @@ export type AppConfig = {
|
|||||||
readonly attachmentRoot: string;
|
readonly attachmentRoot: string;
|
||||||
/** 派发器轮询间隔(毫秒) */
|
/** 派发器轮询间隔(毫秒) */
|
||||||
readonly dispatchIntervalMs: number;
|
readonly dispatchIntervalMs: number;
|
||||||
/**
|
/** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */
|
||||||
* 播种口令(ADR-010)—— 新建员工的初始口令。
|
readonly keycloak?: KeycloakConfig;
|
||||||
* 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。
|
/** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */
|
||||||
*/
|
readonly testAccounts: readonly string[];
|
||||||
readonly seedPassword: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
|
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 {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const nodeEnv = env['NODE_ENV'];
|
const nodeEnv = env['NODE_ENV'];
|
||||||
|
const keycloak = keycloakOf(env);
|
||||||
return {
|
return {
|
||||||
nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development',
|
nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development',
|
||||||
port: Number(env['PORT'] ?? 8787),
|
port: Number(env['PORT'] ?? 8787),
|
||||||
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
|
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
|
||||||
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
|
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
|
||||||
dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000),
|
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 { LedgerService } from '@modules/ledger';
|
||||||
import type { LoopEngineService } from '@modules/loop-engine';
|
import type { LoopEngineService } from '@modules/loop-engine';
|
||||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
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 {
|
import {
|
||||||
createGrantRepository,
|
createKeycloakRoster,
|
||||||
|
createKeycloakTokenVerifier,
|
||||||
createOrgService,
|
createOrgService,
|
||||||
createPositionRepository,
|
createPositionRepository,
|
||||||
createSessionRepository,
|
createRoleAccessRepository,
|
||||||
createStaffRepository,
|
createStaffRepository,
|
||||||
createWorkGroupRepository,
|
createWorkGroupRepository,
|
||||||
hashPassword,
|
TokenRejectedError,
|
||||||
newSessionToken,
|
|
||||||
verifyPassword,
|
|
||||||
} from '@modules/org';
|
} 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 type { LlmService, ModelGateway } from '@modules/llm';
|
||||||
import {
|
import {
|
||||||
createLlmInstanceRepository,
|
createLlmInstanceRepository,
|
||||||
@@ -94,6 +96,8 @@ export type Container = {
|
|||||||
readonly pool: Pool;
|
readonly pool: Pool;
|
||||||
readonly db: Db;
|
readonly db: Db;
|
||||||
readonly org: OrgService;
|
readonly org: OrgService;
|
||||||
|
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
|
||||||
|
readonly tickets: TicketIssuer;
|
||||||
readonly ledger: LedgerService;
|
readonly ledger: LedgerService;
|
||||||
readonly event: EventService;
|
readonly event: EventService;
|
||||||
readonly llm: LlmService;
|
readonly llm: LlmService;
|
||||||
@@ -171,7 +175,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
|||||||
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
|
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
|
||||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
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 bridge = bridgeBroadcaster(pool, broadcaster);
|
||||||
const ids = createIdGenerator();
|
const ids = createIdGenerator();
|
||||||
const secrets = createSecretBox(loadSecretKey());
|
const masterKey = loadSecretKey();
|
||||||
|
const secrets = createSecretBox(masterKey);
|
||||||
|
const tickets = createTicketIssuer(masterKey);
|
||||||
|
|
||||||
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
||||||
await pool.query(DRAFT_DDL);
|
await pool.query(DRAFT_DDL);
|
||||||
|
|
||||||
const inFlightProbe = createDeferredInFlightProbe();
|
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({
|
const org = createOrgService({
|
||||||
|
verifier,
|
||||||
|
...(roster === undefined ? {} : { roster }),
|
||||||
positions: createPositionRepository(db),
|
positions: createPositionRepository(db),
|
||||||
staff: createStaffRepository(db),
|
staff: createStaffRepository(db),
|
||||||
grants: createGrantRepository(db),
|
roleAccess: createRoleAccessRepository(db),
|
||||||
sessions: createSessionRepository(db),
|
|
||||||
workGroups: createWorkGroupRepository(db),
|
workGroups: createWorkGroupRepository(db),
|
||||||
// 散列算法在 adapter,usecase 只认这三个动作(ADR-010)
|
|
||||||
passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken },
|
|
||||||
inFlight: inFlightProbe,
|
inFlight: inFlightProbe,
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
seedPassword: config.seedPassword,
|
|
||||||
ids,
|
ids,
|
||||||
|
testAccounts: config.testAccounts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
|
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
|
||||||
@@ -226,16 +271,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
const { staff } = await org.listStaff();
|
const { staff } = await org.listStaff();
|
||||||
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
|
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
|
||||||
},
|
},
|
||||||
governanceStaff: async () => {
|
// 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端)
|
||||||
const { staff } = await org.listStaff();
|
governanceStaff: async () => (await org.staffWithEnd('ops')).map((s) => ({ id: s.id })),
|
||||||
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 }));
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
ids,
|
ids,
|
||||||
@@ -715,6 +752,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
pool,
|
pool,
|
||||||
db,
|
db,
|
||||||
org,
|
org,
|
||||||
|
tickets,
|
||||||
ledger,
|
ledger,
|
||||||
event,
|
event,
|
||||||
llm,
|
llm,
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
|
* 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);
|
* 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 ——
|
||||||
* `account` / `session` 是 ADR-010 的旧物,随 Task 8 一并删除。
|
* 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。
|
||||||
*/
|
*/
|
||||||
export type AppEnv = {
|
export type AppEnv = { Variables: { principal: Principal | undefined } };
|
||||||
Variables: { account: string; session: Session | undefined; 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 type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
|
||||||
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } 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) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
|
|
||||||
const workflows = await container.process.listWorkflows(50);
|
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) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -23,13 +23,10 @@ import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/
|
|||||||
import type { Report } from '@modules/report';
|
import type { Report } from '@modules/report';
|
||||||
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
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: '会话无效' };
|
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
|
|
||||||
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
||||||
@@ -85,7 +82,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(createMinuteRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
|
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -104,7 +101,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(appendResolutionRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -129,7 +126,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
|
|
||||||
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
||||||
app.post(registerResolutionRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -167,14 +164,14 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
app.get(listReportsRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const reports = await container.report.list(staff.id);
|
const reports = await container.report.list(staff.id);
|
||||||
return c.json({ reports: reports.map(toReportView) });
|
return c.json({ reports: reports.map(toReportView) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(draftReportRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
||||||
if (!period.success) {
|
if (!period.success) {
|
||||||
@@ -186,7 +183,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(submitReportRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function rowsOf<T>(result: unknown): T[] {
|
|||||||
const rows = (result as { rows?: unknown }).rows;
|
const rows = (result as { rows?: unknown }).rows;
|
||||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||||
}
|
}
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
||||||
import { orderChat } from '../chat-order.ts';
|
import { orderChat } from '../chat-order.ts';
|
||||||
@@ -846,12 +847,6 @@ export function gateOf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
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> {
|
async function toCard(task: Awaited<ReturnType<Container['task']['get']>>): Promise<TaskCard> {
|
||||||
let position = 1;
|
let position = 1;
|
||||||
@@ -949,7 +944,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get(listTasksRoute.path, async (c) => {
|
app.get(listTasksRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -960,7 +955,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(taskDetailRoute.path, async (c) => {
|
app.get(taskDetailRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1192,7 +1187,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
||||||
*/
|
*/
|
||||||
app.get(openAssetFileRoute.path, async (c) => {
|
app.get(openAssetFileRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1298,7 +1293,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(saveTableRoute.path, async (c) => {
|
app.post(saveTableRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1337,7 +1332,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
|
|
||||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||||
app.post(replyWorkOrderRoute.path, async (c) => {
|
app.post(replyWorkOrderRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1431,7 +1426,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
||||||
*/
|
*/
|
||||||
app.post(replyChatCardRoute.path, async (c) => {
|
app.post(replyChatCardRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1556,7 +1551,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
||||||
*/
|
*/
|
||||||
app.post(markCommReadRoute.path, async (c) => {
|
app.post(markCommReadRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1615,7 +1610,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(sayRoute.path, async (c) => {
|
app.post(sayRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1630,7 +1625,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(listMessagesRoute.path, async (c) => {
|
app.get(listMessagesRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1686,7 +1681,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
|
|
||||||
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
|
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
|
||||||
app.post(confirmTriggerPendingRoute.path, async (c) => {
|
app.post(confirmTriggerPendingRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1710,7 +1705,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(markMessageReadRoute.path, async (c) => {
|
app.post(markMessageReadRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1725,7 +1720,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
||||||
*/
|
*/
|
||||||
app.post(answerMessageRoute.path, async (c) => {
|
app.post(answerMessageRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { extractText } from '../upload-extract.ts';
|
import { extractText } from '../upload-extract.ts';
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
import type { ApiError } from '@contracts';
|
||||||
import {
|
import {
|
||||||
browseLedgerRoute,
|
browseLedgerRoute,
|
||||||
controlTaskRoute,
|
controlTaskRoute,
|
||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
VersionTakenError,
|
VersionTakenError,
|
||||||
} from '@modules/process';
|
} from '@modules/process';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
import { displayLog } from './log-lines.ts';
|
import { displayLog } from './log-lines.ts';
|
||||||
|
|
||||||
@@ -366,46 +367,9 @@ async function assembleFromMail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
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) => {
|
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 { staff } = await container.org.listStaff();
|
||||||
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
||||||
const rows = await Promise.all(
|
const rows = await Promise.all(
|
||||||
@@ -438,9 +402,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
||||||
app.post(controlTaskRoute.path, async (c) => {
|
app.post(controlTaskRoute.path, async (c) => {
|
||||||
const denied = await governTask(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
const staff = await container.org.findByAccount(c.get('account'));
|
|
||||||
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success || staff === undefined) {
|
if (!parsed.success || staff === undefined) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
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) => {
|
app.post(resumeTaskRoute.path, async (c) => {
|
||||||
const denied = await governTask(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
const staff = await container.org.findByAccount(c.get('account'));
|
|
||||||
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
|
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);
|
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
|
||||||
return c.json({ ok: true, runtime: task.runtime });
|
return c.json({ ok: true, runtime: task.runtime });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(reassignTaskRoute.path, async (c) => {
|
app.post(reassignTaskRoute.path, async (c) => {
|
||||||
const denied = await governTask(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
const staff = await container.org.findByAccount(c.get('account'));
|
|
||||||
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success || staff === undefined) {
|
if (!parsed.success || staff === undefined) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
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) => {
|
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')));
|
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||||
return c.json({
|
return c.json({
|
||||||
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
|
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
|
||||||
@@ -502,9 +458,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
||||||
*/
|
*/
|
||||||
app.get(listApprovalsRoute.path, async (c) => {
|
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 workflows = await container.process.listWorkflows(50);
|
||||||
const perWorkflow = await Promise.all(
|
const perWorkflow = await Promise.all(
|
||||||
workflows.map(async (w) => {
|
workflows.map(async (w) => {
|
||||||
@@ -551,14 +504,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
|
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
|
||||||
*/
|
*/
|
||||||
app.post(testRunRoute.path, async (c) => {
|
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());
|
const parsed = testRunRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
const me = await container.org.findByAccount(c.get('account'));
|
const me = staffOf(c);
|
||||||
const { packageId, mail, assignments } = parsed.data;
|
const { packageId, mail, assignments } = parsed.data;
|
||||||
try {
|
try {
|
||||||
const data = await assembleFromMail(container, packageId, mail);
|
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) => {
|
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());
|
const parsed = testStopRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
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) => {
|
app.get(testCurrentRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
/*
|
/*
|
||||||
* **在跑与跑完要分得开**(`live`)。
|
* **在跑与跑完要分得开**(`live`)。
|
||||||
*
|
*
|
||||||
@@ -668,14 +615,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* `origin='testbed'`,好让人随时连根拔掉。
|
* `origin='testbed'`,好让人随时连根拔掉。
|
||||||
*/
|
*/
|
||||||
app.post(testRunActivityRoute.path, async (c) => {
|
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());
|
const parsed = testRunActivityRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
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;
|
const { activityId, data, message, ownerId } = parsed.data;
|
||||||
/*
|
/*
|
||||||
* 派给谁:显式指定优先,否则**派给操作者自己**。
|
* 派给谁:显式指定优先,否则**派给操作者自己**。
|
||||||
@@ -729,8 +674,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
||||||
*/
|
*/
|
||||||
app.get(testActivityStateRoute.path, async (c) => {
|
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'));
|
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||||
/*
|
/*
|
||||||
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
|
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
|
||||||
@@ -776,16 +719,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
|
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
|
||||||
|
|
||||||
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
|
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
|
||||||
const UPLOAD_CAP = 8 * 1024 * 1024;
|
const UPLOAD_CAP = 8 * 1024 * 1024;
|
||||||
|
|
||||||
app.post(uploadFileRoute.path, async (c) => {
|
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());
|
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
||||||
@@ -806,7 +745,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
}
|
}
|
||||||
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
||||||
const got = extractText(name, contentType, bytes);
|
const got = extractText(name, contentType, bytes);
|
||||||
const me = await container.org.findByAccount(c.get('account'));
|
const me = staffOf(c);
|
||||||
const ref = crypto.randomUUID();
|
const ref = crypto.randomUUID();
|
||||||
await container.db.execute(sql`
|
await container.db.execute(sql`
|
||||||
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
|
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) => {
|
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 }>(
|
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')}`,
|
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) => {
|
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`
|
const rows = await container.db.execute<TestLogDbRow>(sql`
|
||||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||||
jsonb_array_length(trail) as trail_count,
|
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) => {
|
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`
|
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,
|
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||||
jsonb_array_length(trail) as trail_count,
|
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) => {
|
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 }>(
|
const rows = await container.db.execute<{ id: string }>(
|
||||||
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
|
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) => {
|
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`);
|
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 });
|
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
||||||
app.get(traceOfWorkflowRoute.path, async (c) => {
|
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'));
|
const spans = await container.trace.ofWorkflow(c.req.param('id'));
|
||||||
return c.json({ spans });
|
return c.json({ spans });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
||||||
app.get(govWorkflowRoute.path, async (c) => {
|
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 workflow = await container.process.getWorkflow(c.req.param('id'));
|
||||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||||
if (pkg === undefined) {
|
if (pkg === undefined) {
|
||||||
@@ -945,8 +869,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
app.get(listProcessPackagesRoute.path, async (c) => {
|
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() });
|
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) => {
|
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());
|
const parsed = saveProcessRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
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)—— 那些单要按它跑完 */
|
/** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */
|
||||||
app.delete(deleteProcessVersionRoute.path, async (c) => {
|
app.delete(deleteProcessVersionRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
try {
|
try {
|
||||||
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
|
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
|
||||||
return c.json({ packages });
|
return c.json({ packages });
|
||||||
@@ -1004,14 +922,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
|
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
|
||||||
|
|
||||||
app.get(listApprovalTemplatesRoute.path, async (c) => {
|
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() });
|
return c.json({ templates: await container.process.approvalTemplates() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put(upsertApprovalTemplateRoute.path, async (c) => {
|
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());
|
const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' };
|
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) => {
|
app.delete(deleteApprovalTemplateRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
try {
|
try {
|
||||||
await container.process.deleteApprovalTemplate(c.req.param('id'));
|
await container.process.deleteApprovalTemplate(c.req.param('id'));
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -1042,8 +954,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
|
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
|
||||||
*/
|
*/
|
||||||
app.delete(deleteProcessFlowRoute.path, async (c) => {
|
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 id = c.req.param('id');
|
||||||
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
|
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
|
||||||
if (rules.length > 0) {
|
if (rules.length > 0) {
|
||||||
@@ -1067,8 +977,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
|
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
|
||||||
app.post(editProcessVersionRoute.path, async (c) => {
|
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());
|
const parsed = editProcessVersionRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
|
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) => {
|
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());
|
const parsed = setProcessStateRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
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) => {
|
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());
|
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
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) => {
|
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() });
|
return c.json({ triggers: await container.trigger.list() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(toggleTriggerRoute.path, async (c) => {
|
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());
|
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
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) => {
|
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());
|
const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
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) => {
|
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'));
|
const owners = await container.process.nodeOwners(c.req.param('id'));
|
||||||
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
|
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put(setNodeOwnerRoute.path, async (c) => {
|
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());
|
const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||||
@@ -1201,8 +1095,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
|
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
|
||||||
*/
|
*/
|
||||||
app.put(upsertTriggerRoute.path, async (c) => {
|
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());
|
const parsed = upsertTriggerRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
|
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) => {
|
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());
|
const parsed = installActivityRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
|
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) => {
|
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();
|
const cards = await container.registry.list();
|
||||||
/*
|
/*
|
||||||
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
||||||
@@ -1374,8 +1262,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
||||||
*/
|
*/
|
||||||
app.post(registerToolRoute.path, async (c) => {
|
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());
|
const parsed = registerToolRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
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) => {
|
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();
|
const tools = await container.toolbox.list();
|
||||||
return c.json({
|
return c.json({
|
||||||
tools: tools.map((t) => ({
|
tools: tools.map((t) => ({
|
||||||
@@ -1417,8 +1301,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
||||||
|
|
||||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
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)——
|
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
|
||||||
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
|
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
|
||||||
@@ -1438,9 +1320,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
||||||
*/
|
*/
|
||||||
app.get(ledgerByActivityRoute.path, async (c) => {
|
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([
|
const [cards, materialized] = await Promise.all([
|
||||||
container.registry.list(),
|
container.registry.list(),
|
||||||
container.ledger.listObjects(),
|
container.ledger.listObjects(),
|
||||||
@@ -1557,8 +1436,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(browseLedgerRoute.path, async (c) => {
|
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 address = { domain: c.req.param('domain'), object: c.req.param('object') };
|
||||||
const described = await container.ledger.describe(address);
|
const described = await container.ledger.describe(address);
|
||||||
if (described === undefined) {
|
if (described === undefined) {
|
||||||
@@ -1595,8 +1472,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
|
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
|
||||||
*/
|
*/
|
||||||
app.get(govMetricsRoute.path, async (c) => {
|
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());
|
const parsed = govMetricsRoute.query.safeParse(c.req.query());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues };
|
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) => {
|
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();
|
const channels = await container.channel.list();
|
||||||
return c.json({
|
return c.json({
|
||||||
channels: channels.map((ch) => ({
|
channels: channels.map((ch) => ({
|
||||||
@@ -1632,8 +1505,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(listActivityMemoryRoute.path, async (c) => {
|
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 });
|
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
|
||||||
return c.json({
|
return c.json({
|
||||||
entries: entries.map((e) => ({
|
entries: entries.map((e) => ({
|
||||||
|
|||||||
@@ -1,107 +1,108 @@
|
|||||||
/**
|
/**
|
||||||
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
||||||
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
||||||
|
* 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。
|
||||||
*/
|
*/
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ApiError, GovernanceFunction } from '@contracts';
|
import type { ApiError } from '@contracts';
|
||||||
import {
|
import {
|
||||||
createStaffRoute,
|
deleteLlmInstanceRoute,
|
||||||
disableStaffCheckRoute,
|
deleteRoleAccessRoute,
|
||||||
disableStaffRoute,
|
|
||||||
listGrantsRoute,
|
|
||||||
listLlmInstancesRoute,
|
listLlmInstancesRoute,
|
||||||
|
listLlmProvidersRoute,
|
||||||
|
listRoleAccessRoute,
|
||||||
listStaffRoute,
|
listStaffRoute,
|
||||||
listWorkGroupsRoute,
|
listWorkGroupsRoute,
|
||||||
positionIdSchema,
|
|
||||||
listLlmProvidersRoute,
|
|
||||||
LLM_PROVIDERS,
|
LLM_PROVIDERS,
|
||||||
deleteLlmInstanceRoute,
|
|
||||||
probeLlmDraftRoute,
|
probeLlmDraftRoute,
|
||||||
probeLlmInstanceRoute,
|
probeLlmInstanceRoute,
|
||||||
saveWorkGroupRoute,
|
saveWorkGroupRoute,
|
||||||
setGrantsRoute,
|
setRoleAccessRoute,
|
||||||
|
setStaffPositionRoute,
|
||||||
staffIdSchema,
|
staffIdSchema,
|
||||||
|
staffInFlightRoute,
|
||||||
|
syncStaffRoute,
|
||||||
upsertLlmInstanceRoute,
|
upsertLlmInstanceRoute,
|
||||||
} from '@contracts';
|
} from '@contracts';
|
||||||
import { DisableBlockedError } from '@modules/org';
|
import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org';
|
||||||
import { PROVIDER_PROFILES } from '@modules/llm';
|
import { PROVIDER_PROFILES } from '@modules/llm';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
import type { AppEnv } from '../http-env.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 {
|
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
const guardStaff = requireGovern(container, 'staff');
|
/* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */
|
||||||
const guardSystem = requireGovern(container, 'system');
|
|
||||||
|
|
||||||
/* ── 员工管理 ─────────────────────────────────────────────────────── */
|
app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff()));
|
||||||
|
|
||||||
app.get(listStaffRoute.path, async (c) => {
|
/** 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎) */
|
||||||
const denied = await guardStaff(c.get('account'));
|
app.post(syncStaffRoute.path, async (c) => c.json(await container.org.syncRoster()));
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
return c.json(await container.org.listStaff());
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(createStaffRoute.path, async (c) => {
|
app.put(setStaffPositionRoute.path, async (c) => {
|
||||||
const denied = await guardStaff(c.get('account'));
|
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
const parsed = setStaffPositionRoute.body.safeParse(await c.req.json());
|
||||||
const parsed = createStaffRoute.body.safeParse(await c.req.json());
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
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 {
|
try {
|
||||||
await container.org.disableStaff(staffId);
|
return c.json(await container.org.setPosition(staffId, parsed.data.positionId));
|
||||||
return c.json({ ok: true, check: await container.org.checkDisable(staffId) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DisableBlockedError) {
|
if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) {
|
||||||
// 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02)
|
const body: ApiError = { code: 'not-found', message: error.message };
|
||||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check };
|
return c.json(body, 404);
|
||||||
return c.json(body, 412);
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(listGrantsRoute.path, async (c) => {
|
/** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */
|
||||||
const denied = await guardStaff(c.get('account'));
|
app.get(staffInFlightRoute.path, async (c) => {
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
try {
|
||||||
return c.json({ grants: await container.org.listGrants(positionId) });
|
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) => {
|
/* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */
|
||||||
const denied = await guardStaff(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess()));
|
||||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
|
||||||
const parsed = setGrantsRoute.body.safeParse(await c.req.json());
|
/** 锁死不变量不过(改完没人能进运营中心)→ 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) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
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) ──────────────────────────────────── */
|
/* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */
|
||||||
@@ -111,8 +112,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
|
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
|
||||||
*/
|
*/
|
||||||
app.get(listWorkGroupsRoute.path, async (c) => {
|
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 { staff } = await container.org.listStaff();
|
||||||
const found = await Promise.all(
|
const found = await Promise.all(
|
||||||
staff.map(async (s) => {
|
staff.map(async (s) => {
|
||||||
@@ -129,8 +128,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
* 改组**不影响在途**:装进工作流的是起单时的快照。
|
* 改组**不影响在途**:装进工作流的是起单时的快照。
|
||||||
*/
|
*/
|
||||||
app.put(saveWorkGroupRoute.path, async (c) => {
|
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());
|
const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
|
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 实例 ──────────────────────────────────────────── */
|
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
|
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
|
||||||
app.get(listLlmProvidersRoute.path, async (c) => {
|
app.get(listLlmProvidersRoute.path, (c) =>
|
||||||
const denied = await guardSystem(c.get('account'));
|
c.json({
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
return c.json({
|
|
||||||
providers: LLM_PROVIDERS.map((provider) => {
|
providers: LLM_PROVIDERS.map((provider) => {
|
||||||
const profile = PROVIDER_PROFILES[provider];
|
const profile = PROVIDER_PROFILES[provider];
|
||||||
return {
|
return {
|
||||||
@@ -162,18 +157,12 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
structuredOutput: profile.structuredOutput,
|
structuredOutput: profile.structuredOutput,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
app.get(listLlmInstancesRoute.path, async (c) => {
|
app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() }));
|
||||||
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) => {
|
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());
|
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
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 不该知道活动的存在 */
|
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
|
||||||
app.delete(deleteLlmInstanceRoute.path, async (c) => {
|
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 id = c.req.param('id');
|
||||||
const target = (await container.llm.list()).find((i) => i.id === id);
|
const target = (await container.llm.list()).find((i) => i.id === id);
|
||||||
if (target === undefined) {
|
if (target === undefined) {
|
||||||
@@ -208,8 +195,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
|
|
||||||
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
||||||
app.post(probeLlmDraftRoute.path, async (c) => {
|
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());
|
const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
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));
|
return c.json(await container.llm.probeDraft(parsed.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(probeLlmInstanceRoute.path, async (c) => {
|
app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id'))));
|
||||||
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')));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Hono } from 'hono';
|
|||||||
import type { ApiError } from '@contracts';
|
import type { ApiError } from '@contracts';
|
||||||
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
|
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
@@ -17,7 +18,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
|
|||||||
|
|
||||||
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
|
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
|
||||||
app.get(listRegistrationTriggersRoute.path, async (c) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
|
|
||||||
const triggers = await container.trigger.list();
|
const triggers = await container.trigger.list();
|
||||||
@@ -38,7 +39,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(fireRegistrationRoute.path, async (c) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
|
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
||||||
* 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。
|
* 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。
|
||||||
*/
|
*/
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ApiError } from '@contracts';
|
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 type { Container } from '../container.ts';
|
||||||
import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts';
|
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
import { systemClock } from '../runtime.ts';
|
||||||
|
|
||||||
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
app.get(healthRoute.path, async (c) => {
|
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 });
|
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */
|
const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' };
|
||||||
app.get(sessionRoute.path, (c) => {
|
|
||||||
const session = c.get('session');
|
/** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */
|
||||||
if (session === undefined) {
|
app.get(meRoute.path, (c) => {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
|
const principal = c.get('principal');
|
||||||
return c.json(body, 401);
|
if (principal === undefined) return c.json(unauthenticated, 401);
|
||||||
}
|
return c.json(principal);
|
||||||
return c.json(session);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(loginRoute.path, async (c) => {
|
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
|
||||||
const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
app.get(authTicketRoute.path, (c) => {
|
||||||
if (!parsed.success) {
|
const principal = c.get('principal');
|
||||||
const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' };
|
if (principal === undefined) return c.json(unauthenticated, 401);
|
||||||
return c.json(body, 400);
|
const issued = container.tickets.issue(principal.staff.id, systemClock.now());
|
||||||
}
|
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() });
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export async function seed(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await container.org.ensureSeeded();
|
await container.org.ensureSeeded();
|
||||||
const { staff, positions } = await container.org.listStaff();
|
const { staff, positions } = await container.org.listStaff();
|
||||||
process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`);
|
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`);
|
for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`);
|
||||||
|
|
||||||
// MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条)
|
// MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条)
|
||||||
process.stdout.write('\n── 治理台配置 ──\n');
|
process.stdout.write('\n── 治理台配置 ──\n');
|
||||||
|
|||||||
+51
-31
@@ -1,13 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
||||||
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
* 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
||||||
*/
|
*/
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ApiError } from '@contracts';
|
import * as contracts from '@contracts';
|
||||||
import { healthRoute, ingestMailRoute, loginRoute } 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 { Container } from './container.ts';
|
||||||
import type { AppEnv } from './http-env.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 { registerDeskRoutes } from './routes/desk.ts';
|
||||||
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||||
import { registerDeskApprovalRoutes } from './routes/desk-approval.ts';
|
import { registerDeskApprovalRoutes } from './routes/desk-approval.ts';
|
||||||
@@ -20,41 +33,41 @@ import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
|||||||
|
|
||||||
export type { AppEnv };
|
export type { AppEnv };
|
||||||
|
|
||||||
/**
|
/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */
|
||||||
* 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。
|
export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }];
|
||||||
* 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。
|
|
||||||
*/
|
|
||||||
const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':'));
|
|
||||||
|
|
||||||
function isPublic(method: string, path: string): boolean {
|
/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */
|
||||||
if (method === 'GET' && path === healthRoute.path) return true;
|
export function appRouteRegistry(): RouteRegistry {
|
||||||
if (method === 'POST' && path === loginRoute.path) return true;
|
return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]);
|
||||||
if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随统一鉴权中心退役的路由(§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> {
|
export function createServer(container: Container): Hono<AppEnv> {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。
|
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。
|
||||||
*
|
* 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判,
|
||||||
* 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来,
|
* 路由里不再各写守卫 —— 一处拦住,新路由自动生效。
|
||||||
* 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。
|
|
||||||
*/
|
*/
|
||||||
app.use('*', async (c, next) => {
|
app.use(
|
||||||
const token = readSessionToken(c.req.header('cookie'));
|
'*',
|
||||||
const session = token === undefined ? undefined : await container.org.resolveSession(token);
|
createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }),
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
|
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
|
||||||
@@ -92,6 +105,13 @@ export function createServer(container: Container): Hono<AppEnv> {
|
|||||||
return c.json(body, 404);
|
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);
|
registerWorkbenchRoutes(app, container);
|
||||||
registerGovernanceRoutes(app, container);
|
registerGovernanceRoutes(app, container);
|
||||||
registerDeskRoutes(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 { InteractionService } from '@modules/interaction';
|
||||||
import type { MessageService } from '@modules/message';
|
import type { MessageService } from '@modules/message';
|
||||||
import type { OrgService } from '@modules/org';
|
import type { OrgService } from '@modules/org';
|
||||||
|
import { NONE_POSITION_CODE } from '@modules/org';
|
||||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||||
import type { ActivityRegistryService } from '@modules/activity-registry';
|
import type { ActivityRegistryService } from '@modules/activity-registry';
|
||||||
import type { SkillEngineService } from '@modules/skill-engine';
|
import type { SkillEngineService } from '@modules/skill-engine';
|
||||||
@@ -2453,7 +2454,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
org: {
|
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) => {
|
candidates: async (position) => {
|
||||||
const staff = await deps.org.staffOfPosition(position);
|
const staff = await deps.org.staffOfPosition(position);
|
||||||
const withLoad = await Promise.all(
|
const withLoad = await Promise.all(
|
||||||
@@ -2614,10 +2619,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
|
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
|
||||||
report: async (input) => {
|
report: async (input) => {
|
||||||
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
||||||
const { staff } = await deps.org.listStaff();
|
// 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端)
|
||||||
for (const person of staff) {
|
for (const person of await deps.org.staffWithEnd('ops')) {
|
||||||
const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern');
|
|
||||||
if (!governs) continue;
|
|
||||||
await deps.message.notify({
|
await deps.message.notify({
|
||||||
staffId: person.id,
|
staffId: person.id,
|
||||||
title: `触发器起单失败:${input.triggerId}`,
|
title: `触发器起单失败:${input.triggerId}`,
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
/** domain 测试零 mock(编码规范 §16) */
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { Grant, PositionId } from '@contracts';
|
|
||||||
import { actionCovers, isAuthorized, objectCovers } from '../internal/domain/authorization.ts';
|
|
||||||
|
|
||||||
const pos = 'p-bd' as PositionId;
|
|
||||||
|
|
||||||
describe('actionCovers —— 动作蕴含', () => {
|
|
||||||
it('治理蕴含写与读', () => {
|
|
||||||
expect(actionCovers('govern', 'write')).toBe(true);
|
|
||||||
expect(actionCovers('govern', 'read')).toBe(true);
|
|
||||||
});
|
|
||||||
it('写蕴含读,但读不蕴含写', () => {
|
|
||||||
expect(actionCovers('write', 'read')).toBe(true);
|
|
||||||
expect(actionCovers('read', 'write')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('objectCovers —— 客体三级覆盖', () => {
|
|
||||||
it('对象级授权覆盖其下字段', () => {
|
|
||||||
expect(
|
|
||||||
objectCovers(
|
|
||||||
{ kind: 'data', domain: 'shared', object: 'customer' },
|
|
||||||
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('字段级授权不覆盖别的字段', () => {
|
|
||||||
expect(
|
|
||||||
objectCovers(
|
|
||||||
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
|
|
||||||
{ kind: 'data', domain: 'shared', object: 'customer', field: 'payment_terms' },
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('域级通配覆盖域内全部对象', () => {
|
|
||||||
expect(
|
|
||||||
objectCovers(
|
|
||||||
{ kind: 'data', domain: 'shared', object: '*' },
|
|
||||||
{ kind: 'data', domain: 'shared', object: 'supplier', field: 'contact' },
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('跨域不覆盖', () => {
|
|
||||||
expect(
|
|
||||||
objectCovers(
|
|
||||||
{ kind: 'data', domain: 'shared', object: '*' },
|
|
||||||
{ kind: 'data', domain: 'quote_intake', object: 'record' },
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('治理功能逐项授权', () => {
|
|
||||||
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'task' })).toBe(true);
|
|
||||||
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'staff' })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('任务客体未限定活动即覆盖全部任务', () => {
|
|
||||||
expect(objectCovers({ kind: 'task' }, { kind: 'task', activityId: 'a1' })).toBe(true);
|
|
||||||
expect(objectCovers({ kind: 'task', activityId: 'a1' }, { kind: 'task', activityId: 'a2' })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('客体类型不同一律不覆盖', () => {
|
|
||||||
expect(objectCovers({ kind: 'task' }, { kind: 'governance', function: 'task' })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isAuthorized —— 任一授权覆盖即通过', () => {
|
|
||||||
const grants: Grant[] = [
|
|
||||||
{ positionId: pos, object: { kind: 'governance', function: 'task' }, action: 'read' },
|
|
||||||
{ positionId: pos, object: { kind: 'data', domain: 'shared', object: '*' }, action: 'write' },
|
|
||||||
];
|
|
||||||
|
|
||||||
it('命中数据域的写', () => {
|
|
||||||
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'write')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('写权蕴含读权', () => {
|
|
||||||
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'read')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('只读的治理功能不得写', () => {
|
|
||||||
expect(isAuthorized(grants, { kind: 'governance', function: 'task' }, 'govern')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('空授权一律不通过', () => {
|
|
||||||
expect(isAuthorized([], { kind: 'governance', function: 'system' }, 'read')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* usecase 测试:仓储全是内存实现(模块内部机制不 mock,只是换个存法),
|
||||||
|
* 验签是注入的假 verifier(Keycloak 是外界)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Position, RoleAccess, WorkGroup } from '@contracts';
|
||||||
|
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||||
|
import { AccessLockoutError, AccountDisabledError, TokenRejectedError } from '../contract.ts';
|
||||||
|
import { createOrgService } from '../internal/usecase/org-service.ts';
|
||||||
|
import type { OrgServiceDeps } from '../internal/usecase/org-service.ts';
|
||||||
|
import type { RosterSource, StaffRecord, VerifiedClaims } from '../port.ts';
|
||||||
|
|
||||||
|
type Harness = {
|
||||||
|
org: ReturnType<typeof createOrgService>;
|
||||||
|
tokens: Map<string, VerifiedClaims>;
|
||||||
|
staff: Map<string, StaffRecord>;
|
||||||
|
roleAccess: Map<string, RoleAccess>;
|
||||||
|
groups: Map<string, WorkGroup>;
|
||||||
|
positions: Map<string, Position>;
|
||||||
|
calls: { updateIdentity: number };
|
||||||
|
advance(ms: number): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness {
|
||||||
|
const positions = new Map<string, Position>();
|
||||||
|
const staff = new Map<string, StaffRecord>();
|
||||||
|
const roleAccess = new Map<string, RoleAccess>();
|
||||||
|
const groups = new Map<string, WorkGroup>();
|
||||||
|
const tokens = new Map<string, VerifiedClaims>();
|
||||||
|
const calls = { updateIdentity: 0 };
|
||||||
|
let nowMs = Date.parse('2026-08-25T09:00:00Z');
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
const deps: OrgServiceDeps = {
|
||||||
|
verifier: {
|
||||||
|
verify: async (token) => {
|
||||||
|
const claims = tokens.get(token);
|
||||||
|
if (claims === undefined) throw new TokenRejectedError('签名校验失败');
|
||||||
|
return claims;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(opts.roster === undefined ? {} : { roster: opts.roster }),
|
||||||
|
positions: {
|
||||||
|
list: async () => [...positions.values()],
|
||||||
|
findByCode: async (code) => [...positions.values()].find((p) => p.code === code),
|
||||||
|
findById: async (id) => positions.get(id),
|
||||||
|
upsert: async (p) => {
|
||||||
|
const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase());
|
||||||
|
const row: Position = { id, code: p.code, name: p.name, ...(p.note === undefined ? {} : { note: p.note }) };
|
||||||
|
positions.set(id, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
staff: {
|
||||||
|
list: async () => [...staff.values()],
|
||||||
|
findById: async (id) => staff.get(id),
|
||||||
|
listByPosition: async (pid) => [...staff.values()].filter((s) => s.positionId === pid),
|
||||||
|
insert: async (s, marks) => {
|
||||||
|
const row: StaffRecord = { ...s, ...marks };
|
||||||
|
staff.set(s.id, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
updateIdentity: async (id, patch) => {
|
||||||
|
calls.updateIdentity += 1;
|
||||||
|
const { email: _dropped, ...rest } = staff.get(id)!;
|
||||||
|
staff.set(id, {
|
||||||
|
...rest,
|
||||||
|
account: patch.account,
|
||||||
|
name: patch.name,
|
||||||
|
roles: [...patch.roles],
|
||||||
|
...(patch.email === undefined ? {} : { email: patch.email }),
|
||||||
|
...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setPosition: async (id, pid) => {
|
||||||
|
staff.set(id, { ...staff.get(id)!, positionId: pid });
|
||||||
|
},
|
||||||
|
setState: async (id, state, syncedAt) => {
|
||||||
|
staff.set(id, { ...staff.get(id)!, state, syncedAt });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roleAccess: {
|
||||||
|
list: async () => [...roleAccess.values()],
|
||||||
|
upsert: async (row) => {
|
||||||
|
roleAccess.set(row.role, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
remove: async (role) => {
|
||||||
|
roleAccess.delete(role);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workGroups: {
|
||||||
|
listByOwner: async (owner) => [...groups.values()].filter((g) => g.ownerId === owner),
|
||||||
|
upsert: async (g) => {
|
||||||
|
groups.set(g.id, g);
|
||||||
|
return g;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] },
|
||||||
|
clock: { now: () => new Date(nowMs) },
|
||||||
|
ids: { next: () => `id-${(seq += 1)}` },
|
||||||
|
testAccounts: opts.testAccounts ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
org: createOrgService(deps),
|
||||||
|
tokens,
|
||||||
|
staff,
|
||||||
|
roleAccess,
|
||||||
|
groups,
|
||||||
|
positions,
|
||||||
|
calls,
|
||||||
|
advance: (ms) => {
|
||||||
|
nowMs += ms;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = (over: Partial<VerifiedClaims> & { sub: string; preferred_username: string }): VerifiedClaims => ({
|
||||||
|
name: over.preferred_username,
|
||||||
|
realm_access: { roles: ['user', 'default-roles-corp'] },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticate —— 验签 → 名册即时更新 → 角色→端', () => {
|
||||||
|
it('首次见到:建档在 NONE 岗、active、角色滤了噪声、端按缺省表', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
h.tokens.set('t-alice', claims({ sub: 'sub-alice', preferred_username: 'alice', name: '爱丽丝', email: 'a@x.test' }));
|
||||||
|
const p = await h.org.authenticate('t-alice');
|
||||||
|
expect(p.staff.id).toBe('sub-alice');
|
||||||
|
expect(p.staff.account).toBe('alice');
|
||||||
|
expect(p.staff.name).toBe('爱丽丝');
|
||||||
|
expect(p.staff.email).toBe('a@x.test');
|
||||||
|
expect(p.staff.state).toBe('active');
|
||||||
|
expect(p.position.code).toBe('NONE');
|
||||||
|
expect(p.roles).toEqual(['user']);
|
||||||
|
expect(p.ends).toEqual(['workbench']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin → 三端', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
h.tokens.set('t', claims({ sub: 's', preferred_username: 'root', realm_access: { roles: ['admin'] } }));
|
||||||
|
expect((await h.org.authenticate('t')).ends).toEqual(['design', 'ops', 'workbench']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('坏 token → TokenRejectedError 原样抛出', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
await expect(h.org.authenticate('nope')).rejects.toBeInstanceOf(TokenRejectedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('60 秒内再来且没变 → 不重写;改了名 → 写回并返回新名', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃' }));
|
||||||
|
await h.org.authenticate('t');
|
||||||
|
h.advance(10_000);
|
||||||
|
await h.org.authenticate('t');
|
||||||
|
expect(h.calls.updateIdentity).toBe(0);
|
||||||
|
h.advance(61_000);
|
||||||
|
await h.org.authenticate('t');
|
||||||
|
expect(h.calls.updateIdentity).toBe(1); // 过了 60s 只为刷 last_seen_at 写一次
|
||||||
|
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃·新' }));
|
||||||
|
const p = await h.org.authenticate('t');
|
||||||
|
expect(p.staff.name).toBe('鲍勃·新');
|
||||||
|
expect(h.calls.updateIdentity).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('名册标了停用 → 即使 token 有效也抛 AccountDisabledError;principalOf 同款', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' }));
|
||||||
|
await h.org.authenticate('t');
|
||||||
|
h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' });
|
||||||
|
await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError);
|
||||||
|
await expect(h.org.principalOf(staffIdSchema.parse('s'))).rejects.toBeInstanceOf(AccountDisabledError);
|
||||||
|
expect(await h.org.principalOf(staffIdSchema.parse('ghost'))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('测试账号:首次建档即 TEST 岗 + 独立工作组(五岗指向本人)', async () => {
|
||||||
|
const h = harness({ testAccounts: ['service-account-bpos-e2e'] });
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
h.tokens.set('t', claims({ sub: 'sub-ai', preferred_username: 'service-account-bpos-e2e', realm_access: { roles: ['admin'] } }));
|
||||||
|
const p = await h.org.authenticate('t');
|
||||||
|
expect(p.position.code).toBe('TEST');
|
||||||
|
const group = await h.org.workGroupOf(staffIdSchema.parse('sub-ai'));
|
||||||
|
expect(group?.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('名册与岗位', () => {
|
||||||
|
it('staffOfPosition 只回 active;NONE 恒回 []', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] });
|
||||||
|
const b = await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: [] });
|
||||||
|
const bd = (await h.org.listStaff()).positions.find((p) => p.code === 'BD')!;
|
||||||
|
await h.org.setPosition(a.id, bd.id);
|
||||||
|
await h.org.setPosition(b.id, bd.id);
|
||||||
|
h.staff.set('b', { ...h.staff.get('b')!, state: 'disabled' });
|
||||||
|
expect((await h.org.staffOfPosition('BD')).map((s) => s.id)).toEqual(['a']);
|
||||||
|
expect(await h.org.staffOfPosition('NONE')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncRoster 未配置 → configured:false', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
const outcome = await h.org.syncRoster();
|
||||||
|
expect(outcome.configured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncRoster:新增 / 改名 / 远端停用 / 本地有远端无 → 停;远端启用 → 复活', async () => {
|
||||||
|
const remote: RosterSource = {
|
||||||
|
listUsers: async () => [
|
||||||
|
{ sub: 'a', account: 'a', name: '阿', roles: ['user'], enabled: true },
|
||||||
|
{ sub: 'b', account: 'b', name: 'B', roles: ['user'], enabled: false },
|
||||||
|
{ sub: 'c', account: 'c', name: 'C', roles: ['admin'], enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const h = harness({ roster: remote });
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['user'] });
|
||||||
|
await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: ['user'] });
|
||||||
|
await h.org.registerIdentity({ sub: 'z', account: 'z', name: 'Z', roles: ['user'] });
|
||||||
|
h.staff.set('c', { ...h.staff.get('a')!, id: staffIdSchema.parse('c'), account: 'c', name: 'C', roles: ['admin'], state: 'disabled' });
|
||||||
|
const outcome = await h.org.syncRoster();
|
||||||
|
expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3 });
|
||||||
|
expect(h.staff.get('a')?.name).toBe('阿');
|
||||||
|
expect(h.staff.get('b')?.state).toBe('disabled');
|
||||||
|
expect(h.staff.get('z')?.state).toBe('disabled');
|
||||||
|
expect(h.staff.get('c')?.state).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncRoster 插入的新人也走测试账号策略,且远端停用的直接落 disabled', async () => {
|
||||||
|
const remote: RosterSource = {
|
||||||
|
listUsers: async () => [{ sub: 'ai', account: 'ai', name: 'AI', roles: ['admin'], enabled: true }, { sub: 'x', account: 'x', name: 'X', roles: [], enabled: false }],
|
||||||
|
};
|
||||||
|
const h = harness({ roster: remote, testAccounts: ['AI'] });
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
const outcome = await h.org.syncRoster();
|
||||||
|
expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2 });
|
||||||
|
expect((await h.org.getStaff(staffIdSchema.parse('ai'))).position.code).toBe('TEST');
|
||||||
|
expect(h.staff.get('x')?.state).toBe('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('角色 → 端', () => {
|
||||||
|
it('listRoleAccess:knownRoles = 名册上见过的 ∪ 已配置的', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['auditor'] });
|
||||||
|
const { roles, knownRoles } = await h.org.listRoleAccess();
|
||||||
|
expect(roles.map((r) => r.role).sort()).toEqual(['admin', 'user']);
|
||||||
|
expect(knownRoles).toEqual(['admin', 'auditor', 'user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setRoleAccess 归一端的顺序;唯一 admin 摘 ops → 锁死拒;换到别的在册角色 → 过', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
|
||||||
|
await h.org.registerIdentity({ sub: 'lead', account: 'lead', name: 'L', roles: ['ops-lead'] });
|
||||||
|
expect((await h.org.setRoleAccess('designer', ['workbench', 'design'])).ends).toEqual(['design', 'workbench']);
|
||||||
|
await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).rejects.toBeInstanceOf(AccessLockoutError);
|
||||||
|
await h.org.setRoleAccess('ops-lead', ['ops']);
|
||||||
|
await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).resolves.toBeDefined();
|
||||||
|
await expect(h.org.deleteRoleAccess('ops-lead')).rejects.toBeInstanceOf(AccessLockoutError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('staffWithEnd:能进该端的活跃员工', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
|
||||||
|
await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] });
|
||||||
|
expect((await h.org.staffWithEnd('ops')).map((s) => s.id)).toEqual(['root']);
|
||||||
|
expect((await h.org.staffWithEnd('workbench')).map((s) => s.id).sort()).toEqual(['root', 'u']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureSeeded —— 幂等,不覆盖人工改动', () => {
|
||||||
|
it('岗位含 NONE 与 TEST;缺省两行只在表空时播;改过的行不被重播', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
const codes = (await h.org.listStaff()).positions.map((p) => p.code).sort();
|
||||||
|
expect(codes).toEqual(['BD', 'FAE', 'NONE', 'OP', 'PM', 'PRM', 'TEST']);
|
||||||
|
expect([...h.roleAccess.keys()].sort()).toEqual(['admin', 'user']);
|
||||||
|
h.roleAccess.set('user', { role: 'user', ends: [] });
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
expect(h.roleAccess.get('user')?.ends).toEqual([]);
|
||||||
|
expect(h.positions.size).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('播种时给已在册的测试账号补策略', async () => {
|
||||||
|
const h = harness({ testAccounts: ['chaihao'] });
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
const none = (await h.org.listStaff()).positions.find((p) => p.code === 'NONE')!;
|
||||||
|
h.staff.set('ch', { id: staffIdSchema.parse('ch'), account: 'Chaihao', name: '柴浩', positionId: none.id, state: 'active', roles: [] });
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
expect((await h.org.getStaff(staffIdSchema.parse('ch'))).position.code).toBe('TEST');
|
||||||
|
expect((await h.org.workGroupOf(staffIdSchema.parse('ch')))?.id).toBe('wg-solo-chaihao');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('工作组(ADR-011 原样)', () => {
|
||||||
|
it('建组 / 取组 / 绑定快照', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.org.ensureSeeded();
|
||||||
|
const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] });
|
||||||
|
const g = await h.org.saveWorkGroup({ name: 'A 组', ownerId: a.id, members: { BD: a.id } });
|
||||||
|
expect((await h.org.workGroupOf(a.id))?.id).toBe(g.id);
|
||||||
|
expect(await h.org.bindingOf(a.id)).toEqual({ BD: 'a' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* 口令散列(ADR-010)—— 守的是「明文不可逆、比对不早退、坏串不抛错」。
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { hashPassword, newSessionToken, verifyPassword } from '../internal/adapter/password.ts';
|
|
||||||
|
|
||||||
describe('hashPassword / verifyPassword', () => {
|
|
||||||
it('散列串自描述参数,且不含明文', async () => {
|
|
||||||
const hash = await hashPassword('jcai-dev-2026');
|
|
||||||
expect(hash.split('$')).toHaveLength(6);
|
|
||||||
expect(hash.startsWith('scrypt$16384$8$1$')).toBe(true);
|
|
||||||
expect(hash).not.toContain('jcai-dev-2026');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('同一口令两次散列不同 —— 盐是随机的', async () => {
|
|
||||||
expect(await hashPassword('same')).not.toBe(await hashPassword('same'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('对的口令过,错的不过', async () => {
|
|
||||||
const hash = await hashPassword('correct horse');
|
|
||||||
expect(await verifyPassword('correct horse', hash)).toBe(true);
|
|
||||||
expect(await verifyPassword('correct hors', hash)).toBe(false);
|
|
||||||
expect(await verifyPassword('', hash)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('未设口令(null / undefined)一律不过,不抛错', async () => {
|
|
||||||
// 「账号存在但没口令」不能变成一条能登进去的缝
|
|
||||||
expect(await verifyPassword('anything', null)).toBe(false);
|
|
||||||
expect(await verifyPassword('anything', undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('坏串一律不过,不抛错 —— 登录失败的原因不外泄', async () => {
|
|
||||||
for (const bad of ['', 'plaintext', 'scrypt$x$8$1$aa$bb', 'bcrypt$16384$8$1$aa$bb', 'scrypt$16384$8$1$aa']) {
|
|
||||||
expect(await verifyPassword('anything', bad)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('按记录自己的参数重算 —— 日后调参不必迁移旧记录', async () => {
|
|
||||||
// 用一组非默认参数造串,再以默认参数的实现去验:能过才说明读的是串里的参数
|
|
||||||
const legacy = 'scrypt$1024$8$1$c2FsdHNhbHQ$';
|
|
||||||
expect(legacy.split('$')[1]).toBe('1024');
|
|
||||||
// 空 hash 段应判假(下面单独覆盖),这里只确认解析不炸
|
|
||||||
expect(await verifyPassword('x', legacy)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('newSessionToken', () => {
|
|
||||||
it('不透明、够长、每次不同 —— 不编码任何身份信息', () => {
|
|
||||||
const a = newSessionToken();
|
|
||||||
const b = newSessionToken();
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
// 32 字节 base64url ≈ 43 字符
|
|
||||||
expect(a.length).toBeGreaterThanOrEqual(43);
|
|
||||||
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
||||||
|
*
|
||||||
|
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
|
||||||
|
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
|
||||||
|
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
|
||||||
*/
|
*/
|
||||||
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
|
import type {
|
||||||
|
InFlightWork,
|
||||||
|
Position,
|
||||||
|
PositionId,
|
||||||
|
Principal,
|
||||||
|
RoleAccess,
|
||||||
|
RosterSyncOutcome,
|
||||||
|
Staff,
|
||||||
|
StaffId,
|
||||||
|
Surface,
|
||||||
|
WorkGroup,
|
||||||
|
WorkGroupBinding,
|
||||||
|
} from '@contracts';
|
||||||
|
|
||||||
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
|
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||||
|
|
||||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||||
export type StaffWithPosition = Staff & { readonly position: Position };
|
export type StaffWithPosition = Staff & { readonly position: Position };
|
||||||
|
|
||||||
export type CreateStaffInput = {
|
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
||||||
|
export type Identity = {
|
||||||
|
readonly sub: string;
|
||||||
|
/** preferred_username */
|
||||||
readonly account: string;
|
readonly account: string;
|
||||||
|
/** name claim,缺则回退 account */
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly positionId: string;
|
readonly email?: string;
|
||||||
|
/** realm 角色,噪声角色已滤、已排序 */
|
||||||
|
readonly roles: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export class StaffNotFoundError extends Error {
|
export class StaffNotFoundError extends Error {
|
||||||
@@ -28,86 +50,6 @@ export class PositionNotFoundError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccountTakenError extends Error {
|
|
||||||
constructor(readonly account: string) {
|
|
||||||
super(`账号已存在:${account}`);
|
|
||||||
this.name = 'AccountTakenError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
|
|
||||||
export class DisableBlockedError extends Error {
|
|
||||||
constructor(readonly check: DisableCheck) {
|
|
||||||
super(`员工停用前置检查未过:在途任务 ${check.openTasks.length} 件、待答卡 ${check.openCards.length} 张`);
|
|
||||||
this.name = 'DisableBlockedError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
|
|
||||||
export type IssuedSession = {
|
|
||||||
readonly token: string;
|
|
||||||
readonly expiresAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrgService = {
|
|
||||||
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
|
||||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
|
||||||
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
|
|
||||||
createStaff(input: CreateStaffInput): Promise<Staff>;
|
|
||||||
/** 停用前置检查 —— 只读,治理台先看再点 */
|
|
||||||
checkDisable(staffId: StaffId): Promise<DisableCheck>;
|
|
||||||
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
|
|
||||||
disableStaff(staffId: StaffId): Promise<void>;
|
|
||||||
listGrants(positionId: string): Promise<Grant[]>;
|
|
||||||
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
|
|
||||||
/** 岗位权限校验 —— app 层鉴权中间件读它 */
|
|
||||||
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
|
|
||||||
loadSession(account: string): Promise<Session | undefined>;
|
|
||||||
|
|
||||||
/* ── 登录会话(ADR-010)—— 中间件在 app 层,身份判定读这里 ───────────── */
|
|
||||||
|
|
||||||
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
|
|
||||||
login(account: string, password: string): Promise<IssuedSession | undefined>;
|
|
||||||
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
|
|
||||||
logout(token: string): Promise<void>;
|
|
||||||
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
|
|
||||||
resolveSession(token: string): Promise<Session | undefined>;
|
|
||||||
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
|
|
||||||
setPassword(staffId: StaffId, password: string): Promise<void>;
|
|
||||||
|
|
||||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
|
||||||
|
|
||||||
/** 建组 / 改组;id 留空即新建 */
|
|
||||||
saveWorkGroup(input: {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
ownerId: StaffId;
|
|
||||||
members: Readonly<Record<string, StaffId>>;
|
|
||||||
}): Promise<WorkGroup>;
|
|
||||||
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
|
||||||
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
|
||||||
/** 装进工作流的快照:改组不改已起的单 */
|
|
||||||
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
|
||||||
|
|
||||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
|
|
||||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
|
||||||
ensureSeeded(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
|
||||||
export type Identity = {
|
|
||||||
readonly sub: string;
|
|
||||||
/** preferred_username */
|
|
||||||
readonly account: string;
|
|
||||||
/** name claim,缺则回退 account */
|
|
||||||
readonly name: string;
|
|
||||||
readonly email?: string;
|
|
||||||
/** realm 角色,噪声角色已滤、已排序 */
|
|
||||||
readonly roles: readonly string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
|
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
|
||||||
export class TokenRejectedError extends Error {
|
export class TokenRejectedError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -131,3 +73,52 @@ export class AccessLockoutError extends Error {
|
|||||||
this.name = 'AccessLockoutError';
|
this.name = 'AccessLockoutError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OrgService = {
|
||||||
|
/* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */
|
||||||
|
|
||||||
|
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
||||||
|
authenticate(token: string): Promise<Principal>;
|
||||||
|
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
|
||||||
|
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||||||
|
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
|
||||||
|
registerIdentity(identity: Identity): Promise<Staff>;
|
||||||
|
|
||||||
|
/* ── 名册与岗位 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
||||||
|
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||||
|
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
|
||||||
|
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||||
|
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
||||||
|
/** Admin API 同步;未配 provisioning 回 configured:false */
|
||||||
|
syncRoster(): Promise<RosterSyncOutcome>;
|
||||||
|
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
|
||||||
|
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
||||||
|
|
||||||
|
/* ── 角色 → 端 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>;
|
||||||
|
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
|
||||||
|
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
|
||||||
|
deleteRoleAccess(role: string): Promise<void>;
|
||||||
|
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||||||
|
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||||||
|
|
||||||
|
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
||||||
|
|
||||||
|
/** 建组 / 改组;id 留空即新建 */
|
||||||
|
saveWorkGroup(input: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: StaffId;
|
||||||
|
members: Readonly<Record<string, StaffId>>;
|
||||||
|
}): Promise<WorkGroup>;
|
||||||
|
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
||||||
|
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
||||||
|
/** 装进工作流的快照:改组不改已起的单 */
|
||||||
|
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
||||||
|
|
||||||
|
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
|
||||||
|
ensureSeeded(): Promise<void>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,67 +2,59 @@
|
|||||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
*/
|
*/
|
||||||
export type {
|
export type {
|
||||||
CreateStaffInput,
|
Identity,
|
||||||
DisableCheck,
|
InFlightWork,
|
||||||
Grant,
|
|
||||||
GrantAction,
|
|
||||||
GrantObject,
|
|
||||||
IssuedSession,
|
|
||||||
OrgService,
|
OrgService,
|
||||||
Position,
|
Position,
|
||||||
Session,
|
Principal,
|
||||||
|
RoleAccess,
|
||||||
|
RosterSyncOutcome,
|
||||||
Staff,
|
Staff,
|
||||||
StaffWithPosition,
|
StaffWithPosition,
|
||||||
|
Surface,
|
||||||
WorkGroup,
|
WorkGroup,
|
||||||
WorkGroupBinding,
|
WorkGroupBinding,
|
||||||
} from './contract.ts';
|
} from './contract.ts';
|
||||||
export {
|
export {
|
||||||
AccountTakenError,
|
AccessLockoutError,
|
||||||
DisableBlockedError,
|
AccountDisabledError,
|
||||||
PositionNotFoundError,
|
PositionNotFoundError,
|
||||||
StaffNotFoundError,
|
StaffNotFoundError,
|
||||||
|
TokenRejectedError,
|
||||||
} from './contract.ts';
|
} from './contract.ts';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Clock,
|
Clock,
|
||||||
GrantRepository,
|
|
||||||
IdGenerator,
|
IdGenerator,
|
||||||
|
IdentityPatch,
|
||||||
InFlightWorkProbe,
|
InFlightWorkProbe,
|
||||||
PasswordHasher,
|
|
||||||
PositionRepository,
|
PositionRepository,
|
||||||
SessionRepository,
|
RoleAccessRepository,
|
||||||
|
RosterSource,
|
||||||
|
RosterUser,
|
||||||
|
StaffRecord,
|
||||||
StaffRepository,
|
StaffRepository,
|
||||||
|
TokenVerifier,
|
||||||
|
VerifiedClaims,
|
||||||
WorkGroupRepository,
|
WorkGroupRepository,
|
||||||
} from './port.ts';
|
} from './port.ts';
|
||||||
|
|
||||||
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
||||||
export { createOrgService } from './internal/usecase/org-service.ts';
|
export { createOrgService } from './internal/usecase/org-service.ts';
|
||||||
|
|
||||||
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
|
|
||||||
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
|
|
||||||
|
|
||||||
export {
|
|
||||||
createGrantRepository,
|
|
||||||
createPositionRepository,
|
|
||||||
createSessionRepository,
|
|
||||||
createStaffRepository,
|
|
||||||
createWorkGroupRepository,
|
|
||||||
objectKeyOf,
|
|
||||||
} from './internal/adapter/org-repository.ts';
|
|
||||||
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
|
|
||||||
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
|
|
||||||
|
|
||||||
/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */
|
|
||||||
export type { Identity } from './contract.ts';
|
|
||||||
export { AccessLockoutError, AccountDisabledError, TokenRejectedError } from './contract.ts';
|
|
||||||
export type { IdentityPatch, RosterSource, RosterUser, TokenVerifier, VerifiedClaims } from './port.ts';
|
|
||||||
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
||||||
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
||||||
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
||||||
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||||
|
|
||||||
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
|
|
||||||
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
export {
|
||||||
|
createPositionRepository,
|
||||||
|
createRoleAccessRepository,
|
||||||
|
createStaffRepository,
|
||||||
|
createWorkGroupRepository,
|
||||||
|
} from './internal/adapter/org-repository.ts';
|
||||||
|
export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
|
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
|
||||||
*/
|
*/
|
||||||
import { and, eq, gt } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||||
import { grantSchema, positionIdSchema, positionSchema, staffIdSchema, staffSchema, workGroupSchema } from '@contracts';
|
import { SURFACES, positionIdSchema, positionSchema, staffSchema, workGroupSchema } from '@contracts';
|
||||||
import type { Db } from '@shared/db.ts';
|
import type { Db } from '@shared/db.ts';
|
||||||
import type { GrantRepository, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository } from '../../port.ts';
|
import type {
|
||||||
import { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './org-schema.ts';
|
PositionRepository,
|
||||||
|
RoleAccessRepository,
|
||||||
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
|
StaffRecord,
|
||||||
export function objectKeyOf(object: GrantObject): string {
|
StaffRepository,
|
||||||
switch (object.kind) {
|
WorkGroupRepository,
|
||||||
case 'data':
|
} from '../../port.ts';
|
||||||
return `data:${object.domain}.${object.object}${object.field === undefined ? '' : `.${object.field}`}`;
|
import { positionTable, roleAccessTable, staffTable, workGroupTable } from './org-schema.ts';
|
||||||
case 'governance':
|
|
||||||
return `governance:${object.function}`;
|
|
||||||
case 'task':
|
|
||||||
return `task${object.activityId === undefined ? '' : `:${object.activityId}`}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPositionRepository(db: Db): PositionRepository {
|
export function createPositionRepository(db: Db): PositionRepository {
|
||||||
const toPosition = (row: typeof positionTable.$inferSelect): Position =>
|
const toPosition = (row: typeof positionTable.$inferSelect): Position =>
|
||||||
@@ -59,109 +53,100 @@ export function createPositionRepository(db: Db): PositionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createStaffRepository(db: Db): StaffRepository {
|
export function createStaffRepository(db: Db): StaffRepository {
|
||||||
const toStaff = (row: typeof staffTable.$inferSelect): Staff =>
|
const toRecord = (row: typeof staffTable.$inferSelect): StaffRecord => ({
|
||||||
staffSchema.parse({
|
...staffSchema.parse({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
account: row.account,
|
account: row.account,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
...(row.email === null ? {} : { email: row.email }),
|
||||||
positionId: row.positionId,
|
positionId: row.positionId,
|
||||||
state: row.state,
|
state: row.state,
|
||||||
});
|
roles: row.roles,
|
||||||
|
}),
|
||||||
|
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
|
||||||
|
...(row.syncedAt === null ? {} : { syncedAt: row.syncedAt }),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async list() {
|
async list() {
|
||||||
const rows = await db.select().from(staffTable).orderBy(staffTable.account);
|
const rows = await db.select().from(staffTable).orderBy(staffTable.account);
|
||||||
return rows.map(toStaff);
|
return rows.map(toRecord);
|
||||||
},
|
},
|
||||||
async findById(id: StaffId) {
|
async findById(id: StaffId) {
|
||||||
const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1);
|
const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1);
|
||||||
return rows[0] === undefined ? undefined : toStaff(rows[0]);
|
return rows[0] === undefined ? undefined : toRecord(rows[0]);
|
||||||
},
|
|
||||||
async findByAccount(account: string) {
|
|
||||||
const rows = await db.select().from(staffTable).where(eq(staffTable.account, account)).limit(1);
|
|
||||||
return rows[0] === undefined ? undefined : toStaff(rows[0]);
|
|
||||||
},
|
},
|
||||||
async listByPosition(positionId: PositionId) {
|
async listByPosition(positionId: PositionId) {
|
||||||
const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId));
|
const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId));
|
||||||
return rows.map(toStaff);
|
return rows.map(toRecord);
|
||||||
},
|
},
|
||||||
async insert(staff: Staff) {
|
async insert(staff: Staff, marks) {
|
||||||
const [row] = await db.insert(staffTable).values(staff).returning();
|
const [row] = await db
|
||||||
|
.insert(staffTable)
|
||||||
|
.values({
|
||||||
|
id: staff.id,
|
||||||
|
account: staff.account,
|
||||||
|
name: staff.name,
|
||||||
|
email: staff.email ?? null,
|
||||||
|
positionId: staff.positionId,
|
||||||
|
state: staff.state,
|
||||||
|
roles: [...staff.roles],
|
||||||
|
lastSeenAt: marks.lastSeenAt ?? null,
|
||||||
|
syncedAt: marks.syncedAt ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
if (row === undefined) throw new Error('员工写入未返回行');
|
if (row === undefined) throw new Error('员工写入未返回行');
|
||||||
return toStaff(row);
|
return toRecord(row);
|
||||||
},
|
},
|
||||||
async setState(id: StaffId, state) {
|
async updateIdentity(id: StaffId, patch) {
|
||||||
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
|
await db
|
||||||
|
.update(staffTable)
|
||||||
|
.set({
|
||||||
|
account: patch.account,
|
||||||
|
name: patch.name,
|
||||||
|
email: patch.email ?? null,
|
||||||
|
roles: [...patch.roles],
|
||||||
|
...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }),
|
||||||
|
})
|
||||||
|
.where(eq(staffTable.id, id));
|
||||||
},
|
},
|
||||||
async passwordHashOf(account: string) {
|
async setPosition(id: StaffId, positionId: PositionId) {
|
||||||
const rows = await db
|
await db.update(staffTable).set({ positionId }).where(eq(staffTable.id, id));
|
||||||
.select({ hash: staffTable.passwordHash })
|
|
||||||
.from(staffTable)
|
|
||||||
.where(eq(staffTable.account, account))
|
|
||||||
.limit(1);
|
|
||||||
return rows[0]?.hash ?? undefined;
|
|
||||||
},
|
},
|
||||||
async setPasswordHash(id: StaffId, hash: string) {
|
async setState(id: StaffId, state, syncedAt: Date) {
|
||||||
await db.update(staffTable).set({ passwordHash: hash }).where(eq(staffTable.id, id));
|
await db.update(staffTable).set({ state, syncedAt }).where(eq(staffTable.id, id));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话仓储 —— 过期由 `expires_at` 判定,读时顺带滑动续期(ADR-010) */
|
/** 角色 → 端:契约里是端名数组,表里是三列布尔 —— 这里来回翻译 */
|
||||||
export function createSessionRepository(db: Db): SessionRepository {
|
export function createRoleAccessRepository(db: Db): RoleAccessRepository {
|
||||||
return {
|
const toRow = (r: typeof roleAccessTable.$inferSelect): RoleAccess => ({
|
||||||
async issue({ token, staffId, expiresAt }) {
|
role: r.role,
|
||||||
await db.insert(sessionTable).values({ id: token, staffId, expiresAt });
|
ends: SURFACES.filter((s) => r[s]),
|
||||||
},
|
});
|
||||||
async touch(token: string, now: Date, expiresAt: Date) {
|
|
||||||
const rows = await db
|
|
||||||
.update(sessionTable)
|
|
||||||
.set({ lastSeenAt: now, expiresAt })
|
|
||||||
.where(and(eq(sessionTable.id, token), gt(sessionTable.expiresAt, now)))
|
|
||||||
.returning({ staffId: sessionTable.staffId });
|
|
||||||
const row = rows[0];
|
|
||||||
return row === undefined ? undefined : { staffId: staffIdSchema.parse(row.staffId) };
|
|
||||||
},
|
|
||||||
async revoke(token: string) {
|
|
||||||
await db.delete(sessionTable).where(eq(sessionTable.id, token));
|
|
||||||
},
|
|
||||||
async revokeAllOf(staffId: StaffId) {
|
|
||||||
await db.delete(sessionTable).where(eq(sessionTable.staffId, staffId));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGrantRepository(db: Db): GrantRepository {
|
|
||||||
const toGrant = (row: typeof grantTable.$inferSelect): Grant =>
|
|
||||||
grantSchema.parse({ positionId: row.positionId, object: row.object, action: row.action });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async listByPosition(positionId: PositionId) {
|
async list() {
|
||||||
const rows = await db
|
const rows = await db.select().from(roleAccessTable).orderBy(roleAccessTable.role);
|
||||||
.select()
|
return rows.map(toRow);
|
||||||
.from(grantTable)
|
|
||||||
.where(and(eq(grantTable.positionId, positionId), eq(grantTable.enabled, true)));
|
|
||||||
return rows.map(toGrant);
|
|
||||||
},
|
},
|
||||||
async replaceForPosition(positionId: PositionId, grants) {
|
async upsert(row: RoleAccess) {
|
||||||
return db.transaction(async (tx) => {
|
const flags = {
|
||||||
await tx.delete(grantTable).where(eq(grantTable.positionId, positionId));
|
design: row.ends.includes('design'),
|
||||||
if (grants.length === 0) return [];
|
ops: row.ends.includes('ops'),
|
||||||
const rows = await tx
|
workbench: row.ends.includes('workbench'),
|
||||||
.insert(grantTable)
|
};
|
||||||
.values(
|
const [saved] = await db
|
||||||
grants.map((g) => ({
|
.insert(roleAccessTable)
|
||||||
positionId,
|
.values({ role: row.role, ...flags })
|
||||||
objectKey: objectKeyOf(g.object),
|
.onConflictDoUpdate({ target: roleAccessTable.role, set: { ...flags, updatedAt: new Date() } })
|
||||||
action: g.action,
|
.returning();
|
||||||
object: g.object,
|
if (saved === undefined) throw new Error('角色→端写入未返回行');
|
||||||
enabled: true,
|
return toRow(saved);
|
||||||
})),
|
},
|
||||||
)
|
async remove(role: string) {
|
||||||
.onConflictDoNothing()
|
await db.delete(roleAccessTable).where(eq(roleAccessTable.role, role));
|
||||||
.returning();
|
|
||||||
return rows.map(toGrant);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。
|
* org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。
|
||||||
* drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。
|
* drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。
|
||||||
|
* 会话表与岗位授权表已随 ADR-012 退役(迁移 0032 drop)。
|
||||||
*/
|
*/
|
||||||
import { boolean, index, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sys } from '@shared/db.ts';
|
import { sys } from '@shared/db.ts';
|
||||||
|
|
||||||
export const positionTable = sys.table(
|
export const positionTable = sys.table(
|
||||||
@@ -17,55 +18,36 @@ export const positionTable = sys.table(
|
|||||||
(t) => [uniqueIndex('position_code_key').on(t.code)],
|
(t) => [uniqueIndex('position_code_key').on(t.code)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 员工 —— `id` 就是 Keycloak 的 sub(ADR-012);`account` 只作显示 */
|
||||||
export const staffTable = sys.table(
|
export const staffTable = sys.table(
|
||||||
'staff',
|
'staff',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
account: text('account').notNull(),
|
account: text('account').notNull(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
|
email: text('email'),
|
||||||
positionId: text('position_id')
|
positionId: text('position_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => positionTable.id),
|
.references(() => positionTable.id),
|
||||||
|
/** 只由名册同步置 disabled;有效 token 不改它 */
|
||||||
state: text('state').notNull().default('active'),
|
state: text('state').notNull().default('active'),
|
||||||
/** 口令散列(ADR-010):`scrypt$N$r$p$salt$hash`,明文永不落库 */
|
/** realm 角色快照(签发时;噪声角色已滤) */
|
||||||
passwordHash: text('password_hash'),
|
roles: jsonb('roles').$type<string[]>().notNull().default([]),
|
||||||
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||||
|
syncedAt: timestamp('synced_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
|
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/** 角色 → 端(ADR-012):一行一个 realm 角色,三列布尔 —— 运营中心可改 */
|
||||||
* 会话(ADR-010)—— 服务端表而非签名 cookie:**登出必须立刻失效**,
|
export const roleAccessTable = sys.table('role_access', {
|
||||||
* 且停用员工时要能一并踢掉在途会话。id 即 token,不透明随机串。
|
role: text('role').primaryKey(),
|
||||||
*/
|
design: boolean('design').notNull().default(false),
|
||||||
export const sessionTable = sys.table(
|
ops: boolean('ops').notNull().default(false),
|
||||||
'session',
|
workbench: boolean('workbench').notNull().default(false),
|
||||||
{
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
id: text('id').primaryKey(),
|
});
|
||||||
staffId: text('staff_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => staffTable.id, { onDelete: 'cascade' }),
|
|
||||||
issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
||||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [index('session_staff_idx').on(t.staffId), index('session_expires_idx').on(t.expiresAt)],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const grantTable = sys.table(
|
|
||||||
'position_grant',
|
|
||||||
{
|
|
||||||
positionId: text('position_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => positionTable.id, { onDelete: 'cascade' }),
|
|
||||||
/** 客体的规范化串:data:<域>.<对象>[.<字段>] / governance:<功能> / task[:<活动>] */
|
|
||||||
objectKey: text('object_key').notNull(),
|
|
||||||
action: text('action').notNull(),
|
|
||||||
object: jsonb('object').notNull(),
|
|
||||||
enabled: boolean('enabled').notNull().default(true),
|
|
||||||
},
|
|
||||||
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。
|
* 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。
|
|
||||||
*
|
|
||||||
* 串格式自描述:`scrypt$N$r$p$<salt-b64url>$<hash-b64url>`。
|
|
||||||
* 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。
|
|
||||||
* 明文永不落库,比对走 `timingSafeEqual`(等长常数时间,不因前缀相同而早退)。
|
|
||||||
*/
|
|
||||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
/** 默认代价参数:N=16384 约 16MB 内存、单次 ~50ms —— 内网 ≤50 人足够 */
|
|
||||||
const N = 16_384;
|
|
||||||
const R = 8;
|
|
||||||
const P = 1;
|
|
||||||
const KEY_LEN = 32;
|
|
||||||
const SALT_LEN = 16;
|
|
||||||
|
|
||||||
const b64 = (buf: Buffer) => buf.toString('base64url');
|
|
||||||
|
|
||||||
function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// maxmem 需随 N 抬高:默认 32MB 挡不住 N=16384(约需 128*N*r ≈ 16MB,留两倍余量)
|
|
||||||
scrypt(password, salt, KEY_LEN, { N: n, r, p, maxmem: 256 * n * r }, (error, key) => {
|
|
||||||
if (error !== null) reject(error);
|
|
||||||
else resolve(key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
|
||||||
const salt = randomBytes(SALT_LEN);
|
|
||||||
const hash = await derive(password, salt, N, R, P);
|
|
||||||
return `scrypt$${N}$${R}$${P}$${b64(salt)}$${b64(hash)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 比对。存的串不合格式 / 员工尚未设口令 → 一律 false,不抛错:
|
|
||||||
* 登录失败的原因不外泄(账号不存在、未设口令、口令错,对外都是同一句话)。
|
|
||||||
*/
|
|
||||||
export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {
|
|
||||||
if (stored === null || stored === undefined) return false;
|
|
||||||
const parts = stored.split('$');
|
|
||||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
|
||||||
|
|
||||||
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
|
|
||||||
const n = Number(nRaw);
|
|
||||||
const r = Number(rRaw);
|
|
||||||
const p = Number(pRaw);
|
|
||||||
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
|
||||||
|
|
||||||
const expected = Buffer.from(hashRaw ?? '', 'base64url');
|
|
||||||
if (expected.length === 0) return false;
|
|
||||||
|
|
||||||
const actual = await derive(password, Buffer.from(saltRaw ?? '', 'base64url'), n, r, p).catch(() => undefined);
|
|
||||||
if (actual === undefined || actual.length !== expected.length) return false;
|
|
||||||
return timingSafeEqual(actual, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 会话 token —— 32 字节密码学随机,不透明(不编码任何身份信息) */
|
|
||||||
export function newSessionToken(): string {
|
|
||||||
return b64(randomBytes(32));
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* 岗位授权判定 —— 纯逻辑,零 IO(编码规范 §5.3)。
|
|
||||||
*
|
|
||||||
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
|
|
||||||
* 语义出处:OS 02 员工管理。
|
|
||||||
*/
|
|
||||||
import type { Grant, GrantAction, GrantObject } from '@contracts';
|
|
||||||
|
|
||||||
/** 动作蕴含:治理 ⊃ 写 ⊃ 读 —— 有写权即可读,有治理权即可写 */
|
|
||||||
const IMPLIES: Readonly<Record<GrantAction, readonly GrantAction[]>> = {
|
|
||||||
govern: ['govern', 'write', 'read'],
|
|
||||||
write: ['write', 'read'],
|
|
||||||
read: ['read'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function actionCovers(granted: GrantAction, required: GrantAction): boolean {
|
|
||||||
return IMPLIES[granted].includes(required);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 客体覆盖:授权客体是否覆盖被请求客体。
|
|
||||||
* 数据客体分三级:域.对象.字段 —— 授到域即覆盖其下全部对象,授到对象即覆盖其下全部字段。
|
|
||||||
*/
|
|
||||||
export function objectCovers(granted: GrantObject, required: GrantObject): boolean {
|
|
||||||
if (granted.kind !== required.kind) return false;
|
|
||||||
|
|
||||||
switch (granted.kind) {
|
|
||||||
case 'data': {
|
|
||||||
const req = required as Extract<GrantObject, { kind: 'data' }>;
|
|
||||||
if (granted.domain !== req.domain) return false;
|
|
||||||
// 授权到域级(未指定对象名的通配 '*')
|
|
||||||
if (granted.object !== '*' && granted.object !== req.object) return false;
|
|
||||||
if (granted.field === undefined) return true;
|
|
||||||
return granted.field === req.field;
|
|
||||||
}
|
|
||||||
case 'governance': {
|
|
||||||
const req = required as Extract<GrantObject, { kind: 'governance' }>;
|
|
||||||
return granted.function === req.function;
|
|
||||||
}
|
|
||||||
case 'task': {
|
|
||||||
const req = required as Extract<GrantObject, { kind: 'task' }>;
|
|
||||||
// 未限定活动 = 覆盖全部任务
|
|
||||||
if (granted.activityId === undefined) return true;
|
|
||||||
return granted.activityId === req.activityId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 任一授权覆盖即通过 */
|
|
||||||
export function isAuthorized(
|
|
||||||
grants: readonly Grant[],
|
|
||||||
object: GrantObject,
|
|
||||||
action: GrantAction,
|
|
||||||
): boolean {
|
|
||||||
return grants.some((g) => actionCovers(g.action, action) && objectCovers(g.object, object));
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 停用前置检查 —— 纯逻辑,零 IO(编码规范 §5.3)。
|
|
||||||
*
|
|
||||||
* OS 02:员工停用前,其在途任务与待答卡须先改派完毕。
|
|
||||||
*/
|
|
||||||
import type { DisableCheck, StaffId } from '@contracts';
|
|
||||||
|
|
||||||
export type InFlightWork = {
|
|
||||||
readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>;
|
|
||||||
readonly openCards: ReadonlyArray<{ cardId: string; title: string }>;
|
|
||||||
readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildDisableCheck(staffId: StaffId, work: InFlightWork): DisableCheck {
|
|
||||||
const openTasks = [...work.openTasks];
|
|
||||||
const openCards = [...work.openCards];
|
|
||||||
const openApprovals = [...work.openApprovals];
|
|
||||||
return {
|
|
||||||
staffId,
|
|
||||||
blocked: openTasks.length > 0 || openCards.length > 0 || openApprovals.length > 0,
|
|
||||||
openTasks,
|
|
||||||
openCards,
|
|
||||||
openApprovals,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 种子数据(README《种子数据》)—— 纯常量,零 IO。
|
* 种子数据 —— 纯常量,零 IO。
|
||||||
|
* 人不再播种(名册来自鉴权中心,ADR-012);这里只有岗位与缺省的角色→端。
|
||||||
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
|
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
|
||||||
*/
|
*/
|
||||||
import type { RoleAccess } from '@contracts';
|
import type { RoleAccess } from '@contracts';
|
||||||
|
|
||||||
|
export const NONE_POSITION_CODE = 'NONE';
|
||||||
|
export const TEST_POSITION_CODE = 'TEST';
|
||||||
|
|
||||||
export const POSITION_SEED = [
|
export const POSITION_SEED = [
|
||||||
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
|
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
|
||||||
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
|
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
|
||||||
@@ -15,88 +19,12 @@ export const POSITION_SEED = [
|
|||||||
* 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 ——
|
* 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 ——
|
||||||
* 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。
|
* 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。
|
||||||
*/
|
*/
|
||||||
{ code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
|
{ code: TEST_POSITION_CODE, name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
|
||||||
/** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */
|
/** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */
|
||||||
{ code: 'NONE', name: '未分配', note: '未指派岗位;流程包不得声明它' },
|
{ code: NONE_POSITION_CODE, name: '未分配', note: '未指派岗位;流程包不得声明它' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
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' },
|
|
||||||
// 两个测试账号:各自一个「全岗位指向自己」的工作组,起单即整条流归自己
|
|
||||||
{ account: 'AI', name: 'AI', position: 'TEST' },
|
|
||||||
{ account: 'Chaihao', name: '柴浩', position: 'TEST' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
|
|
||||||
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
|
|
||||||
|
|
||||||
export type PositionSeed = (typeof POSITION_SEED)[number];
|
export type PositionSeed = (typeof POSITION_SEED)[number];
|
||||||
export type StaffSeed = (typeof STAFF_SEED)[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 岗位默认授权 —— 治理台九功能对 PM 全开,其余岗位只开与其职责相关的读。
|
|
||||||
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
|
|
||||||
*/
|
|
||||||
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
|
|
||||||
/** 测试岗与 PM 同权:一个人要走完整条流,还得在治理台看过程 */
|
|
||||||
TEST: [
|
|
||||||
{ fn: 'task', action: 'govern' },
|
|
||||||
{ fn: 'staff', action: 'govern' },
|
|
||||||
{ fn: 'process', action: 'govern' },
|
|
||||||
{ fn: 'trigger', action: 'govern' },
|
|
||||||
{ fn: 'activity', action: 'govern' },
|
|
||||||
{ fn: 'tool', action: 'govern' },
|
|
||||||
{ fn: 'data', action: 'govern' },
|
|
||||||
{ fn: 'memory', action: 'govern' },
|
|
||||||
{ fn: 'system', action: 'govern' },
|
|
||||||
],
|
|
||||||
PM: [
|
|
||||||
{ fn: 'task', action: 'govern' },
|
|
||||||
{ fn: 'staff', action: 'govern' },
|
|
||||||
{ fn: 'process', action: 'govern' },
|
|
||||||
{ fn: 'trigger', action: 'govern' },
|
|
||||||
{ fn: 'activity', action: 'govern' },
|
|
||||||
{ fn: 'tool', action: 'govern' },
|
|
||||||
{ fn: 'data', action: 'govern' },
|
|
||||||
{ fn: 'memory', action: 'govern' },
|
|
||||||
{ fn: 'system', action: 'govern' },
|
|
||||||
],
|
|
||||||
BD: [
|
|
||||||
{ fn: 'task', action: 'read' },
|
|
||||||
{ fn: 'data', action: 'read' },
|
|
||||||
],
|
|
||||||
FAE: [
|
|
||||||
{ fn: 'task', action: 'read' },
|
|
||||||
{ fn: 'data', action: 'read' },
|
|
||||||
],
|
|
||||||
PRM: [
|
|
||||||
{ fn: 'task', action: 'read' },
|
|
||||||
{ fn: 'data', action: 'read' },
|
|
||||||
],
|
|
||||||
OP: [
|
|
||||||
{ fn: 'task', action: 'read' },
|
|
||||||
{ fn: 'data', action: 'read' },
|
|
||||||
{ fn: 'memory', action: 'read' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 测试账号的**独立工作组**(ADR-011)—— 所有岗位都指向本人。
|
|
||||||
* 起单即整条流(A1…A7 + 审批点)全部落到自己工位,一个人从头走到尾。
|
|
||||||
*/
|
|
||||||
export const SOLO_GROUP_ACCOUNTS = ['AI', 'Chaihao'] as const;
|
|
||||||
|
|
||||||
/** 组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */
|
|
||||||
export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const;
|
|
||||||
|
|
||||||
/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export const NONE_POSITION_CODE = 'NONE';
|
|
||||||
export const TEST_POSITION_CODE = 'TEST';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 缺省的角色 → 端(表为空时播,之后由运营中心改):
|
* 缺省的角色 → 端(表为空时播,之后由运营中心改):
|
||||||
@@ -106,3 +34,6 @@ export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [
|
|||||||
{ role: 'admin', ends: ['design', 'ops', 'workbench'] },
|
{ role: 'admin', ends: ['design', 'ops', 'workbench'] },
|
||||||
{ role: 'user', ends: ['workbench'] },
|
{ role: 'user', ends: ['workbench'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 独立测试组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */
|
||||||
|
export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const;
|
||||||
|
|||||||
@@ -1,51 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。
|
* org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。
|
||||||
|
*
|
||||||
|
* 身份归统一鉴权中心(ADR-012):
|
||||||
|
* authenticate = 验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × 角色→端表 → Principal。
|
||||||
|
* `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 ——
|
||||||
|
* 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。
|
||||||
*/
|
*/
|
||||||
import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, Staff, StaffId } from '@contracts';
|
import type { Position, Principal, RoleAccess, Staff } from '@contracts';
|
||||||
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
|
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||||
import type {
|
import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts';
|
||||||
CreateStaffInput,
|
import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts';
|
||||||
IssuedSession,
|
|
||||||
OrgService,
|
|
||||||
StaffWithPosition,
|
|
||||||
} from '../../contract.ts';
|
|
||||||
import {
|
|
||||||
AccountTakenError,
|
|
||||||
DisableBlockedError,
|
|
||||||
PositionNotFoundError,
|
|
||||||
StaffNotFoundError,
|
|
||||||
} from '../../contract.ts';
|
|
||||||
import type {
|
import type {
|
||||||
Clock,
|
Clock,
|
||||||
GrantRepository,
|
|
||||||
IdGenerator,
|
IdGenerator,
|
||||||
InFlightWorkProbe,
|
InFlightWorkProbe,
|
||||||
PasswordHasher,
|
|
||||||
PositionRepository,
|
PositionRepository,
|
||||||
SessionRepository,
|
RoleAccessRepository,
|
||||||
|
RosterSource,
|
||||||
|
StaffRecord,
|
||||||
StaffRepository,
|
StaffRepository,
|
||||||
|
TokenVerifier,
|
||||||
WorkGroupRepository,
|
WorkGroupRepository,
|
||||||
} from '../../port.ts';
|
} from '../../port.ts';
|
||||||
import { isAuthorized } from '../domain/authorization.ts';
|
import { identityOf } from '../domain/claims.ts';
|
||||||
import { buildDisableCheck } from '../domain/disable-check.ts';
|
import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts';
|
||||||
import { POSITION_GRANT_SEED, POSITION_SEED, SOLO_GROUP_ACCOUNTS, SOLO_GROUP_POSITIONS, STAFF_SEED } from '../domain/seed-data.ts';
|
import { buildInFlightWork } from '../domain/in-flight.ts';
|
||||||
|
import { diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts';
|
||||||
|
import { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from '../domain/seed-data.ts';
|
||||||
|
import { isTestAccount, soloGroupOf } from '../domain/test-account.ts';
|
||||||
|
|
||||||
export type OrgServiceDeps = {
|
export type OrgServiceDeps = {
|
||||||
|
readonly verifier: TokenVerifier;
|
||||||
|
/** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */
|
||||||
|
readonly roster?: RosterSource;
|
||||||
readonly positions: PositionRepository;
|
readonly positions: PositionRepository;
|
||||||
readonly staff: StaffRepository;
|
readonly staff: StaffRepository;
|
||||||
readonly grants: GrantRepository;
|
readonly roleAccess: RoleAccessRepository;
|
||||||
readonly sessions: SessionRepository;
|
|
||||||
readonly workGroups: WorkGroupRepository;
|
readonly workGroups: WorkGroupRepository;
|
||||||
readonly passwords: PasswordHasher;
|
|
||||||
readonly inFlight: InFlightWorkProbe;
|
readonly inFlight: InFlightWorkProbe;
|
||||||
readonly clock: Clock;
|
readonly clock: Clock;
|
||||||
readonly ids: IdGenerator;
|
readonly ids: IdGenerator;
|
||||||
/** 种子口令 —— 由 app 从环境读入,源码不写任何真实口令(ADR-010) */
|
/** 测试账号(Keycloak 用户名):首次建档与播种时落 TEST 岗 + 独立工作组 */
|
||||||
readonly seedPassword: string;
|
readonly testAccounts: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 会话有效期 12 小时,每次命中滑动续期 */
|
/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */
|
||||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
const LAST_SEEN_REWRITE_MS = 60_000;
|
||||||
|
|
||||||
|
/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */
|
||||||
|
function toStaff(record: StaffRecord): Staff {
|
||||||
|
const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record;
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||||
async function positionMap(): Promise<Map<string, Position>> {
|
async function positionMap(): Promise<Map<string, Position>> {
|
||||||
@@ -60,142 +66,188 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
|||||||
return { ...staff, position };
|
return { ...staff, position };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function grantsOfStaff(staffId: StaffId): Promise<Grant[]> {
|
async function positionByCode(code: string): Promise<Position> {
|
||||||
const staff = await deps.staff.findById(staffId);
|
const position = await deps.positions.findByCode(code);
|
||||||
if (!staff) throw new StaffNotFoundError(staffId);
|
if (!position) throw new PositionNotFoundError(code);
|
||||||
return deps.grants.listByPosition(staff.positionId);
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
async function principalFor(record: StaffRecord): Promise<Principal> {
|
||||||
|
if (record.state !== 'active') throw new AccountDisabledError(record.account);
|
||||||
|
const staff = toStaff(record);
|
||||||
|
const position = await deps.positions.findById(staff.positionId);
|
||||||
|
if (!position) throw new PositionNotFoundError(staff.positionId);
|
||||||
|
const table = await deps.roleAccess.list();
|
||||||
|
return { staff, position, roles: [...staff.roles], ends: endsOf(staff.roles, table) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试账号策略(ADR-011):TEST 岗 + 独立工作组;不是测试账号就什么都不做 */
|
||||||
|
async function applyTestAccountPolicy(staff: Staff): Promise<void> {
|
||||||
|
if (!isTestAccount(staff.account, deps.testAccounts)) return;
|
||||||
|
const none = await positionByCode(NONE_POSITION_CODE);
|
||||||
|
if (staff.positionId === none.id) {
|
||||||
|
const test = await positionByCode(TEST_POSITION_CODE);
|
||||||
|
await deps.staff.setPosition(staff.id, test.id);
|
||||||
|
}
|
||||||
|
if ((await deps.workGroups.listByOwner(staff.id)).length === 0) {
|
||||||
|
await deps.workGroups.upsert(soloGroupOf(staff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activeStaffRoles(): Promise<string[][]> {
|
||||||
|
return (await deps.staff.list()).filter((s) => s.state === 'active').map((s) => [...s.roles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertPerson(
|
||||||
|
identity: Identity,
|
||||||
|
state: Staff['state'],
|
||||||
|
marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date },
|
||||||
|
): Promise<Staff> {
|
||||||
|
const none = await positionByCode(NONE_POSITION_CODE);
|
||||||
|
const created = await deps.staff.insert(
|
||||||
|
{
|
||||||
|
id: staffIdSchema.parse(identity.sub),
|
||||||
|
account: identity.account,
|
||||||
|
name: identity.name,
|
||||||
|
...(identity.email === undefined ? {} : { email: identity.email }),
|
||||||
|
positionId: none.id,
|
||||||
|
state,
|
||||||
|
roles: [...identity.roles],
|
||||||
|
},
|
||||||
|
marks,
|
||||||
|
);
|
||||||
|
await applyTestAccountPolicy(toStaff(created));
|
||||||
|
const fresh = await deps.staff.findById(created.id);
|
||||||
|
return toStaff(fresh ?? created);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service: OrgService = {
|
||||||
|
async authenticate(token) {
|
||||||
|
const claims = await deps.verifier.verify(token);
|
||||||
|
await service.registerIdentity(identityOf(claims));
|
||||||
|
const id = staffIdSchema.parse(claims.sub);
|
||||||
|
const record = await deps.staff.findById(id);
|
||||||
|
if (record === undefined) throw new StaffNotFoundError(id);
|
||||||
|
return principalFor(record);
|
||||||
|
},
|
||||||
|
|
||||||
|
async principalOf(staffId) {
|
||||||
|
const record = await deps.staff.findById(staffId);
|
||||||
|
return record === undefined ? undefined : principalFor(record);
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerIdentity(identity) {
|
||||||
|
const now = deps.clock.now();
|
||||||
|
const id = staffIdSchema.parse(identity.sub);
|
||||||
|
const existing = await deps.staff.findById(id);
|
||||||
|
if (existing === undefined) return insertPerson(identity, 'active', { lastSeenAt: now });
|
||||||
|
|
||||||
|
const changed = identityChanged(existing, identity);
|
||||||
|
const stale =
|
||||||
|
existing.lastSeenAt === undefined || now.getTime() - existing.lastSeenAt.getTime() >= LAST_SEEN_REWRITE_MS;
|
||||||
|
if (!changed && !stale) return toStaff(existing);
|
||||||
|
await deps.staff.updateIdentity(id, { ...patchOf(identity), lastSeenAt: now });
|
||||||
|
const fresh = await deps.staff.findById(id);
|
||||||
|
return toStaff(fresh ?? existing);
|
||||||
|
},
|
||||||
|
|
||||||
async listStaff() {
|
async listStaff() {
|
||||||
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
|
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
|
||||||
const map = new Map(positions.map((p) => [p.id, p]));
|
const map = new Map(positions.map((p) => [p.id, p]));
|
||||||
const staff = await Promise.all(rows.map((s) => withPosition(s, map)));
|
const staff = await Promise.all(rows.map((s) => withPosition(toStaff(s), map)));
|
||||||
return { staff, positions };
|
return { staff, positions };
|
||||||
},
|
},
|
||||||
|
|
||||||
async getStaff(staffId) {
|
async getStaff(staffId) {
|
||||||
const staff = await deps.staff.findById(staffId);
|
const record = await deps.staff.findById(staffId);
|
||||||
if (!staff) throw new StaffNotFoundError(staffId);
|
if (!record) throw new StaffNotFoundError(staffId);
|
||||||
return withPosition(staff);
|
return withPosition(toStaff(record));
|
||||||
},
|
},
|
||||||
|
|
||||||
async findByAccount(account) {
|
async staffOfPosition(positionCode) {
|
||||||
const staff = await deps.staff.findByAccount(account);
|
if (positionCode === NONE_POSITION_CODE) return [];
|
||||||
if (!staff) return undefined;
|
const position = await positionByCode(positionCode);
|
||||||
return withPosition(staff);
|
const rows = await deps.staff.listByPosition(position.id);
|
||||||
|
return rows.filter((s) => s.state === 'active').map((s) => ({ ...toStaff(s), position }));
|
||||||
},
|
},
|
||||||
|
|
||||||
async createStaff(input: CreateStaffInput) {
|
async setPosition(staffId, positionId) {
|
||||||
const taken = await deps.staff.findByAccount(input.account);
|
const record = await deps.staff.findById(staffId);
|
||||||
if (taken) throw new AccountTakenError(input.account);
|
if (!record) throw new StaffNotFoundError(staffId);
|
||||||
const positionId = positionIdSchema.parse(input.positionId);
|
|
||||||
const position = await deps.positions.findById(positionId);
|
const position = await deps.positions.findById(positionId);
|
||||||
if (!position) throw new PositionNotFoundError(input.positionId);
|
if (!position) throw new PositionNotFoundError(positionId);
|
||||||
const created = await deps.staff.insert({
|
await deps.staff.setPosition(staffId, positionId);
|
||||||
id: staffIdSchema.parse(deps.ids.next()),
|
return { ...toStaff(record), positionId, position };
|
||||||
account: input.account,
|
|
||||||
name: input.name,
|
|
||||||
positionId,
|
|
||||||
state: 'active',
|
|
||||||
roles: [],
|
|
||||||
});
|
|
||||||
// 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品
|
|
||||||
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
|
|
||||||
return created;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async checkDisable(staffId) {
|
async syncRoster(): Promise<RosterSyncOutcome> {
|
||||||
const staff = await deps.staff.findById(staffId);
|
if (deps.roster === undefined) {
|
||||||
if (!staff) throw new StaffNotFoundError(staffId);
|
return {
|
||||||
|
configured: false,
|
||||||
|
message: '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const remote = await deps.roster.listUsers();
|
||||||
|
const local = await deps.staff.list();
|
||||||
|
const diff = diffRoster(remote, local);
|
||||||
|
const now = deps.clock.now();
|
||||||
|
for (const user of diff.insert) await insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now });
|
||||||
|
for (const u of diff.update) await deps.staff.updateIdentity(u.id, u.patch);
|
||||||
|
for (const s of diff.setState) await deps.staff.setState(s.id, s.state, now);
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
inserted: diff.insert.length,
|
||||||
|
updated: diff.update.length,
|
||||||
|
disabled: diff.setState.filter((s) => s.state === 'disabled').length,
|
||||||
|
total: remote.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async inFlightOf(staffId) {
|
||||||
|
const record = await deps.staff.findById(staffId);
|
||||||
|
if (!record) throw new StaffNotFoundError(staffId);
|
||||||
const [openTasks, openCards, openApprovals] = await Promise.all([
|
const [openTasks, openCards, openApprovals] = await Promise.all([
|
||||||
deps.inFlight.openTasksOf(staffId),
|
deps.inFlight.openTasksOf(staffId),
|
||||||
deps.inFlight.openCardsOf(staffId),
|
deps.inFlight.openCardsOf(staffId),
|
||||||
deps.inFlight.openApprovalsOf(staffId),
|
deps.inFlight.openApprovalsOf(staffId),
|
||||||
]);
|
]);
|
||||||
return buildDisableCheck(staffId, { openTasks, openCards, openApprovals });
|
return buildInFlightWork(staffId, { openTasks, openCards, openApprovals });
|
||||||
},
|
},
|
||||||
|
|
||||||
async disableStaff(staffId) {
|
async listRoleAccess() {
|
||||||
const check = await this.checkDisable(staffId);
|
const rows = await deps.roleAccess.list();
|
||||||
if (check.blocked) throw new DisableBlockedError(check);
|
const seen = new Set<string>(rows.map((r) => r.role));
|
||||||
await deps.staff.setState(staffId, 'disabled');
|
for (const s of await deps.staff.list()) for (const r of s.roles) seen.add(r);
|
||||||
// 停用即踢会话:否则被停用的人凭旧 cookie 还能继续办事(ADR-010)
|
return { roles: rows, knownRoles: [...seen].sort() };
|
||||||
await deps.sessions.revokeAllOf(staffId);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async listGrants(positionId) {
|
async setRoleAccess(role, ends) {
|
||||||
return deps.grants.listByPosition(positionIdSchema.parse(positionId));
|
const row: RoleAccess = { role, ends: normalizeEnds(ends) };
|
||||||
|
const current = await deps.roleAccess.list();
|
||||||
|
assertNotLockedOut([...current.filter((r) => r.role !== role), row], await activeStaffRoles());
|
||||||
|
return deps.roleAccess.upsert(row);
|
||||||
},
|
},
|
||||||
|
|
||||||
async setGrants(positionId, grants) {
|
async deleteRoleAccess(role) {
|
||||||
return deps.grants.replaceForPosition(positionIdSchema.parse(positionId), grants);
|
const current = await deps.roleAccess.list();
|
||||||
|
assertNotLockedOut(
|
||||||
|
current.filter((r) => r.role !== role),
|
||||||
|
await activeStaffRoles(),
|
||||||
|
);
|
||||||
|
await deps.roleAccess.remove(role);
|
||||||
},
|
},
|
||||||
|
|
||||||
async authorize(staffId: StaffId, object: GrantObject, action: GrantAction) {
|
async staffWithEnd(surface) {
|
||||||
const staff = await deps.staff.findById(staffId);
|
const table = await deps.roleAccess.list();
|
||||||
if (!staff || staff.state !== 'active') return false;
|
const { staff } = await service.listStaff();
|
||||||
const grants = await grantsOfStaff(staffId);
|
return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface));
|
||||||
return isAuthorized(grants, object, action);
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSession(account): Promise<Session | undefined> {
|
|
||||||
const staff = await deps.staff.findByAccount(account);
|
|
||||||
if (!staff || staff.state !== 'active') return undefined;
|
|
||||||
const position = await deps.positions.findById(staff.positionId);
|
|
||||||
if (!position) throw new PositionNotFoundError(staff.positionId);
|
|
||||||
const grants = await deps.grants.listByPosition(staff.positionId);
|
|
||||||
return { staff, position, grants };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录 —— 失败一律 undefined,**不区分原因**:
|
|
||||||
* 账号不存在 / 已停用 / 未设口令 / 口令错,对外都是同一句话,不外泄账号是否存在。
|
|
||||||
*/
|
|
||||||
async login(account, password): Promise<IssuedSession | undefined> {
|
|
||||||
const staff = await deps.staff.findByAccount(account);
|
|
||||||
const stored = await deps.staff.passwordHashOf(account);
|
|
||||||
// 无论账号是否存在都走一遍散列比对:否则响应时间会泄漏账号是否存在
|
|
||||||
const ok = await deps.passwords.verify(password, stored);
|
|
||||||
if (!ok || staff === undefined || staff.state !== 'active') return undefined;
|
|
||||||
|
|
||||||
const token = deps.passwords.newToken();
|
|
||||||
const expiresAt = new Date(deps.clock.now().getTime() + SESSION_TTL_MS);
|
|
||||||
await deps.sessions.issue({ token, staffId: staff.id, expiresAt });
|
|
||||||
return { token, expiresAt: expiresAt.toISOString() };
|
|
||||||
},
|
|
||||||
|
|
||||||
async logout(token) {
|
|
||||||
await deps.sessions.revoke(token);
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveSession(token): Promise<Session | undefined> {
|
|
||||||
const now = deps.clock.now();
|
|
||||||
const hit = await deps.sessions.touch(token, now, new Date(now.getTime() + SESSION_TTL_MS));
|
|
||||||
if (hit === undefined) return undefined;
|
|
||||||
const staff = await deps.staff.findById(hit.staffId);
|
|
||||||
// 会话还在但人已停用 —— 立刻作废,不给旧 cookie 留缝
|
|
||||||
if (staff === undefined || staff.state !== 'active') {
|
|
||||||
await deps.sessions.revoke(token);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const position = await deps.positions.findById(staff.positionId);
|
|
||||||
if (!position) throw new PositionNotFoundError(staff.positionId);
|
|
||||||
const grants = await deps.grants.listByPosition(staff.positionId);
|
|
||||||
return { staff, position, grants };
|
|
||||||
},
|
|
||||||
|
|
||||||
async setPassword(staffId, password) {
|
|
||||||
const staff = await deps.staff.findById(staffId);
|
|
||||||
if (!staff) throw new StaffNotFoundError(staffId);
|
|
||||||
await deps.staff.setPasswordHash(staffId, await deps.passwords.hash(password));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
|
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
|
||||||
|
|
||||||
async saveWorkGroup(input) {
|
async saveWorkGroup(input) {
|
||||||
const staff = await deps.staff.findById(input.ownerId);
|
const record = await deps.staff.findById(input.ownerId);
|
||||||
if (!staff) throw new StaffNotFoundError(input.ownerId);
|
if (!record) throw new StaffNotFoundError(input.ownerId);
|
||||||
return deps.workGroups.upsert({
|
return deps.workGroups.upsert({
|
||||||
id: input.id ?? deps.ids.next(),
|
id: input.id ?? deps.ids.next(),
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -211,84 +263,31 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bindingOf(staffId) {
|
async bindingOf(staffId) {
|
||||||
const group = await this.workGroupOf(staffId);
|
const group = await service.workGroupOf(staffId);
|
||||||
return group === undefined ? undefined : { ...group.members };
|
return group === undefined ? undefined : { ...group.members };
|
||||||
},
|
},
|
||||||
|
|
||||||
async staffOfPosition(positionCode) {
|
|
||||||
const position = await deps.positions.findByCode(positionCode);
|
|
||||||
if (!position) throw new PositionNotFoundError(positionCode);
|
|
||||||
const rows = await deps.staff.listByPosition(position.id);
|
|
||||||
return rows.filter((s) => s.state === 'active').map((s) => ({ ...s, position }));
|
|
||||||
},
|
|
||||||
|
|
||||||
async ensureSeeded() {
|
async ensureSeeded() {
|
||||||
const byCode = new Map<string, PositionId>();
|
|
||||||
for (const seed of POSITION_SEED) {
|
for (const seed of POSITION_SEED) {
|
||||||
const existing = await deps.positions.findByCode(seed.code);
|
const existing = await deps.positions.findByCode(seed.code);
|
||||||
const position = existing
|
if (existing !== undefined) continue;
|
||||||
? existing
|
await deps.positions.upsert({
|
||||||
: await deps.positions.upsert({
|
id: positionIdSchema.parse(deps.ids.next()),
|
||||||
id: positionIdSchema.parse(deps.ids.next()),
|
code: seed.code,
|
||||||
code: seed.code,
|
|
||||||
name: seed.name,
|
|
||||||
note: seed.note,
|
|
||||||
});
|
|
||||||
byCode.set(seed.code, position.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const seed of STAFF_SEED) {
|
|
||||||
const existing = await deps.staff.findByAccount(seed.account);
|
|
||||||
if (existing) {
|
|
||||||
// 补种口令:ADR-010 之前建的员工没有 password_hash,不补就永远登不进
|
|
||||||
const hash = await deps.staff.passwordHashOf(seed.account);
|
|
||||||
if (hash === undefined) {
|
|
||||||
await deps.staff.setPasswordHash(existing.id, await deps.passwords.hash(deps.seedPassword));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const positionId = byCode.get(seed.position);
|
|
||||||
if (!positionId) throw new PositionNotFoundError(seed.position);
|
|
||||||
const created = await deps.staff.insert({
|
|
||||||
id: staffIdSchema.parse(deps.ids.next()),
|
|
||||||
account: seed.account,
|
|
||||||
name: seed.name,
|
name: seed.name,
|
||||||
positionId,
|
note: seed.note,
|
||||||
state: 'active',
|
|
||||||
roles: [],
|
|
||||||
});
|
|
||||||
// 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口
|
|
||||||
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试账号的独立工作组(ADR-011):所有业务岗都指向本人,起单即整条流归自己
|
|
||||||
for (const account of SOLO_GROUP_ACCOUNTS) {
|
|
||||||
const staff = await deps.staff.findByAccount(account);
|
|
||||||
if (staff === undefined) continue;
|
|
||||||
const existing = await deps.workGroups.listByOwner(staff.id);
|
|
||||||
if (existing.length > 0) continue;
|
|
||||||
await deps.workGroups.upsert({
|
|
||||||
id: `wg-solo-${account.toLowerCase()}`,
|
|
||||||
name: `${staff.name} 独立测试组`,
|
|
||||||
ownerId: staff.id,
|
|
||||||
members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 缺省的角色→端只在表空时播 —— 之后是运营中心的事,播种不覆盖人工改动
|
||||||
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
|
if ((await deps.roleAccess.list()).length === 0) {
|
||||||
const positionId = byCode.get(code);
|
for (const row of DEFAULT_ROLE_ACCESS) await deps.roleAccess.upsert(row);
|
||||||
if (!positionId) continue;
|
}
|
||||||
const existing = await deps.grants.listByPosition(positionId);
|
// 已在册的测试账号补策略(名册先同步后配 JCAI_TEST_ACCOUNTS 的那种顺序)
|
||||||
if (existing.length > 0) continue;
|
for (const record of await deps.staff.list()) {
|
||||||
await deps.grants.replaceForPosition(
|
if (isTestAccount(record.account, deps.testAccounts)) await applyTestAccountPolicy(toStaff(record));
|
||||||
positionId,
|
|
||||||
entries.map((e) => ({
|
|
||||||
positionId,
|
|
||||||
object: { kind: 'governance' as const, function: governanceFunctionSchema.parse(e.fn) },
|
|
||||||
action: e.action,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return service;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,73 +2,11 @@
|
|||||||
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||||
*/
|
*/
|
||||||
import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||||
import type { Identity } from './contract.ts';
|
import type { Identity } from './contract.ts';
|
||||||
|
|
||||||
export type Clock = { now(): Date };
|
export type Clock = { now(): Date };
|
||||||
|
|
||||||
export type PositionRepository = {
|
|
||||||
list(): Promise<Position[]>;
|
|
||||||
findByCode(code: string): Promise<Position | undefined>;
|
|
||||||
findById(id: PositionId): Promise<Position | undefined>;
|
|
||||||
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StaffRepository = {
|
|
||||||
list(): Promise<Staff[]>;
|
|
||||||
findById(id: StaffId): Promise<Staff | undefined>;
|
|
||||||
findByAccount(account: string): Promise<Staff | undefined>;
|
|
||||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
|
||||||
insert(staff: Staff): Promise<Staff>;
|
|
||||||
setState(id: StaffId, state: Staff['state']): Promise<void>;
|
|
||||||
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
|
|
||||||
passwordHashOf(account: string): Promise<string | undefined>;
|
|
||||||
setPasswordHash(id: StaffId, hash: string): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
|
|
||||||
* token 即主键,不透明随机串,不编码任何身份信息。
|
|
||||||
*/
|
|
||||||
export type SessionRepository = {
|
|
||||||
issue(input: { token: string; staffId: StaffId; expiresAt: Date }): Promise<void>;
|
|
||||||
/** 未过期才返回;命中同时滑动续期 */
|
|
||||||
touch(token: string, now: Date, expiresAt: Date): Promise<{ staffId: StaffId } | undefined>;
|
|
||||||
revoke(token: string): Promise<void>;
|
|
||||||
revokeAllOf(staffId: StaffId): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
|
|
||||||
export type PasswordHasher = {
|
|
||||||
hash(password: string): Promise<string>;
|
|
||||||
verify(password: string, stored: string | undefined): Promise<boolean>;
|
|
||||||
newToken(): string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrantRepository = {
|
|
||||||
listByPosition(positionId: PositionId): Promise<Grant[]>;
|
|
||||||
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
|
|
||||||
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
|
||||||
*/
|
|
||||||
export type InFlightWorkProbe = {
|
|
||||||
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
|
|
||||||
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
|
||||||
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 工作组仓储(ADR-011) */
|
|
||||||
export type WorkGroupRepository = {
|
|
||||||
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
|
|
||||||
upsert(group: WorkGroup): Promise<WorkGroup>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** ID 生成 —— domain 零随机(§5.3) */
|
|
||||||
export type IdGenerator = { next(): string };
|
|
||||||
|
|
||||||
/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */
|
/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */
|
||||||
|
|
||||||
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
|
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
|
||||||
@@ -95,3 +33,50 @@ export type IdentityPatch = {
|
|||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
readonly lastSeenAt?: Date;
|
readonly lastSeenAt?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── 仓储 ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type PositionRepository = {
|
||||||
|
list(): Promise<Position[]>;
|
||||||
|
findByCode(code: string): Promise<Position | undefined>;
|
||||||
|
findById(id: PositionId): Promise<Position | undefined>;
|
||||||
|
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||||
|
export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
|
||||||
|
|
||||||
|
export type StaffRepository = {
|
||||||
|
list(): Promise<StaffRecord[]>;
|
||||||
|
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||||||
|
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||||
|
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
|
||||||
|
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||||||
|
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||||||
|
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleAccessRepository = {
|
||||||
|
list(): Promise<RoleAccess[]>;
|
||||||
|
upsert(row: RoleAccess): Promise<RoleAccess>;
|
||||||
|
remove(role: string): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
|
||||||
|
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
||||||
|
*/
|
||||||
|
export type InFlightWorkProbe = {
|
||||||
|
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
|
||||||
|
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||||
|
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 工作组仓储(ADR-011) */
|
||||||
|
export type WorkGroupRepository = {
|
||||||
|
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
|
||||||
|
upsert(group: WorkGroup): Promise<WorkGroup>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ID 生成 —— domain 零随机(§5.3) */
|
||||||
|
export type IdGenerator = { next(): string };
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export default defineConfig({
|
|||||||
main: 'src/app/main.ts',
|
main: 'src/app/main.ts',
|
||||||
seed: 'src/app/seed.ts',
|
seed: 'src/app/seed.ts',
|
||||||
migrate: 'src/app/migrate.ts',
|
migrate: 'src/app/migrate.ts',
|
||||||
// 改密 CLI(ADR-010):治理台改密界面未做前的运维口子
|
|
||||||
'set-password': 'src/app/set-password.ts',
|
|
||||||
// 清运行数据:开发期反复用
|
// 清运行数据:开发期反复用
|
||||||
'reset-runs': 'src/app/reset-runs.ts',
|
'reset-runs': 'src/app/reset-runs.ts',
|
||||||
// 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次
|
// 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak 的 sub。
|
||||||
|
--
|
||||||
|
-- 从前(ADR-010)是自建账号口令 + 服务端会话表 + 岗位授权(客体三类 × 动作三种)。
|
||||||
|
-- 现在:后端只验 Bearer JWT;权限只到「端」(design / ops / workbench),
|
||||||
|
-- 角色 → 端是运营中心可改的一张表;名册从 Keycloak 同步、岗位本地指派。
|
||||||
|
drop table if exists sys.session;
|
||||||
|
drop table if exists sys.position_grant;
|
||||||
|
|
||||||
|
alter table sys.staff
|
||||||
|
drop column if exists password_hash,
|
||||||
|
add column if not exists email text,
|
||||||
|
add column if not exists roles jsonb not null default '[]'::jsonb,
|
||||||
|
add column if not exists last_seen_at timestamptz,
|
||||||
|
add column if not exists synced_at timestamptz;
|
||||||
|
|
||||||
|
create table if not exists sys.role_access (
|
||||||
|
role text primary key,
|
||||||
|
design boolean not null default false,
|
||||||
|
ops boolean not null default false,
|
||||||
|
workbench boolean not null default false,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 种子人不是 Keycloak 的人:id 不是 sub,账号还会与 Keycloak 用户名撞唯一索引 —— 清掉。
|
||||||
|
-- 他们名下的运行数据由 `reset-runs` 清(迁移不碰运行表)。
|
||||||
|
delete from sys.work_group;
|
||||||
|
delete from sys.staff;
|
||||||
@@ -225,6 +225,13 @@
|
|||||||
"when": 1785130120179,
|
"when": 1785130120179,
|
||||||
"tag": "0031_task_land_master",
|
"tag": "0031_task_land_master",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785130120180,
|
||||||
|
"tag": "0032_keycloak_identity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user