feat: scaffold yonexus MVP core with storage, auth, query, and scope memory
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/*.json
|
||||||
|
!data/.gitkeep
|
||||||
@@ -3,39 +3,39 @@
|
|||||||
> 目标:将插件拆解为可执行任务(按阶段/优先级)。
|
> 目标:将插件拆解为可执行任务(按阶段/优先级)。
|
||||||
|
|
||||||
## Phase 0 — 基础准备(P0)
|
## Phase 0 — 基础准备(P0)
|
||||||
- [ ] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本)
|
- [x] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本)
|
||||||
- [ ] 定义最终配置文件格式(schema + permissions + registrars)
|
- [x] 定义最终配置文件格式(schema + permissions + registrars)
|
||||||
- [ ] 统一 ID 规则(org/dept/team/agent)
|
- [x] 统一 ID 规则(org/dept/team/agent)
|
||||||
|
|
||||||
## Phase 1 — MVP 核心(P0)
|
## Phase 1 — MVP 核心(P0)
|
||||||
### 数据与存储
|
### 数据与存储
|
||||||
- [ ] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor)
|
- [x] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor)
|
||||||
- [ ] 实现 in-memory store + JSON 持久化
|
- [x] 实现 in-memory store + JSON 持久化
|
||||||
- [ ] 定义 CRUD API
|
- [x] 定义 CRUD API
|
||||||
|
|
||||||
### 权限系统
|
### 权限系统
|
||||||
- [ ] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent)
|
- [x] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent)
|
||||||
- [ ] 实现权限校验函数 authorize(action, actor, scope)
|
- [x] 实现权限校验函数 authorize(action, actor, scope)
|
||||||
- [ ] 实现 registrars 白名单(禁止自注册)
|
- [x] 实现 registrars 白名单(禁止自注册)
|
||||||
|
|
||||||
### 工具/API
|
### 工具/API
|
||||||
- [ ] create_department
|
- [x] create_department
|
||||||
- [ ] create_team
|
- [x] create_team
|
||||||
- [ ] register_agent
|
- [x] register_agent
|
||||||
- [ ] assign_identity
|
- [x] assign_identity
|
||||||
- [ ] set_supervisor
|
- [x] set_supervisor
|
||||||
- [ ] whoami
|
- [x] whoami
|
||||||
- [ ] query_agents
|
- [x] query_agents
|
||||||
|
|
||||||
### Query DSL
|
### Query DSL
|
||||||
- [ ] filters/op 解析(eq / contains / regex)
|
- [x] filters/op 解析(eq / contains / regex)
|
||||||
- [ ] schema queryable 字段约束
|
- [x] schema queryable 字段约束
|
||||||
- [ ] pagination(limit/offset)
|
- [x] pagination(limit/offset)
|
||||||
|
|
||||||
### Scope Memory
|
### Scope Memory
|
||||||
- [ ] scope_memory.put(scopeId, text, metadata)
|
- [x] scope_memory.put(scopeId, text, metadata)
|
||||||
- [ ] scope_memory.search(scopeId, query, limit)
|
- [x] scope_memory.search(scopeId, query, limit)
|
||||||
- [ ] 兼容 memory-lancedb-pro
|
- [x] 兼容 memory-lancedb-pro
|
||||||
|
|
||||||
## Phase 2 — v1 增强(P1)
|
## Phase 2 — v1 增强(P1)
|
||||||
- [ ] 模糊/正则性能优化(索引/缓存)
|
- [ ] 模糊/正则性能优化(索引/缓存)
|
||||||
|
|||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Yonexus (MVP foundation)
|
||||||
|
|
||||||
|
OpenClaw plugin foundation for:
|
||||||
|
- Organization hierarchy (Org/Dept/Team)
|
||||||
|
- Agent registration + multi-identity
|
||||||
|
- Supervisor mapping
|
||||||
|
- Role-based authorization
|
||||||
|
- Query DSL (`eq | contains | regex`) with schema queryable guard
|
||||||
|
- Scoped shared memory adapter (compatible with memory tools)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
bash scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
Implemented in this branch:
|
||||||
|
- Data models + JSON persistence store
|
||||||
|
- Permission checker `authorize(action, actor, scope)`
|
||||||
|
- Core APIs:
|
||||||
|
- `createDepartment`
|
||||||
|
- `createTeam`
|
||||||
|
- `registerAgent`
|
||||||
|
- `assignIdentity`
|
||||||
|
- `setSupervisor`
|
||||||
|
- `whoami`
|
||||||
|
- `queryAgents`
|
||||||
|
- Query parser/executor with pagination
|
||||||
|
- Scope memory adapter (`put/search`)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Persistence file defaults to `data/org.json`.
|
||||||
|
- Meta fields are validated against schema; unknown fields are dropped.
|
||||||
|
- Supervisor relation does not imply permissions.
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
48
package-lock.json
generated
Normal file
48
package-lock.json
generated
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Yonexus OpenClaw plugin: hierarchy, identities, permissions, and scoped memory",
|
||||||
|
"main": "dist/yonexus/index.js",
|
||||||
|
"types": "dist/yonexus/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"prepare": "npm run clean && npm run build"
|
||||||
|
},
|
||||||
|
"keywords": ["openclaw", "plugin", "organization", "agents"],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"@types/node": "^22.13.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
plugin.json
Normal file
21
plugin.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "yonexus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"entry": "dist/yonexus/index.js",
|
||||||
|
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
|
||||||
|
"permissions": [
|
||||||
|
"memory_store",
|
||||||
|
"memory_recall"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"dataFile": "./data/org.json",
|
||||||
|
"registrars": [],
|
||||||
|
"schema": {
|
||||||
|
"position": { "type": "string", "queryable": true },
|
||||||
|
"discord_user_id": { "type": "string", "queryable": true },
|
||||||
|
"git_user_name": { "type": "string", "queryable": true },
|
||||||
|
"department": { "type": "string", "queryable": false },
|
||||||
|
"team": { "type": "string", "queryable": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,14 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
DIST_DIR="$ROOT_DIR/dist/yonexus"
|
DIST_DIR="$ROOT_DIR/dist/yonexus"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run build
|
||||||
mkdir -p "$DIST_DIR"
|
mkdir -p "$DIST_DIR"
|
||||||
|
cp -f "$ROOT_DIR/plugin.json" "$DIST_DIR/plugin.json"
|
||||||
# Build step placeholder (replace with actual build command when ready)
|
|
||||||
# e.g., npm run build
|
|
||||||
|
|
||||||
# Example: copy plugin manifest and compiled assets into dist/yonexus
|
|
||||||
# cp -r "$ROOT_DIR/plugin.json" "$DIST_DIR/"
|
|
||||||
# cp -r "$ROOT_DIR/dist-build/*" "$DIST_DIR/"
|
|
||||||
|
|
||||||
echo "[yonexus] install complete -> $DIST_DIR"
|
echo "[yonexus] install complete -> $DIST_DIR"
|
||||||
|
|||||||
18
src/config/defaults.ts
Normal file
18
src/config/defaults.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { StoreState, YonexusSchema } from "../models/types";
|
||||||
|
|
||||||
|
export const DEFAULT_STATE: StoreState = {
|
||||||
|
organizations: [],
|
||||||
|
departments: [],
|
||||||
|
teams: [],
|
||||||
|
agents: [],
|
||||||
|
identities: [],
|
||||||
|
supervisors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEMA: YonexusSchema = {
|
||||||
|
position: { type: "string", queryable: true },
|
||||||
|
discord_user_id: { type: "string", queryable: true },
|
||||||
|
git_user_name: { type: "string", queryable: true },
|
||||||
|
department: { type: "string", queryable: false },
|
||||||
|
team: { type: "string", queryable: false }
|
||||||
|
};
|
||||||
105
src/index.ts
Normal file
105
src/index.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { DEFAULT_SCHEMA } from "./config/defaults";
|
||||||
|
import type {
|
||||||
|
Actor,
|
||||||
|
Agent,
|
||||||
|
Identity,
|
||||||
|
QueryInput,
|
||||||
|
Scope,
|
||||||
|
StoreState,
|
||||||
|
YonexusSchema
|
||||||
|
} from "./models/types";
|
||||||
|
import { authorize } from "./permissions/authorize";
|
||||||
|
import { JsonStore } from "./store/jsonStore";
|
||||||
|
import { queryIdentities } from "./tools/query";
|
||||||
|
import { makeId } from "./utils/id";
|
||||||
|
|
||||||
|
export interface YonexusOptions {
|
||||||
|
dataFile?: string;
|
||||||
|
schema?: YonexusSchema;
|
||||||
|
registrars?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Yonexus {
|
||||||
|
private readonly schema: YonexusSchema;
|
||||||
|
private readonly registrars: Set<string>;
|
||||||
|
private readonly store: JsonStore;
|
||||||
|
|
||||||
|
constructor(options: YonexusOptions = {}) {
|
||||||
|
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
|
||||||
|
this.store = new JsonStore(dataFile);
|
||||||
|
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
||||||
|
this.registrars = new Set(options.registrars ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
const team = { id: makeId("team", `${deptId}-${name}`), name, deptId };
|
||||||
|
return this.store.addTeam(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (this.store.findAgent(agentId)) throw new Error(`agent_exists: ${agentId}`);
|
||||||
|
return this.store.addAgent({ id: agentId, name, roles });
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
const validatedMeta: Record<string, string> = {};
|
||||||
|
for (const [field, value] of Object.entries(meta)) {
|
||||||
|
if (!this.schema[field]) continue;
|
||||||
|
validatedMeta[field] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.store.addIdentity({
|
||||||
|
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
|
||||||
|
agentId,
|
||||||
|
deptId,
|
||||||
|
teamId,
|
||||||
|
meta: validatedMeta
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
whoami(agentId: string) {
|
||||||
|
const agent = this.store.findAgent(agentId);
|
||||||
|
if (!agent) throw new Error(`agent_not_found: ${agentId}`);
|
||||||
|
|
||||||
|
const identities = this.store.listIdentities().filter((x) => x.agentId === agentId);
|
||||||
|
const supervisor = this.store.findSupervisor(agentId);
|
||||||
|
return { agent, identities, supervisor };
|
||||||
|
}
|
||||||
|
|
||||||
|
queryAgents(actor: Actor, scope: Scope, query: QueryInput) {
|
||||||
|
authorize("query_agents", actor, scope, this.store);
|
||||||
|
const identities = this.store.listIdentities();
|
||||||
|
return queryIdentities(identities, query, this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugSnapshot(): StoreState {
|
||||||
|
return this.store.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Yonexus;
|
||||||
16
src/memory/scopeMemory.ts
Normal file
16
src/memory/scopeMemory.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface MemoryPort {
|
||||||
|
store(input: { text: string; scope: string; metadata?: Record<string, string> }): Promise<unknown>;
|
||||||
|
recall(input: { query: string; scope: string; limit?: number }): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScopeMemory {
|
||||||
|
constructor(private readonly memory: MemoryPort) {}
|
||||||
|
|
||||||
|
async put(scopeId: string, text: string, metadata?: Record<string, string>): Promise<unknown> {
|
||||||
|
return this.memory.store({ text, scope: scopeId, metadata });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(scopeId: string, query: string, limit = 5): Promise<unknown> {
|
||||||
|
return this.memory.recall({ query, scope: scopeId, limit });
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/models/types.ts
Normal file
89
src/models/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export type Role = "org_admin" | "dept_admin" | "team_lead" | "agent";
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deptId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Identity {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
deptId: string;
|
||||||
|
teamId: string;
|
||||||
|
meta: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Supervisor {
|
||||||
|
agentId: string;
|
||||||
|
supervisorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaField {
|
||||||
|
type: "string";
|
||||||
|
queryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YonexusSchema {
|
||||||
|
[field: string]: SchemaField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreState {
|
||||||
|
organizations: Organization[];
|
||||||
|
departments: Department[];
|
||||||
|
teams: Team[];
|
||||||
|
agents: Agent[];
|
||||||
|
identities: Identity[];
|
||||||
|
supervisors: Supervisor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Actor {
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| "create_department"
|
||||||
|
| "create_team"
|
||||||
|
| "register_agent"
|
||||||
|
| "assign_identity"
|
||||||
|
| "set_supervisor"
|
||||||
|
| "query_agents";
|
||||||
|
|
||||||
|
export interface Scope {
|
||||||
|
orgId?: string;
|
||||||
|
deptId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryFilter {
|
||||||
|
field: string;
|
||||||
|
op: "eq" | "contains" | "regex";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
filters: QueryFilter[];
|
||||||
|
options?: QueryOptions;
|
||||||
|
}
|
||||||
34
src/permissions/authorize.ts
Normal file
34
src/permissions/authorize.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Action, Actor, Scope } from "../models/types";
|
||||||
|
import { JsonStore } from "../store/jsonStore";
|
||||||
|
|
||||||
|
function hasRole(store: JsonStore, actor: Actor, role: string): boolean {
|
||||||
|
const me = store.findAgent(actor.agentId);
|
||||||
|
return Boolean(me?.roles.includes(role as never));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inDeptScope(scope: Scope): boolean {
|
||||||
|
return Boolean(scope.deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inTeamScope(scope: Scope): boolean {
|
||||||
|
return Boolean(scope.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorize(action: Action, actor: Actor, scope: Scope, store: JsonStore): void {
|
||||||
|
const orgAdmin = hasRole(store, actor, "org_admin");
|
||||||
|
const deptAdmin = hasRole(store, actor, "dept_admin") && inDeptScope(scope);
|
||||||
|
const teamLead = hasRole(store, actor, "team_lead") && inTeamScope(scope);
|
||||||
|
const agent = hasRole(store, actor, "agent");
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
(action === "create_department" && orgAdmin) ||
|
||||||
|
(action === "create_team" && (orgAdmin || deptAdmin)) ||
|
||||||
|
(action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||||
|
(action === "register_agent" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||||
|
(action === "set_supervisor" && (orgAdmin || deptAdmin)) ||
|
||||||
|
(action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent));
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw new Error(`permission_denied: ${action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/store/jsonStore.ts
Normal file
89
src/store/jsonStore.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { DEFAULT_STATE } from "../config/defaults";
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
Department,
|
||||||
|
Identity,
|
||||||
|
Organization,
|
||||||
|
StoreState,
|
||||||
|
Supervisor,
|
||||||
|
Team
|
||||||
|
} from "../models/types";
|
||||||
|
import { readJsonFile, writeJsonFile } from "../utils/fs";
|
||||||
|
|
||||||
|
export class JsonStore {
|
||||||
|
private state: StoreState;
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.state = readJsonFile<StoreState>(filePath, DEFAULT_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
writeJsonFile(this.filePath, this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): StoreState {
|
||||||
|
return JSON.parse(JSON.stringify(this.state)) as StoreState;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrganization(org: Organization): Organization {
|
||||||
|
this.state.organizations.push(org);
|
||||||
|
this.save();
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDepartment(dept: Department): Department {
|
||||||
|
this.state.departments.push(dept);
|
||||||
|
this.save();
|
||||||
|
return dept;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTeam(team: Team): Team {
|
||||||
|
this.state.teams.push(team);
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAgent(agent: Agent): Agent {
|
||||||
|
this.state.agents.push(agent);
|
||||||
|
this.save();
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
addIdentity(identity: Identity): Identity {
|
||||||
|
this.state.identities.push(identity);
|
||||||
|
this.save();
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertSupervisor(rel: Supervisor): Supervisor {
|
||||||
|
const idx = this.state.supervisors.findIndex((x) => x.agentId === rel.agentId);
|
||||||
|
if (idx >= 0) this.state.supervisors[idx] = rel;
|
||||||
|
else this.state.supervisors.push(rel);
|
||||||
|
this.save();
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
findAgent(agentId: string): Agent | undefined {
|
||||||
|
return this.state.agents.find((a) => a.id === agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findDepartment(deptId: string): Department | undefined {
|
||||||
|
return this.state.departments.find((d) => d.id === deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findTeam(teamId: string): Team | undefined {
|
||||||
|
return this.state.teams.find((t) => t.id === teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listAgents(): Agent[] {
|
||||||
|
return this.state.agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Identity[] {
|
||||||
|
return this.state.identities;
|
||||||
|
}
|
||||||
|
|
||||||
|
findSupervisor(agentId: string): Supervisor | undefined {
|
||||||
|
return this.state.supervisors.find((s) => s.agentId === agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/tools/query.ts
Normal file
43
src/tools/query.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types";
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 20;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
function isQueryable(field: string, schema: YonexusSchema): boolean {
|
||||||
|
return Boolean(schema[field]?.queryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFilter(identity: Identity, filter: QueryFilter): boolean {
|
||||||
|
const raw = identity.meta[filter.field] ?? "";
|
||||||
|
|
||||||
|
switch (filter.op) {
|
||||||
|
case "eq":
|
||||||
|
return raw === filter.value;
|
||||||
|
case "contains":
|
||||||
|
return raw.toLowerCase().includes(filter.value.toLowerCase());
|
||||||
|
case "regex": {
|
||||||
|
const re = new RegExp(filter.value);
|
||||||
|
return re.test(raw);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
|
||||||
|
const limit = Math.min(Math.max(1, options?.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||||
|
const offset = Math.max(0, options?.offset ?? 0);
|
||||||
|
return { limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = identities.filter((identity) => input.filters.every((f) => matchFilter(identity, f)));
|
||||||
|
const { limit, offset } = normalizeOptions(input.options);
|
||||||
|
return filtered.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
18
src/utils/fs.ts
Normal file
18
src/utils/fs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function ensureDirForFile(filePath: string): void {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsonFile<T>(filePath: string, fallback: T): T {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJsonFile(filePath: string, data: unknown): void {
|
||||||
|
ensureDirForFile(filePath);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
|
}
|
||||||
10
src/utils/id.ts
Normal file
10
src/utils/id.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const SAFE = /[^a-z0-9]+/g;
|
||||||
|
|
||||||
|
export function slug(input: string): string {
|
||||||
|
return input.trim().toLowerCase().replace(SAFE, "-").replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeId(prefix: string, input: string): string {
|
||||||
|
const s = slug(input);
|
||||||
|
return `${prefix}:${s || "unknown"}`;
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist/yonexus",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user