@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.69
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 +31 -1
- 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 +10 -0
- package/src/requirements-migrate.ts +32 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements.ts +107 -35
- 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 +99 -32
package/src/requirements.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
2
|
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
3
|
import { db } from "./db.js";
|
|
4
|
-
import {
|
|
5
|
-
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";
|
|
7
|
+
import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners, migrateRequirementPriorityCheck } from "./requirements-migrate.js";
|
|
6
8
|
|
|
7
9
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
8
10
|
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
11
|
+
migrateRequirementPriorityCheck(db);
|
|
9
12
|
migrateRequirementAgentOwners(db);
|
|
10
13
|
ensureRequirementProjects(db);
|
|
11
14
|
ensureRequirementIndexes(db);
|
|
@@ -22,7 +25,8 @@ export type RequirementsRequestContext = {
|
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
25
|
-
|
|
28
|
+
// 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
|
|
29
|
+
const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
|
|
26
30
|
const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
27
31
|
|
|
28
32
|
type Row = {
|
|
@@ -50,6 +54,7 @@ type Row = {
|
|
|
50
54
|
due_on: string | null;
|
|
51
55
|
assignee: string | null;
|
|
52
56
|
issues_json: string | null;
|
|
57
|
+
tags_json: string | null;
|
|
53
58
|
created_at: string;
|
|
54
59
|
};
|
|
55
60
|
|
|
@@ -284,6 +289,7 @@ function toPublic(row: Row) {
|
|
|
284
289
|
createdAt: row.created_at,
|
|
285
290
|
updatedAt: row.updated_at || row.created_at,
|
|
286
291
|
issues: storedIssues(row.issues_json),
|
|
292
|
+
tags: storedTags(row.tags_json),
|
|
287
293
|
// 同步用的外部引用(如 github:owner/repo#123)和链接;归档的卡默认不在列表里(include_archived=1 才有)。
|
|
288
294
|
external_ref: row.external_ref || null,
|
|
289
295
|
external_url: row.external_url || null,
|
|
@@ -312,22 +318,55 @@ function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestCon
|
|
|
312
318
|
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
313
319
|
// 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
|
|
314
320
|
if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
|
|
315
|
-
return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
321
|
+
if (ctx.isNodeToken) return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
322
|
+
// 需求看板是人类协作面:受限成员(只看授权 Agent 的成员)照样能读写卡片,
|
|
323
|
+
// 只是卡片上没授权给他的 Agent 引用会被隐去、也不能被他指派(见 hiddenNodeFilter)。
|
|
324
|
+
return canRestWriteNetworkAsHuman(ctx.auth, networkId, ctx.isAdmin);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 多用户 Agent 权限 ──
|
|
328
|
+
// 受限成员看需求卡时:agent_owner / 参与人 / created_by / updated_by 里没授权给他的节点一律隐去;
|
|
329
|
+
// 他写卡时也不能引用这些节点。返回 null = 这个调用者在这个网络里不受限。
|
|
330
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
331
|
+
function hiddenNodeFilter(ctx: RequirementsRequestContext, networkId: string): HiddenNode {
|
|
332
|
+
if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
|
|
333
|
+
if (!isAgentRestricted(ctx.auth.userId, networkId)) return null;
|
|
334
|
+
const visible = new Set(visibleAgents(ctx.auth.userId, networkId).nodeIds);
|
|
335
|
+
return (nodeId: string) => !visible.has(nodeId);
|
|
336
|
+
}
|
|
337
|
+
function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
|
|
338
|
+
if (!hidden || !ref || typeof ref !== "object") return false;
|
|
339
|
+
const r = ref as { kind?: unknown; id?: unknown };
|
|
340
|
+
return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
|
|
341
|
+
}
|
|
342
|
+
function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
343
|
+
const pub = toPublic(row);
|
|
344
|
+
const hidden = hiddenNodeFilter(ctx, row.network_id);
|
|
345
|
+
if (!hidden) return pub;
|
|
346
|
+
return {
|
|
347
|
+
...pub,
|
|
348
|
+
owner: isHiddenRef(pub.owner, hidden) ? null : pub.owner,
|
|
349
|
+
agent_owner: isHiddenRef(pub.agent_owner, hidden) ? null : pub.agent_owner,
|
|
350
|
+
participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
|
|
351
|
+
created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
|
|
352
|
+
updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
|
|
353
|
+
};
|
|
316
354
|
}
|
|
317
355
|
|
|
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, " +
|
|
356
|
+
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
357
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
320
358
|
"(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
359
|
|
|
322
360
|
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
323
|
-
function personRef(value: unknown, networkId: string): PersonRef {
|
|
361
|
+
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
|
|
324
362
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
325
363
|
const row = value as Record<string, unknown>;
|
|
326
364
|
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
327
365
|
const exists = row.kind === 'user'
|
|
328
366
|
? 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
367
|
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
330
|
-
|
|
368
|
+
// 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
|
|
369
|
+
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
|
|
331
370
|
return { kind: row.kind, id: row.id };
|
|
332
371
|
}
|
|
333
372
|
|
|
@@ -336,12 +375,14 @@ function personRef(value: unknown, networkId: string): PersonRef {
|
|
|
336
375
|
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
337
376
|
// → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
|
|
338
377
|
// 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
|
|
339
|
-
function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
|
|
378
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
|
|
340
379
|
let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
341
380
|
let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
|
|
342
381
|
let coerced: PersonRef | null = null;
|
|
382
|
+
// 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
|
|
383
|
+
const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
|
|
343
384
|
if ('owner' in body) {
|
|
344
|
-
owner = body.owner === null ? null : personRef(body.owner, networkId);
|
|
385
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
345
386
|
if (owner && owner.kind !== 'user') {
|
|
346
387
|
if ('agent_owner' in body) throw new Error('owner_must_be_human');
|
|
347
388
|
coerced = owner;
|
|
@@ -349,15 +390,19 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
349
390
|
owner = null;
|
|
350
391
|
}
|
|
351
392
|
}
|
|
393
|
+
if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
352
394
|
if ('agent_owner' in body) {
|
|
353
|
-
|
|
395
|
+
if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
396
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
|
|
354
397
|
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
355
398
|
}
|
|
356
399
|
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
357
400
|
if ('participants' in body) {
|
|
358
401
|
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
|
-
|
|
402
|
+
const refs = body.participants.map(value => personRef(value, networkId, hidden));
|
|
403
|
+
// 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
|
|
404
|
+
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
405
|
+
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
361
406
|
}
|
|
362
407
|
return {
|
|
363
408
|
coerced,
|
|
@@ -382,7 +427,7 @@ function operationOf(req: Request, url: URL): string {
|
|
|
382
427
|
return "create";
|
|
383
428
|
}
|
|
384
429
|
|
|
385
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements"] as const;
|
|
430
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest"] as const;
|
|
386
431
|
|
|
387
432
|
// ── 子需求 ──
|
|
388
433
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -495,6 +540,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
495
540
|
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
496
541
|
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
497
542
|
if (issues === null) return jsonError("invalid_issues", 400);
|
|
543
|
+
const tags = body.tags === undefined ? [] : normalizeTags(body.tags);
|
|
544
|
+
if (tags === null) return jsonError("invalid_tags", 400);
|
|
498
545
|
const description = body.description === undefined ? "" : normalizeDescription(body.description);
|
|
499
546
|
if (description === null) return jsonError("invalid_description", 400);
|
|
500
547
|
const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
|
|
@@ -520,19 +567,19 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
520
567
|
}
|
|
521
568
|
if (clientId) {
|
|
522
569
|
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:
|
|
570
|
+
if (existing) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
524
571
|
}
|
|
525
572
|
const id = `req_${crypto.randomUUID()}`;
|
|
526
573
|
const createdAt = new Date().toISOString();
|
|
527
574
|
let people;
|
|
528
|
-
try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
575
|
+
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
529
576
|
const actor = JSON.stringify(actorOf(ctx));
|
|
530
577
|
try {
|
|
531
578
|
db.run(
|
|
532
579
|
`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],
|
|
580
|
+
(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)
|
|
581
|
+
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)`,
|
|
582
|
+
[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
583
|
);
|
|
537
584
|
} catch {
|
|
538
585
|
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
@@ -543,14 +590,14 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
543
590
|
if (!clientId) return jsonError("insert_failed", 500);
|
|
544
591
|
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
545
592
|
if (!existing) return jsonError("insert_failed", 500);
|
|
546
|
-
return Response.json({ ok: true, requirement:
|
|
593
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
547
594
|
}
|
|
548
595
|
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
549
|
-
return Response.json(withLegacyOwner(
|
|
596
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, created), people.coerced), { status: 201 });
|
|
550
597
|
}
|
|
551
598
|
|
|
552
599
|
// ── 修改(省略的字段保留原值) ──
|
|
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"];
|
|
600
|
+
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
601
|
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
555
602
|
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
556
603
|
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
@@ -558,6 +605,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
558
605
|
if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
559
606
|
const issues = has("issues") ? normalizeIssues(body.issues) : null;
|
|
560
607
|
if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
|
|
608
|
+
const tags = has("tags") ? normalizeTags(body.tags) : null;
|
|
609
|
+
if (has("tags") && tags === null) return jsonError("invalid_tags", 400);
|
|
561
610
|
const description = has("description") ? normalizeDescription(body.description) : null;
|
|
562
611
|
if (has("description") && description === null) return jsonError("invalid_description", 400);
|
|
563
612
|
const checklist = has("checklist") ? normalizeChecklist(body.checklist) : null;
|
|
@@ -585,7 +634,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
585
634
|
assignee = body.assignee.trim().slice(0, 80);
|
|
586
635
|
}
|
|
587
636
|
let people;
|
|
588
|
-
try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
637
|
+
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
589
638
|
let projectId = row.project_id;
|
|
590
639
|
if (has("project_id")) {
|
|
591
640
|
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
@@ -624,13 +673,13 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
624
673
|
const updatedAt = new Date().toISOString();
|
|
625
674
|
db.run(
|
|
626
675
|
`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
|
|
676
|
+
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
677
|
WHERE requirement_id = ?3`,
|
|
629
678
|
[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],
|
|
679
|
+
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
680
|
);
|
|
632
681
|
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
633
|
-
return Response.json(withLegacyOwner(
|
|
682
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
|
|
634
683
|
}
|
|
635
684
|
|
|
636
685
|
/**
|
|
@@ -646,12 +695,12 @@ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: Pers
|
|
|
646
695
|
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
|
|
647
696
|
const params: unknown[] = [id];
|
|
648
697
|
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
649
|
-
sql =
|
|
698
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
650
699
|
return db.get<Row>(sql, ...params) ?? undefined;
|
|
651
700
|
}
|
|
652
701
|
|
|
653
702
|
/** 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 {
|
|
703
|
+
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
655
704
|
const q = url.searchParams;
|
|
656
705
|
const status = q.get("status");
|
|
657
706
|
if (status !== null) {
|
|
@@ -666,6 +715,8 @@ function listFilters(url: URL, sql: string, params: unknown[]): string | Respons
|
|
|
666
715
|
if (v === "none") { sql += ` AND ${column} IS NULL`; continue; }
|
|
667
716
|
const m = /^(user|node):(.+)$/.exec(v);
|
|
668
717
|
if (!m) return jsonError(`invalid_${param}`, 400);
|
|
718
|
+
// 受限成员按一个没授权给他的节点筛选:与「没有这样的卡」同一结果(否则是「谁负责这张卡」的探测器)。
|
|
719
|
+
if (m[1] === "node" && ctx && hiddenFilterForScope(ctx, m[2])) { sql += " AND 1=0"; continue; }
|
|
669
720
|
sql += ` AND ${column} = ?${params.push(JSON.stringify({ kind: m[1], id: m[2] }))}`;
|
|
670
721
|
}
|
|
671
722
|
const since = q.get("updated_since");
|
|
@@ -679,10 +730,22 @@ function listFilters(url: URL, sql: string, params: unknown[]): string | Respons
|
|
|
679
730
|
const parent = q.get("parent_id");
|
|
680
731
|
if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
|
|
681
732
|
if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
|
|
682
|
-
|
|
733
|
+
// Explicit archived-only wins over the legacy include-all switch.
|
|
734
|
+
if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
|
|
735
|
+
else if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
|
|
683
736
|
return sql;
|
|
684
737
|
}
|
|
685
738
|
|
|
739
|
+
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
740
|
+
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
741
|
+
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
742
|
+
for (const networkId of restricted) {
|
|
743
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
744
|
+
if (hidden?.(nodeId)) return true;
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
686
749
|
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
687
750
|
const { req, url } = ctx;
|
|
688
751
|
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
@@ -692,25 +755,34 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
692
755
|
|
|
693
756
|
if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
|
|
694
757
|
|
|
758
|
+
if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
|
|
759
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
760
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
761
|
+
const rows = db.all<{ tags_json: string | null }>("SELECT tags_json FROM requirements WHERE network_id=?1", networkId);
|
|
762
|
+
const tags = [...new Set(rows.flatMap(row => storedTags(row.tags_json)))].sort();
|
|
763
|
+
return Response.json({ ok: true, networkId, tags });
|
|
764
|
+
}
|
|
765
|
+
|
|
695
766
|
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
696
767
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
697
768
|
if (!networkId) return jsonError('network_id_required', 400);
|
|
698
769
|
const users = db.all<{ id: string; name: string }>(
|
|
699
770
|
"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
771
|
);
|
|
772
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
701
773
|
const nodes = db.all<{ id: string; name: string }>(
|
|
702
774
|
"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
|
-
);
|
|
775
|
+
).filter(row => !hidden?.(row.id));
|
|
704
776
|
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
705
777
|
}
|
|
706
778
|
|
|
707
779
|
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
708
780
|
const params: unknown[] = [];
|
|
709
781
|
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
710
|
-
sql =
|
|
711
|
-
const filtered = listFilters(url, sql, params);
|
|
782
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
783
|
+
const filtered = listFilters(url, sql, params, ctx);
|
|
712
784
|
if (typeof filtered !== "string") return filtered;
|
|
713
|
-
const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(
|
|
785
|
+
const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(row => toPublicFor(ctx, row));
|
|
714
786
|
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
715
787
|
return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
|
|
716
788
|
}
|
|
@@ -739,7 +811,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
739
811
|
return Response.json({ ...data, created: true }, { status: 201 });
|
|
740
812
|
}
|
|
741
813
|
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:
|
|
814
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
|
|
743
815
|
const res = patchRequirement(ctx, existing, patch);
|
|
744
816
|
if (res.status !== 200) return res;
|
|
745
817
|
const data = await res.json() as Record<string, unknown>;
|
|
@@ -763,7 +835,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
763
835
|
item.done = body.done;
|
|
764
836
|
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
837
|
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
766
|
-
return Response.json({ ok: true, requirement:
|
|
838
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
|
|
767
839
|
}
|
|
768
840
|
|
|
769
841
|
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
@@ -771,7 +843,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
771
843
|
const id = decodeURIComponent(match[1]);
|
|
772
844
|
if (req.method === "GET") {
|
|
773
845
|
const row = scopedRow(ctx, id);
|
|
774
|
-
return row ? Response.json({ ok: true, requirement:
|
|
846
|
+
return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) }) : jsonError("requirement_not_found", 404);
|
|
775
847
|
}
|
|
776
848
|
if (req.method === "DELETE") {
|
|
777
849
|
// 删除只给人(节点令牌在入口已被拒):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";
|