@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +30 -0
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +79 -1
- package/src/requirements-mcp.test.ts +3 -0
- package/src/requirements.ts +103 -33
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-tasks-http.test.ts +2 -1
- package/src/scheduled-tasks.ts +12 -0
- package/src/server.ts +345 -41
- package/src/tools.ts +98 -31
|
@@ -55,6 +55,84 @@ afterAll(() => {
|
|
|
55
55
|
describe("requirements stay on the hub", () => {
|
|
56
56
|
let id = "";
|
|
57
57
|
|
|
58
|
+
test("name limit is consistent across create, PATCH and both upsert paths", async () => {
|
|
59
|
+
const send = (path: string, method: string, body: unknown) => api(ownerToken, path, { method, body: JSON.stringify(body) });
|
|
60
|
+
const ref = 'github:test/name-limit#1';
|
|
61
|
+
const created = await send('/api/requirements/upsert', 'POST', { name: '字'.repeat(80), external_ref: ref });
|
|
62
|
+
expect(created.status).toBe(201);
|
|
63
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
64
|
+
for (const name of ['a'.repeat(150), '字'.repeat(81), ' ', null, 12]) {
|
|
65
|
+
for (const [url, method, extra] of [
|
|
66
|
+
['/api/requirements', 'POST', {}],
|
|
67
|
+
[path, 'PATCH', {}],
|
|
68
|
+
['/api/requirements/upsert', 'POST', { external_ref: ref }],
|
|
69
|
+
['/api/requirements/upsert', 'POST', { external_ref: 'github:test/name-limit#2' }],
|
|
70
|
+
] as const) {
|
|
71
|
+
const result = await send(url, method, { ...extra, name });
|
|
72
|
+
expect(result.status).toBe(400);
|
|
73
|
+
expect(result.body.error).toBe('invalid_name');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
expect((await api(ownerToken, path)).body.requirement.name).toBe('字'.repeat(80));
|
|
77
|
+
const boundary = await send(path, 'PATCH', { name: ` ${'界'.repeat(80)} ` });
|
|
78
|
+
expect(boundary.status).toBe(200);
|
|
79
|
+
expect(boundary.body.requirement.name).toBe('界'.repeat(80));
|
|
80
|
+
expect((await send(path, 'PATCH', { priority: 'high' })).body.requirement.name).toBe('界'.repeat(80));
|
|
81
|
+
await api(ownerToken, path, { method: 'DELETE' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("archived=true returns only archived tasks and default excludes them", async () => {
|
|
85
|
+
const create = async (token: string, name: string) => api(token, '/api/requirements', { method: 'POST', body: JSON.stringify({ name }) });
|
|
86
|
+
const active = (await create(ownerToken, 'archive-filter-active')).body.requirement.id;
|
|
87
|
+
const archived = (await create(ownerToken, 'archive-filter-archived')).body.requirement.id;
|
|
88
|
+
const foreign = (await create(otherToken, 'archive-filter-foreign')).body.requirement.id;
|
|
89
|
+
for (const [token, taskId] of [[ownerToken, archived], [otherToken, foreign]]) {
|
|
90
|
+
expect((await api(token, `/api/requirements/${taskId}`, { method: 'PATCH', body: JSON.stringify({ archived: true }) })).status).toBe(200);
|
|
91
|
+
}
|
|
92
|
+
const ids = async (query: string) => {
|
|
93
|
+
const result = await api(ownerToken, `/api/requirements${query}${query ? '&' : '?'}network_id=${ownerNetwork}`);
|
|
94
|
+
expect(result.status).toBe(200);
|
|
95
|
+
return result.body.requirements.map((task: any) => task.id);
|
|
96
|
+
};
|
|
97
|
+
expect(await ids('?archived=true')).toEqual([archived]);
|
|
98
|
+
expect(await ids('?archived=true&include_archived=1')).toEqual([archived]);
|
|
99
|
+
const defaults = await ids('');
|
|
100
|
+
expect(defaults).toContain(active);
|
|
101
|
+
expect(defaults).not.toContain(archived);
|
|
102
|
+
expect(defaults).not.toContain(foreign);
|
|
103
|
+
expect(await ids('?archived=false')).toEqual(defaults);
|
|
104
|
+
const all = await ids('?include_archived=1');
|
|
105
|
+
expect(all).toContain(active);
|
|
106
|
+
expect(all).toContain(archived);
|
|
107
|
+
expect(all).not.toContain(foreign);
|
|
108
|
+
for (const [token, taskId] of [[ownerToken, active], [ownerToken, archived], [otherToken, foreign]]) {
|
|
109
|
+
await api(token, `/api/requirements/${taskId}`, { method: 'DELETE' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("tags validate, aggregate within network, and survive old PATCH shapes", async () => {
|
|
114
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: 'tagged task', tags: [' UI ', 'UI', '交付'] }) });
|
|
115
|
+
expect(created.status).toBe(201);
|
|
116
|
+
expect(created.body.requirement.tags).toEqual(['UI', '交付']);
|
|
117
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
118
|
+
const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ column: 'doing', name: 'old app edit' }) });
|
|
119
|
+
expect(old.status).toBe(200);
|
|
120
|
+
expect(old.body.requirement.tags).toEqual(['UI', '交付']);
|
|
121
|
+
for (const tags of [null, 'UI', [12], [''], ['a'.repeat(21)], Array.from({ length: 11 }, (_, i) => `t${i}`)]) {
|
|
122
|
+
const invalid = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags }) });
|
|
123
|
+
expect(invalid.status).toBe(400);
|
|
124
|
+
expect(invalid.body.error).toBe('invalid_tags');
|
|
125
|
+
}
|
|
126
|
+
const own = await api(ownerToken, '/api/requirements/tags');
|
|
127
|
+
expect(own.body.tags).toEqual(['UI', '交付']);
|
|
128
|
+
expect((await api(otherToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
129
|
+
expect((await api(nodeToken, '/api/requirements/tags')).body.tags).toEqual(['UI', '交付']);
|
|
130
|
+
expect((await api(viewerToken, `${path}?network_id=${ownerNetwork}`, { method: 'PATCH', body: JSON.stringify({ tags: [] }) })).status).toBe(403);
|
|
131
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags: [] }) });
|
|
132
|
+
expect(cleared.body.requirement.tags).toEqual([]);
|
|
133
|
+
expect((await api(ownerToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
58
136
|
test("typed assignments validate network membership, preserve kinds and clear explicitly", async () => {
|
|
59
137
|
const { db } = await import('./db.js');
|
|
60
138
|
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
@@ -294,7 +372,7 @@ describe("requirements stay on the hub", () => {
|
|
|
294
372
|
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
295
373
|
const listed = await api(ownerToken, "/api/requirements");
|
|
296
374
|
expect(listed.body.capabilities).toContain("due_datetime");
|
|
297
|
-
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements"]);
|
|
375
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags"]);
|
|
298
376
|
});
|
|
299
377
|
|
|
300
378
|
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
@@ -70,9 +70,12 @@ describe("requirements MCP tools", () => {
|
|
|
70
70
|
const first = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, external_url: "https://github.com/acme/widgets/issues/42", name: "修复登录", description: "issue 正文", checklist: [{ id: "c1", text: "复现" }, { id: "c2", text: "修" }] });
|
|
71
71
|
expect(first.ok).toBe(true);
|
|
72
72
|
expect(first.created).toBe(true);
|
|
73
|
+
const tagged = await s.call("requirements_update", { id: first.requirement.id, tags: ["release"] });
|
|
74
|
+
expect(tagged.requirement.tags).toEqual(["release"]);
|
|
73
75
|
expect(first.requirement.created_by).toEqual({ kind: "node", id: "node_gh_sync" });
|
|
74
76
|
const again = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, name: "修复登录(改了标题)" });
|
|
75
77
|
expect(again.created).toBe(false);
|
|
78
|
+
expect(again.requirement.tags).toEqual(["release"]);
|
|
76
79
|
expect(again.requirement.id).toBe(first.requirement.id);
|
|
77
80
|
const listed = await s.call("requirements_list", { external_ref: ref });
|
|
78
81
|
expect(listed.requirements.length).toBe(1);
|
package/src/requirements.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
2
|
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
3
|
import { db } from "./db.js";
|
|
4
|
-
import {
|
|
4
|
+
import { normalizeTags, storedTags } from "./requirement-tags.js";
|
|
5
|
+
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
6
|
+
import { isAgentRestricted, visibleAgents } from "./agent-access.js";
|
|
5
7
|
import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
|
|
6
8
|
|
|
7
9
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
@@ -50,6 +52,7 @@ type Row = {
|
|
|
50
52
|
due_on: string | null;
|
|
51
53
|
assignee: string | null;
|
|
52
54
|
issues_json: string | null;
|
|
55
|
+
tags_json: string | null;
|
|
53
56
|
created_at: string;
|
|
54
57
|
};
|
|
55
58
|
|
|
@@ -284,6 +287,7 @@ function toPublic(row: Row) {
|
|
|
284
287
|
createdAt: row.created_at,
|
|
285
288
|
updatedAt: row.updated_at || row.created_at,
|
|
286
289
|
issues: storedIssues(row.issues_json),
|
|
290
|
+
tags: storedTags(row.tags_json),
|
|
287
291
|
// 同步用的外部引用(如 github:owner/repo#123)和链接;归档的卡默认不在列表里(include_archived=1 才有)。
|
|
288
292
|
external_ref: row.external_ref || null,
|
|
289
293
|
external_url: row.external_url || null,
|
|
@@ -312,22 +316,55 @@ function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestCon
|
|
|
312
316
|
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
313
317
|
// 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
|
|
314
318
|
if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
|
|
315
|
-
return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
319
|
+
if (ctx.isNodeToken) return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
320
|
+
// 需求看板是人类协作面:受限成员(只看授权 Agent 的成员)照样能读写卡片,
|
|
321
|
+
// 只是卡片上没授权给他的 Agent 引用会被隐去、也不能被他指派(见 hiddenNodeFilter)。
|
|
322
|
+
return canRestWriteNetworkAsHuman(ctx.auth, networkId, ctx.isAdmin);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── 多用户 Agent 权限 ──
|
|
326
|
+
// 受限成员看需求卡时:agent_owner / 参与人 / created_by / updated_by 里没授权给他的节点一律隐去;
|
|
327
|
+
// 他写卡时也不能引用这些节点。返回 null = 这个调用者在这个网络里不受限。
|
|
328
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
329
|
+
function hiddenNodeFilter(ctx: RequirementsRequestContext, networkId: string): HiddenNode {
|
|
330
|
+
if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
|
|
331
|
+
if (!isAgentRestricted(ctx.auth.userId, networkId)) return null;
|
|
332
|
+
const visible = new Set(visibleAgents(ctx.auth.userId, networkId).nodeIds);
|
|
333
|
+
return (nodeId: string) => !visible.has(nodeId);
|
|
334
|
+
}
|
|
335
|
+
function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
|
|
336
|
+
if (!hidden || !ref || typeof ref !== "object") return false;
|
|
337
|
+
const r = ref as { kind?: unknown; id?: unknown };
|
|
338
|
+
return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
|
|
339
|
+
}
|
|
340
|
+
function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
341
|
+
const pub = toPublic(row);
|
|
342
|
+
const hidden = hiddenNodeFilter(ctx, row.network_id);
|
|
343
|
+
if (!hidden) return pub;
|
|
344
|
+
return {
|
|
345
|
+
...pub,
|
|
346
|
+
owner: isHiddenRef(pub.owner, hidden) ? null : pub.owner,
|
|
347
|
+
agent_owner: isHiddenRef(pub.agent_owner, hidden) ? null : pub.agent_owner,
|
|
348
|
+
participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
|
|
349
|
+
created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
|
|
350
|
+
updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
|
|
351
|
+
};
|
|
316
352
|
}
|
|
317
353
|
|
|
318
|
-
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, " +
|
|
354
|
+
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, " +
|
|
319
355
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
320
356
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
|
|
321
357
|
|
|
322
358
|
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
323
|
-
function personRef(value: unknown, networkId: string): PersonRef {
|
|
359
|
+
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
|
|
324
360
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
325
361
|
const row = value as Record<string, unknown>;
|
|
326
362
|
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
327
363
|
const exists = row.kind === 'user'
|
|
328
364
|
? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
|
|
329
365
|
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
330
|
-
|
|
366
|
+
// 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
|
|
367
|
+
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
|
|
331
368
|
return { kind: row.kind, id: row.id };
|
|
332
369
|
}
|
|
333
370
|
|
|
@@ -336,12 +373,14 @@ function personRef(value: unknown, networkId: string): PersonRef {
|
|
|
336
373
|
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
337
374
|
// → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
|
|
338
375
|
// 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
|
|
339
|
-
function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
|
|
376
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
|
|
340
377
|
let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
341
378
|
let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
|
|
342
379
|
let coerced: PersonRef | null = null;
|
|
380
|
+
// 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
|
|
381
|
+
const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
|
|
343
382
|
if ('owner' in body) {
|
|
344
|
-
owner = body.owner === null ? null : personRef(body.owner, networkId);
|
|
383
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
345
384
|
if (owner && owner.kind !== 'user') {
|
|
346
385
|
if ('agent_owner' in body) throw new Error('owner_must_be_human');
|
|
347
386
|
coerced = owner;
|
|
@@ -349,15 +388,19 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
349
388
|
owner = null;
|
|
350
389
|
}
|
|
351
390
|
}
|
|
391
|
+
if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
352
392
|
if ('agent_owner' in body) {
|
|
353
|
-
|
|
393
|
+
if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
394
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
|
|
354
395
|
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
355
396
|
}
|
|
356
397
|
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
357
398
|
if ('participants' in body) {
|
|
358
399
|
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
359
|
-
const refs = body.participants.map(value => personRef(value, networkId));
|
|
360
|
-
|
|
400
|
+
const refs = body.participants.map(value => personRef(value, networkId, hidden));
|
|
401
|
+
// 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
|
|
402
|
+
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
403
|
+
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
361
404
|
}
|
|
362
405
|
return {
|
|
363
406
|
coerced,
|
|
@@ -382,7 +425,7 @@ function operationOf(req: Request, url: URL): string {
|
|
|
382
425
|
return "create";
|
|
383
426
|
}
|
|
384
427
|
|
|
385
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements"] as const;
|
|
428
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags"] as const;
|
|
386
429
|
|
|
387
430
|
// ── 子需求 ──
|
|
388
431
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -495,6 +538,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
495
538
|
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
496
539
|
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
497
540
|
if (issues === null) return jsonError("invalid_issues", 400);
|
|
541
|
+
const tags = body.tags === undefined ? [] : normalizeTags(body.tags);
|
|
542
|
+
if (tags === null) return jsonError("invalid_tags", 400);
|
|
498
543
|
const description = body.description === undefined ? "" : normalizeDescription(body.description);
|
|
499
544
|
if (description === null) return jsonError("invalid_description", 400);
|
|
500
545
|
const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
|
|
@@ -520,19 +565,19 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
520
565
|
}
|
|
521
566
|
if (clientId) {
|
|
522
567
|
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
523
|
-
if (existing) return Response.json({ ok: true, requirement:
|
|
568
|
+
if (existing) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
524
569
|
}
|
|
525
570
|
const id = `req_${crypto.randomUUID()}`;
|
|
526
571
|
const createdAt = new Date().toISOString();
|
|
527
572
|
let people;
|
|
528
|
-
try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
573
|
+
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
529
574
|
const actor = JSON.stringify(actorOf(ctx));
|
|
530
575
|
try {
|
|
531
576
|
db.run(
|
|
532
577
|
`INSERT INTO requirements
|
|
533
|
-
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id)
|
|
534
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21)`,
|
|
535
|
-
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId],
|
|
578
|
+
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json)
|
|
579
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22)`,
|
|
580
|
+
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags)],
|
|
536
581
|
);
|
|
537
582
|
} catch {
|
|
538
583
|
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
@@ -543,14 +588,14 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
543
588
|
if (!clientId) return jsonError("insert_failed", 500);
|
|
544
589
|
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
545
590
|
if (!existing) return jsonError("insert_failed", 500);
|
|
546
|
-
return Response.json({ ok: true, requirement:
|
|
591
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
547
592
|
}
|
|
548
593
|
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
549
|
-
return Response.json(withLegacyOwner(
|
|
594
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, created), people.coerced), { status: 201 });
|
|
550
595
|
}
|
|
551
596
|
|
|
552
597
|
// ── 修改(省略的字段保留原值) ──
|
|
553
|
-
const PATCH_FIELDS = ["column", "issues", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id"];
|
|
598
|
+
const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id"];
|
|
554
599
|
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
555
600
|
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
556
601
|
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
@@ -558,6 +603,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
558
603
|
if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
559
604
|
const issues = has("issues") ? normalizeIssues(body.issues) : null;
|
|
560
605
|
if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
|
|
606
|
+
const tags = has("tags") ? normalizeTags(body.tags) : null;
|
|
607
|
+
if (has("tags") && tags === null) return jsonError("invalid_tags", 400);
|
|
561
608
|
const description = has("description") ? normalizeDescription(body.description) : null;
|
|
562
609
|
if (has("description") && description === null) return jsonError("invalid_description", 400);
|
|
563
610
|
const checklist = has("checklist") ? normalizeChecklist(body.checklist) : null;
|
|
@@ -585,7 +632,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
585
632
|
assignee = body.assignee.trim().slice(0, 80);
|
|
586
633
|
}
|
|
587
634
|
let people;
|
|
588
|
-
try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
635
|
+
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
589
636
|
let projectId = row.project_id;
|
|
590
637
|
if (has("project_id")) {
|
|
591
638
|
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
@@ -624,13 +671,13 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
624
671
|
const updatedAt = new Date().toISOString();
|
|
625
672
|
db.run(
|
|
626
673
|
`UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
|
|
627
|
-
agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19
|
|
674
|
+
agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20
|
|
628
675
|
WHERE requirement_id = ?3`,
|
|
629
676
|
[has("column") ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
|
|
630
|
-
has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId],
|
|
677
|
+
has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId, has("tags") ? JSON.stringify(tags) : row.tags_json],
|
|
631
678
|
);
|
|
632
679
|
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
633
|
-
return Response.json(withLegacyOwner(
|
|
680
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
|
|
634
681
|
}
|
|
635
682
|
|
|
636
683
|
/**
|
|
@@ -646,12 +693,12 @@ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: Pers
|
|
|
646
693
|
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
|
|
647
694
|
const params: unknown[] = [id];
|
|
648
695
|
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
649
|
-
sql =
|
|
696
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
650
697
|
return db.get<Row>(sql, ...params) ?? undefined;
|
|
651
698
|
}
|
|
652
699
|
|
|
653
700
|
/** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
654
|
-
function listFilters(url: URL, sql: string, params: unknown[]): string | Response {
|
|
701
|
+
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
655
702
|
const q = url.searchParams;
|
|
656
703
|
const status = q.get("status");
|
|
657
704
|
if (status !== null) {
|
|
@@ -666,6 +713,8 @@ function listFilters(url: URL, sql: string, params: unknown[]): string | Respons
|
|
|
666
713
|
if (v === "none") { sql += ` AND ${column} IS NULL`; continue; }
|
|
667
714
|
const m = /^(user|node):(.+)$/.exec(v);
|
|
668
715
|
if (!m) return jsonError(`invalid_${param}`, 400);
|
|
716
|
+
// 受限成员按一个没授权给他的节点筛选:与「没有这样的卡」同一结果(否则是「谁负责这张卡」的探测器)。
|
|
717
|
+
if (m[1] === "node" && ctx && hiddenFilterForScope(ctx, m[2])) { sql += " AND 1=0"; continue; }
|
|
669
718
|
sql += ` AND ${column} = ?${params.push(JSON.stringify({ kind: m[1], id: m[2] }))}`;
|
|
670
719
|
}
|
|
671
720
|
const since = q.get("updated_since");
|
|
@@ -679,10 +728,22 @@ function listFilters(url: URL, sql: string, params: unknown[]): string | Respons
|
|
|
679
728
|
const parent = q.get("parent_id");
|
|
680
729
|
if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
|
|
681
730
|
if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
|
|
682
|
-
|
|
731
|
+
// Explicit archived-only wins over the legacy include-all switch.
|
|
732
|
+
if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
|
|
733
|
+
else if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
|
|
683
734
|
return sql;
|
|
684
735
|
}
|
|
685
736
|
|
|
737
|
+
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
738
|
+
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
739
|
+
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
740
|
+
for (const networkId of restricted) {
|
|
741
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
742
|
+
if (hidden?.(nodeId)) return true;
|
|
743
|
+
}
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
|
|
686
747
|
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
687
748
|
const { req, url } = ctx;
|
|
688
749
|
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
@@ -692,25 +753,34 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
692
753
|
|
|
693
754
|
if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
|
|
694
755
|
|
|
756
|
+
if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
|
|
757
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
758
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
759
|
+
const rows = db.all<{ tags_json: string | null }>("SELECT tags_json FROM requirements WHERE network_id=?1", networkId);
|
|
760
|
+
const tags = [...new Set(rows.flatMap(row => storedTags(row.tags_json)))].sort();
|
|
761
|
+
return Response.json({ ok: true, networkId, tags });
|
|
762
|
+
}
|
|
763
|
+
|
|
695
764
|
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
696
765
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
697
766
|
if (!networkId) return jsonError('network_id_required', 400);
|
|
698
767
|
const users = db.all<{ id: string; name: string }>(
|
|
699
768
|
"SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 ORDER BY name, id", networkId,
|
|
700
769
|
);
|
|
770
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
701
771
|
const nodes = db.all<{ id: string; name: string }>(
|
|
702
772
|
"SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
|
|
703
|
-
);
|
|
773
|
+
).filter(row => !hidden?.(row.id));
|
|
704
774
|
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
705
775
|
}
|
|
706
776
|
|
|
707
777
|
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
708
778
|
const params: unknown[] = [];
|
|
709
779
|
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
710
|
-
sql =
|
|
711
|
-
const filtered = listFilters(url, sql, params);
|
|
780
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
781
|
+
const filtered = listFilters(url, sql, params, ctx);
|
|
712
782
|
if (typeof filtered !== "string") return filtered;
|
|
713
|
-
const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(
|
|
783
|
+
const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(row => toPublicFor(ctx, row));
|
|
714
784
|
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
715
785
|
return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
|
|
716
786
|
}
|
|
@@ -739,7 +809,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
739
809
|
return Response.json({ ...data, created: true }, { status: 201 });
|
|
740
810
|
}
|
|
741
811
|
const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
|
|
742
|
-
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement:
|
|
812
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
|
|
743
813
|
const res = patchRequirement(ctx, existing, patch);
|
|
744
814
|
if (res.status !== 200) return res;
|
|
745
815
|
const data = await res.json() as Record<string, unknown>;
|
|
@@ -763,7 +833,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
763
833
|
item.done = body.done;
|
|
764
834
|
db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), new Date().toISOString(), JSON.stringify(actorOf(ctx)), row.requirement_id]);
|
|
765
835
|
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
766
|
-
return Response.json({ ok: true, requirement:
|
|
836
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
|
|
767
837
|
}
|
|
768
838
|
|
|
769
839
|
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
@@ -771,7 +841,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
771
841
|
const id = decodeURIComponent(match[1]);
|
|
772
842
|
if (req.method === "GET") {
|
|
773
843
|
const row = scopedRow(ctx, id);
|
|
774
|
-
return row ? Response.json({ ok: true, requirement:
|
|
844
|
+
return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) }) : jsonError("requirement_not_found", 404);
|
|
775
845
|
}
|
|
776
846
|
if (req.method === "DELETE") {
|
|
777
847
|
// 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
|
|
@@ -31,7 +31,7 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
31
31
|
// —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
|
|
32
32
|
authMeNetwork: [
|
|
33
33
|
"network_id", "network_name", "owner_id", "description", "settings",
|
|
34
|
-
"created_at", "updated_at", "visibility", "max_members", "member_role",
|
|
34
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access",
|
|
35
35
|
],
|
|
36
36
|
networkDetail: [
|
|
37
37
|
"network_id", "network_name", "owner_id", "description", "settings",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 多用户 Agent 权限 —— 受限成员与上传文件。
|
|
2
|
+
// 文件按网络归属;受限成员(只看授权 Agent 的成员)在受限网络里不能凭 file_id 读网络里任意文件,
|
|
3
|
+
// 也不能把自己看不见的 file_id 塞进发给授权 Agent 的任务(那等于借 Agent 的网络令牌去读)。
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { db } from "./db.js";
|
|
6
|
+
import { addOwnTrafficScope, type RestNetworkScope } from "./network-scope.js";
|
|
7
|
+
import { FILE_ID_REGEX, indexEntryPath, validateIndexEntry } from "./uploads.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 受限成员能不能看见这个文件:只认**对方(授权 Agent / 给他发私信的人)放进来**的附件 ——
|
|
11
|
+
* user_inbox 里发给自己的私信附件;
|
|
12
|
+
* 授权 Agent 发给自己的任务 / inbox 行的 attachments;
|
|
13
|
+
* 自己发给授权 Agent 的任务上,Agent 回复写进的 reply_attachments。
|
|
14
|
+
* 自己随任务带出去的 attachments **不算**:否则把任意 file_id 塞进自己的任务就能「解锁」别人的文件。
|
|
15
|
+
*/
|
|
16
|
+
export function restrictedMemberSeesFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
17
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
18
|
+
const listed = (metaJson: string | null | undefined, key: "attachments" | "reply_attachments"): boolean => {
|
|
19
|
+
if (!metaJson) return false;
|
|
20
|
+
try {
|
|
21
|
+
const list = JSON.parse(metaJson)?.[key];
|
|
22
|
+
return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
};
|
|
25
|
+
// LIKE 只做预筛(file_id 只含 [A-Za-z0-9_-],`_` 转义后逐字匹配),真正的判定在 listed() 里按键解析。
|
|
26
|
+
const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
|
|
27
|
+
for (const row of db.all<{ meta_json: string | null }>(
|
|
28
|
+
"SELECT meta_json FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50",
|
|
29
|
+
userId, networkId, needle,
|
|
30
|
+
)) {
|
|
31
|
+
if (listed(row.meta_json, "attachments")) return true;
|
|
32
|
+
}
|
|
33
|
+
const scope: RestNetworkScope = { networkId, networkIds: null, agentRestriction: { userId, username, networkIds: [networkId] } };
|
|
34
|
+
const taskParams: unknown[] = [needle];
|
|
35
|
+
const taskSql = addOwnTrafficScope("SELECT from_name, to_name, meta_json FROM tasks WHERE meta_json LIKE ?1 ESCAPE '\\'", taskParams, scope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
36
|
+
for (const row of db.all<{ from_name: string; to_name: string; meta_json: string | null }>(taskSql + " LIMIT 50", ...taskParams)) {
|
|
37
|
+
if (row.to_name === username && listed(row.meta_json, "attachments")) return true;
|
|
38
|
+
if (row.from_name === username && listed(row.meta_json, "reply_attachments")) return true;
|
|
39
|
+
}
|
|
40
|
+
const inboxParams: unknown[] = [needle];
|
|
41
|
+
const inboxSql = addOwnTrafficScope("SELECT session_name, meta_json FROM inbox WHERE meta_json LIKE ?1 ESCAPE '\\'", inboxParams, scope, { from: "from_session", to: "session_name" });
|
|
42
|
+
for (const row of db.all<{ session_name: string; meta_json: string | null }>(inboxSql + " LIMIT 50", ...inboxParams)) {
|
|
43
|
+
if (row.session_name === username && listed(row.meta_json, "attachments")) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 受限成员能不能用这个文件(下载 / 当附件转给 Agent):自己传的,或 restrictedMemberSeesFile。 */
|
|
49
|
+
export function restrictedMemberCanUseFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
50
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
51
|
+
const path = indexEntryPath(fileId);
|
|
52
|
+
if (!path || !existsSync(path)) return false;
|
|
53
|
+
let entry: any;
|
|
54
|
+
try { entry = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
|
|
55
|
+
if (!validateIndexEntry(entry) || entry.file_id !== fileId) return false;
|
|
56
|
+
if (entry.network_id !== networkId) return false;
|
|
57
|
+
if (entry.owner_id === userId) return true;
|
|
58
|
+
return restrictedMemberSeesFile(userId, username, networkId, fileId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 受限成员发给 Agent 的任务 / 消息里的附件:每个 file_id 都必须是他自己能用的文件。
|
|
63
|
+
* 返回第一个不合格的 file_id(调用方回 403),全部合格返回 null。
|
|
64
|
+
*/
|
|
65
|
+
export function restrictedMemberAttachmentsDenied(userId: string, username: string, networkId: string, raw: unknown): string | null {
|
|
66
|
+
if (!Array.isArray(raw)) return null;
|
|
67
|
+
for (const item of raw) {
|
|
68
|
+
const fileId = item && typeof item === "object" ? (item as { file_id?: unknown }).file_id : undefined;
|
|
69
|
+
if (fileId === undefined) continue;
|
|
70
|
+
if (typeof fileId !== "string" || !restrictedMemberCanUseFile(userId, username, networkId, fileId)) return String(fileId);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -41,7 +41,8 @@ beforeAll(async () => {
|
|
|
41
41
|
expect(viewer.ok).toBe(true);
|
|
42
42
|
viewerToken = viewer.token!;
|
|
43
43
|
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
|
|
44
|
-
|
|
44
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
45
|
+
addNetworkMember(networkId, viewerId, "viewer", ownerId, { agentAccess: "all" });
|
|
45
46
|
const ntok = createNetworkTokenForNode(ownerId, networkId, "scheduler-node");
|
|
46
47
|
expect(ntok.ok).toBe(true);
|
|
47
48
|
nodeToken = ntok.token!;
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { db, logTaskEvent } from "./db.js";
|
|
2
2
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
3
3
|
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
4
|
+
import { canMessageAgent } from "./agent-access.js";
|
|
4
5
|
import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
5
6
|
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
6
7
|
|
|
@@ -266,6 +267,17 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
266
267
|
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
267
268
|
return;
|
|
268
269
|
}
|
|
270
|
+
// 多用户 Agent 权限:排程是「建它的人」的委托。建的人后来被设成受限成员、而且没被授权
|
|
271
|
+
// 给目标 Agent 发任务时,不再代他派发(否则受限前建的排程会永远绕过授权)。
|
|
272
|
+
if (row.created_by && !canMessageAgent(row.created_by, row.network_id, { alias: node.alias, nodeId: node.node_id })) {
|
|
273
|
+
finalStatus = "failed";
|
|
274
|
+
db.run(
|
|
275
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'creator_access_revoked', error_message = 'The schedule creator is no longer allowed to message this agent', completed_at = datetime('now') WHERE run_id = ?1",
|
|
276
|
+
[runId],
|
|
277
|
+
);
|
|
278
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
269
281
|
const lifecycle = assertNodeActive(node.alias, row.network_id);
|
|
270
282
|
if (!lifecycle.ok) {
|
|
271
283
|
finalStatus = "failed";
|