@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.
@@ -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);
@@ -1,7 +1,9 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { db } from "./db.js";
4
- import { addNetworkScope, canRestWriteNetwork, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
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
- if (!exists) throw new Error('person_not_in_network');
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
- agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId);
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
- participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
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: toPublic(existing) });
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: toPublic(existing) });
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(toPublic(created), people.coerced), { status: 201 });
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(toPublic(updated), people.coerced));
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 = addNetworkScope(sql, params, ctx.scope);
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
- if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
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 = addNetworkScope(sql, params, ctx.scope);
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(toPublic);
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: toPublic(existing), created: false });
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: toPublic(updated) });
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: toPublic(row) }) : jsonError("requirement_not_found", 404);
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
- addNetworkMember(networkId, viewerId, "viewer", ownerId);
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!;
@@ -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";