@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.
@@ -1,11 +1,14 @@
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";
5
- import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.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";
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
- const PRIORITIES = new Set(["high", "normal", "low"]);
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
- if (!exists) throw new Error('person_not_in_network');
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
- agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId);
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
- participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
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: toPublic(existing) });
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: toPublic(existing) });
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(toPublic(created), people.coerced), { status: 201 });
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(toPublic(updated), people.coerced));
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 = addNetworkScope(sql, params, ctx.scope);
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
- if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
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 = addNetworkScope(sql, params, ctx.scope);
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(toPublic);
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: toPublic(existing), created: false });
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: toPublic(updated) });
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: toPublic(row) }) : jsonError("requirement_not_found", 404);
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
- 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";