feat: add v1 management APIs, audit logs, and import/export

This commit is contained in:
2026-03-07 05:35:40 +00:00
parent 1436d63a8c
commit 0ede080e85
9 changed files with 233 additions and 22 deletions

View File

@@ -39,9 +39,9 @@
## Phase 2 — v1 增强P1 ## Phase 2 — v1 增强P1
- [ ] 模糊/正则性能优化(索引/缓存) - [ ] 模糊/正则性能优化(索引/缓存)
- [ ] 管理命令与校验(重命名/删除/迁移) - [x] 管理命令与校验(重命名/删除/迁移)
- [ ] 完善错误码与审计日志 - [x] 完善错误码与审计日志
- [ ] 增加导入/导出工具 - [x] 增加导入/导出工具
## Phase 3 — 体验与文档P1 ## Phase 3 — 体验与文档P1
- [ ] README安装/配置/示例) - [ ] README安装/配置/示例)

View File

@@ -31,6 +31,14 @@ Implemented in this branch:
- `queryAgents` - `queryAgents`
- Query parser/executor with pagination - Query parser/executor with pagination
- Scope memory adapter (`put/search`) - Scope memory adapter (`put/search`)
- Management APIs:
- `renameDepartment`
- `renameTeam`
- `migrateTeam`
- `deleteDepartment`
- `deleteTeam`
- Error code model (`YonexusError`) and audit logs
- Import/export APIs (`importData` / `exportData`)
## Notes ## Notes

View File

@@ -1,5 +1,7 @@
import path from "node:path"; import path from "node:path";
import { DEFAULT_SCHEMA } from "./config/defaults"; import { DEFAULT_SCHEMA } from "./config/defaults";
import type { AuditLogEntry } from "./models/audit";
import { YonexusError } from "./models/errors";
import type { import type {
Actor, Actor,
Agent, Agent,
@@ -10,6 +12,7 @@ import type {
YonexusSchema YonexusSchema
} from "./models/types"; } from "./models/types";
import { authorize } from "./permissions/authorize"; import { authorize } from "./permissions/authorize";
import { AuditStore } from "./store/auditStore";
import { JsonStore } from "./store/jsonStore"; import { JsonStore } from "./store/jsonStore";
import { queryIdentities } from "./tools/query"; import { queryIdentities } from "./tools/query";
import { makeId } from "./utils/id"; import { makeId } from "./utils/id";
@@ -24,6 +27,7 @@ export class Yonexus {
private readonly schema: YonexusSchema; private readonly schema: YonexusSchema;
private readonly registrars: Set<string>; private readonly registrars: Set<string>;
private readonly store: JsonStore; private readonly store: JsonStore;
private readonly audit = new AuditStore();
constructor(options: YonexusOptions = {}) { constructor(options: YonexusOptions = {}) {
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json"); const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
@@ -32,32 +36,62 @@ export class Yonexus {
this.registrars = new Set(options.registrars ?? []); this.registrars = new Set(options.registrars ?? []);
} }
private log(entry: Omit<AuditLogEntry, "id" | "ts">): void {
this.audit.append({
id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`),
ts: new Date().toISOString(),
...entry
});
}
createDepartment(actor: Actor, name: string, orgId: string) { createDepartment(actor: Actor, name: string, orgId: string) {
try {
authorize("create_department", actor, { orgId }, this.store); authorize("create_department", actor, { orgId }, this.store);
const dept = { id: makeId("dept", name), name, orgId }; const dept = { id: makeId("dept", name), name, orgId };
return this.store.addDepartment(dept); const result = this.store.addDepartment(dept);
this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" });
return result;
} catch (error) {
this.log({
actorId: actor.agentId,
action: "create_department",
target: name,
status: "error",
message: error instanceof Error ? error.message : String(error)
});
throw error;
}
} }
createTeam(actor: Actor, name: string, deptId: string) { createTeam(actor: Actor, name: string, deptId: string) {
authorize("create_team", actor, { deptId }, this.store); authorize("create_team", actor, { deptId }, this.store);
if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`); if (!this.store.findDepartment(deptId)) {
throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId });
}
const team = { id: makeId("team", `${deptId}-${name}`), name, deptId }; const team = { id: makeId("team", `${deptId}-${name}`), name, deptId };
return this.store.addTeam(team); const result = this.store.addTeam(team);
this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" });
return result;
} }
registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) { registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) {
if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) { if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) {
authorize("register_agent", actor, {}, this.store); throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`);
} }
if (this.store.findAgent(agentId)) throw new Error(`agent_exists: ${agentId}`); authorize("register_agent", actor, {}, this.store);
return this.store.addAgent({ id: agentId, name, roles }); if (this.store.findAgent(agentId)) {
throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId });
}
const result = this.store.addAgent({ id: agentId, name, roles });
this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" });
return result;
} }
assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record<string, string>): Identity { assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record<string, string>): Identity {
authorize("assign_identity", actor, { deptId, teamId }, this.store); authorize("assign_identity", actor, { deptId, teamId }, this.store);
if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`); if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`); if (!this.store.findDepartment(deptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
if (!this.store.findTeam(teamId)) throw new Error(`team_not_found: ${teamId}`); if (!this.store.findTeam(teamId)) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
const validatedMeta: Record<string, string> = {}; const validatedMeta: Record<string, string> = {};
for (const [field, value] of Object.entries(meta)) { for (const [field, value] of Object.entries(meta)) {
@@ -65,26 +99,30 @@ export class Yonexus {
validatedMeta[field] = String(value); validatedMeta[field] = String(value);
} }
return this.store.addIdentity({ const result = this.store.addIdentity({
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`), id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
agentId, agentId,
deptId, deptId,
teamId, teamId,
meta: validatedMeta meta: validatedMeta
}); });
this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" });
return result;
} }
setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) { setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) {
authorize("set_supervisor", actor, { deptId }, this.store); authorize("set_supervisor", actor, { deptId }, this.store);
if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`); if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
if (!this.store.findAgent(supervisorId)) throw new Error(`supervisor_not_found: ${supervisorId}`); if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`);
if (agentId === supervisorId) throw new Error("invalid_supervisor: self_reference"); if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference");
return this.store.upsertSupervisor({ agentId, supervisorId }); const result = this.store.upsertSupervisor({ agentId, supervisorId });
this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" });
return result;
} }
whoami(agentId: string) { whoami(agentId: string) {
const agent = this.store.findAgent(agentId); const agent = this.store.findAgent(agentId);
if (!agent) throw new Error(`agent_not_found: ${agentId}`); if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
const identities = this.store.listIdentities().filter((x) => x.agentId === agentId); const identities = this.store.listIdentities().filter((x) => x.agentId === agentId);
const supervisor = this.store.findSupervisor(agentId); const supervisor = this.store.findSupervisor(agentId);
@@ -97,6 +135,64 @@ export class Yonexus {
return queryIdentities(identities, query, this.schema); return queryIdentities(identities, query, this.schema);
} }
renameDepartment(actor: Actor, deptId: string, newName: string) {
authorize("create_department", actor, {}, this.store);
const updated = this.store.renameDepartment(deptId, newName);
if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" });
return updated;
}
renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) {
authorize("create_team", actor, { deptId }, this.store);
const updated = this.store.renameTeam(teamId, newName);
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" });
return updated;
}
migrateTeam(actor: Actor, teamId: string, newDeptId: string) {
authorize("create_team", actor, { deptId: newDeptId }, this.store);
if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`);
const updated = this.store.migrateTeam(teamId, newDeptId);
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" });
return updated;
}
deleteDepartment(actor: Actor, deptId: string) {
authorize("create_department", actor, {}, this.store);
const ok = this.store.deleteDepartment(deptId);
if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" });
return { ok };
}
deleteTeam(actor: Actor, teamId: string, deptId?: string) {
authorize("create_team", actor, { deptId }, this.store);
const ok = this.store.deleteTeam(teamId);
if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" });
return { ok };
}
exportData(actor: Actor): StoreState {
authorize("query_agents", actor, {}, this.store);
this.log({ actorId: actor.agentId, action: "export_data", status: "ok" });
return this.store.snapshot();
}
importData(actor: Actor, state: StoreState): { ok: true } {
authorize("create_department", actor, {}, this.store);
this.store.replace(state);
this.log({ actorId: actor.agentId, action: "import_data", status: "ok" });
return { ok: true };
}
listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] {
return this.audit.list(limit, offset);
}
debugSnapshot(): StoreState { debugSnapshot(): StoreState {
return this.store.snapshot(); return this.store.snapshot();
} }

10
src/models/audit.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface AuditLogEntry {
id: string;
ts: string;
actorId: string;
action: string;
target?: string;
status: 'ok' | 'error';
message?: string;
meta?: Record<string, unknown>;
}

19
src/models/errors.ts Normal file
View File

@@ -0,0 +1,19 @@
export type ErrorCode =
| 'PERMISSION_DENIED'
| 'NOT_FOUND'
| 'ALREADY_EXISTS'
| 'VALIDATION_ERROR'
| 'FIELD_NOT_QUERYABLE'
| 'INVALID_SUPERVISOR'
| 'REGISTRAR_DENIED';
export class YonexusError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = 'YonexusError';
}
}

View File

@@ -1,3 +1,4 @@
import { YonexusError } from '../models/errors';
import type { Action, Actor, Scope } from "../models/types"; import type { Action, Actor, Scope } from "../models/types";
import { JsonStore } from "../store/jsonStore"; import { JsonStore } from "../store/jsonStore";
@@ -29,6 +30,10 @@ export function authorize(action: Action, actor: Actor, scope: Scope, store: Jso
(action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent)); (action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent));
if (!allowed) { if (!allowed) {
throw new Error(`permission_denied: ${action}`); throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, {
action,
actorId: actor.agentId,
scope
});
} }
} }

19
src/store/auditStore.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { AuditLogEntry } from '../models/audit';
const MAX_AUDIT = 1000;
export class AuditStore {
private logs: AuditLogEntry[] = [];
append(entry: AuditLogEntry): AuditLogEntry {
this.logs.push(entry);
if (this.logs.length > MAX_AUDIT) this.logs.shift();
return entry;
}
list(limit = 100, offset = 0): AuditLogEntry[] {
const safeLimit = Math.min(Math.max(1, limit), 500);
const safeOffset = Math.max(0, offset);
return this.logs.slice(safeOffset, safeOffset + safeLimit);
}
}

View File

@@ -25,6 +25,11 @@ export class JsonStore {
return JSON.parse(JSON.stringify(this.state)) as StoreState; return JSON.parse(JSON.stringify(this.state)) as StoreState;
} }
replace(state: StoreState): void {
this.state = JSON.parse(JSON.stringify(state)) as StoreState;
this.save();
}
addOrganization(org: Organization): Organization { addOrganization(org: Organization): Organization {
this.state.organizations.push(org); this.state.organizations.push(org);
this.save(); this.save();
@@ -37,12 +42,58 @@ export class JsonStore {
return dept; return dept;
} }
renameDepartment(deptId: string, name: string): Department | undefined {
const dept = this.findDepartment(deptId);
if (!dept) return undefined;
dept.name = name;
this.save();
return dept;
}
deleteDepartment(deptId: string): boolean {
const before = this.state.departments.length;
this.state.departments = this.state.departments.filter((d) => d.id !== deptId);
this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId);
this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId);
const changed = this.state.departments.length !== before;
if (changed) this.save();
return changed;
}
addTeam(team: Team): Team { addTeam(team: Team): Team {
this.state.teams.push(team); this.state.teams.push(team);
this.save(); this.save();
return team; return team;
} }
renameTeam(teamId: string, name: string): Team | undefined {
const team = this.findTeam(teamId);
if (!team) return undefined;
team.name = name;
this.save();
return team;
}
migrateTeam(teamId: string, newDeptId: string): Team | undefined {
const team = this.findTeam(teamId);
if (!team) return undefined;
team.deptId = newDeptId;
for (const identity of this.state.identities) {
if (identity.teamId === teamId) identity.deptId = newDeptId;
}
this.save();
return team;
}
deleteTeam(teamId: string): boolean {
const before = this.state.teams.length;
this.state.teams = this.state.teams.filter((t) => t.id !== teamId);
this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId);
const changed = before !== this.state.teams.length;
if (changed) this.save();
return changed;
}
addAgent(agent: Agent): Agent { addAgent(agent: Agent): Agent {
this.state.agents.push(agent); this.state.agents.push(agent);
this.save(); this.save();

View File

@@ -1,3 +1,4 @@
import { YonexusError } from '../models/errors';
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types"; import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types";
const DEFAULT_LIMIT = 20; const DEFAULT_LIMIT = 20;
@@ -33,7 +34,9 @@ function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] { export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] {
for (const filter of input.filters) { for (const filter of input.filters) {
if (!isQueryable(filter.field, schema)) { if (!isQueryable(filter.field, schema)) {
throw new Error(`field_not_queryable: ${filter.field}`); throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, {
field: filter.field
});
} }
} }