feat: add v1 management APIs, audit logs, and import/export
This commit is contained in:
@@ -39,9 +39,9 @@
|
||||
|
||||
## Phase 2 — v1 增强(P1)
|
||||
- [ ] 模糊/正则性能优化(索引/缓存)
|
||||
- [ ] 管理命令与校验(重命名/删除/迁移)
|
||||
- [ ] 完善错误码与审计日志
|
||||
- [ ] 增加导入/导出工具
|
||||
- [x] 管理命令与校验(重命名/删除/迁移)
|
||||
- [x] 完善错误码与审计日志
|
||||
- [x] 增加导入/导出工具
|
||||
|
||||
## Phase 3 — 体验与文档(P1)
|
||||
- [ ] README(安装/配置/示例)
|
||||
|
||||
@@ -31,6 +31,14 @@ Implemented in this branch:
|
||||
- `queryAgents`
|
||||
- Query parser/executor with pagination
|
||||
- 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
|
||||
|
||||
|
||||
130
src/index.ts
130
src/index.ts
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SCHEMA } from "./config/defaults";
|
||||
import type { AuditLogEntry } from "./models/audit";
|
||||
import { YonexusError } from "./models/errors";
|
||||
import type {
|
||||
Actor,
|
||||
Agent,
|
||||
@@ -10,6 +12,7 @@ import type {
|
||||
YonexusSchema
|
||||
} from "./models/types";
|
||||
import { authorize } from "./permissions/authorize";
|
||||
import { AuditStore } from "./store/auditStore";
|
||||
import { JsonStore } from "./store/jsonStore";
|
||||
import { queryIdentities } from "./tools/query";
|
||||
import { makeId } from "./utils/id";
|
||||
@@ -24,6 +27,7 @@ export class Yonexus {
|
||||
private readonly schema: YonexusSchema;
|
||||
private readonly registrars: Set<string>;
|
||||
private readonly store: JsonStore;
|
||||
private readonly audit = new AuditStore();
|
||||
|
||||
constructor(options: YonexusOptions = {}) {
|
||||
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
|
||||
@@ -32,32 +36,62 @@ export class Yonexus {
|
||||
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) {
|
||||
authorize("create_department", actor, { orgId }, this.store);
|
||||
const dept = { id: makeId("dept", name), name, orgId };
|
||||
return this.store.addDepartment(dept);
|
||||
try {
|
||||
authorize("create_department", actor, { orgId }, this.store);
|
||||
const dept = { id: makeId("dept", name), name, orgId };
|
||||
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) {
|
||||
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 };
|
||||
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"]) {
|
||||
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}`);
|
||||
return this.store.addAgent({ id: agentId, name, roles });
|
||||
authorize("register_agent", actor, {}, this.store);
|
||||
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 {
|
||||
authorize("assign_identity", actor, { deptId, teamId }, this.store);
|
||||
if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`);
|
||||
if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`);
|
||||
if (!this.store.findTeam(teamId)) throw new Error(`team_not_found: ${teamId}`);
|
||||
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||
if (!this.store.findDepartment(deptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||
if (!this.store.findTeam(teamId)) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||
|
||||
const validatedMeta: Record<string, string> = {};
|
||||
for (const [field, value] of Object.entries(meta)) {
|
||||
@@ -65,26 +99,30 @@ export class Yonexus {
|
||||
validatedMeta[field] = String(value);
|
||||
}
|
||||
|
||||
return this.store.addIdentity({
|
||||
const result = this.store.addIdentity({
|
||||
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
|
||||
agentId,
|
||||
deptId,
|
||||
teamId,
|
||||
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) {
|
||||
authorize("set_supervisor", actor, { deptId }, this.store);
|
||||
if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`);
|
||||
if (!this.store.findAgent(supervisorId)) throw new Error(`supervisor_not_found: ${supervisorId}`);
|
||||
if (agentId === supervisorId) throw new Error("invalid_supervisor: self_reference");
|
||||
return this.store.upsertSupervisor({ agentId, supervisorId });
|
||||
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||
if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`);
|
||||
if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference");
|
||||
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) {
|
||||
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 supervisor = this.store.findSupervisor(agentId);
|
||||
@@ -97,6 +135,64 @@ export class Yonexus {
|
||||
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 {
|
||||
return this.store.snapshot();
|
||||
}
|
||||
|
||||
10
src/models/audit.ts
Normal file
10
src/models/audit.ts
Normal 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
19
src/models/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { YonexusError } from '../models/errors';
|
||||
import type { Action, Actor, Scope } from "../models/types";
|
||||
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));
|
||||
|
||||
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
19
src/store/auditStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export class JsonStore {
|
||||
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 {
|
||||
this.state.organizations.push(org);
|
||||
this.save();
|
||||
@@ -37,12 +42,58 @@ export class JsonStore {
|
||||
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 {
|
||||
this.state.teams.push(team);
|
||||
this.save();
|
||||
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 {
|
||||
this.state.agents.push(agent);
|
||||
this.save();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { YonexusError } from '../models/errors';
|
||||
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types";
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
@@ -33,7 +34,9 @@ function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
|
||||
export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] {
|
||||
for (const filter of input.filters) {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user