@sleep2agi/commhub-server 0.9.0-preview.73 → 0.9.0-preview.75

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.
@@ -2,11 +2,17 @@
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { createHash } from "node:crypto";
4
4
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
5
- import { db } from "./db.js";
6
- import { normalizeTags, storedTags } from "./requirement-tags.js";
5
+ import { db, logAudit } from "./db.js";
6
+ import { markGzipReusable } from "./http-gzip.js";
7
+ import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
8
+ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
7
9
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
8
- import { isAgentRestricted, visibleAgents } from "./agent-access.js";
9
- import { ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
10
+ import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
11
+ import {
12
+ addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
13
+ isTaskScoped, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
14
+ } from "./task-access.js";
15
+ import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
10
16
 
11
17
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
12
18
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
@@ -15,6 +21,9 @@ migrateRequirementAgentOwners(db);
15
21
  ensureRequirementProjects(db);
16
22
  ensureRequirementIndexes(db);
17
23
  ensureRequirementSeq(db);
24
+ ensureRequirementCompletedAt(db);
25
+ ensureNetworkTags(db);
26
+ ensureRequirementTombstones(db);
18
27
 
19
28
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
20
29
 
@@ -61,12 +70,36 @@ type Row = {
61
70
  tags_json: string | null;
62
71
  created_at: string;
63
72
  seq: number | null;
73
+ completed_at: string | null;
74
+ completed_by_json: string | null;
75
+ completed_at_approx: number | null;
64
76
  };
65
77
 
66
78
  function jsonError(error: string, status: number): Response {
67
79
  return Response.json({ ok: false, error }, { status });
68
80
  }
69
81
 
82
+ // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
83
+ // 节点令牌(Agent)、Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
84
+ function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
85
+ if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
86
+ return { userId: ctx.auth.userId };
87
+ }
88
+ /** 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。 */
89
+ function taskDenied(ctx: RequirementsRequestContext, row: Row, error: "task_read_only" | "task_delete_denied"): Response {
90
+ if (ctx.auth && shouldAuditDenied(ctx.auth.userId, row.requirement_id)) {
91
+ logAudit(ctx.auth.userId, ctx.auth.username || null, "task_access_denied", "requirement", row.requirement_id, JSON.stringify({ error }), undefined, row.network_id);
92
+ }
93
+ return jsonError(error, 403);
94
+ }
95
+ /** 父卡对调用者不可见 = 与不存在同一个错误(不给 scoped 成员留卡片存在性探测)。 */
96
+ function parentHidden(ctx: RequirementsRequestContext, parentId: string): boolean {
97
+ const caller = taskCaller(ctx);
98
+ if (!caller) return false;
99
+ const parent = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, parentId);
100
+ return !!parent && !canSeeTask(caller, parent);
101
+ }
102
+
70
103
  function dueOk(due: string): boolean {
71
104
  if (!DUE.test(due)) return false;
72
105
  const [y, m, d] = due.split("-").map(Number);
@@ -219,10 +252,15 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
219
252
  if (!networkId) return jsonError("network_id_required", 400);
220
253
  const one = url.pathname.match(/^\/api\/requirements\/projects\/([^/]+)$/);
221
254
  if (url.pathname === "/api/requirements/projects" && req.method === "GET") {
222
- const rows = db.all<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1 ORDER BY sort, created_at`, networkId);
255
+ // scoped 成员只列授权给自己的项目(RFC-038 §9.3)。
256
+ const projectParams: unknown[] = [networkId];
257
+ const projectSql = addProjectVisibilityScope(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1`, projectParams, taskCaller(ctx), networkId);
258
+ const rows = db.all<ProjectRow>(`${projectSql} ORDER BY sort, created_at`, ...projectParams);
223
259
  return Response.json({ ok: true, projects: rows.map(projectPublic) });
224
260
  }
225
261
  if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
262
+ // 项目管理(建 / 改名 / 归档 / 删)只给不受任务范围限制的人。
263
+ if (taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId)) return jsonError("permission_denied", 403);
226
264
  let body: Record<string, unknown> = {};
227
265
  if (req.method === "POST" || req.method === "PATCH") {
228
266
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
@@ -246,8 +284,10 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
246
284
  if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
247
285
  if (req.method === "DELETE") {
248
286
  // 先把卡片上的引用置空,再删项目:卡片一张不少。
249
- db.run("UPDATE requirements SET project_id = NULL WHERE project_id = ?1", [current.project_id]);
287
+ // updated_at 跟着动:按 updated_since 增量同步的客户端要看得见「项目被清空」这一改动。
288
+ db.run("UPDATE requirements SET project_id = NULL, updated_at = ?2 WHERE project_id = ?1", [current.project_id, new Date().toISOString()]);
250
289
  db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
290
+ deleteTaskGrantsForProject(current.project_id);
251
291
  return Response.json({ ok: true });
252
292
  }
253
293
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -274,6 +314,65 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
274
314
  return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
275
315
  }
276
316
 
317
+ // ── 标签管理(RFC-038 §9 的取舍:同项目管理) ──
318
+ // 改名 / 合并 / 删除要改写网络里**每一张**带这个标签的卡,包括调用者看不见的;所以只给不受任务范围限制的人
319
+ // (网络 owner / 管理员 / task_access='all' 的成员),scoped 成员一律 403 —— 不做「只改我看得见的那几张」:
320
+ // 那样同一个标签会被拆成两半,而且改动条数会泄露看不见的卡有几张。节点令牌在入口已被拒(tags_write)。
321
+ function canManageTags(ctx: RequirementsRequestContext, networkId: string): boolean {
322
+ if (ctx.isNodeToken || !canWrite(ctx, networkId)) return false;
323
+ return !(taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId));
324
+ }
325
+
326
+ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
327
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
328
+ if (!networkId) return jsonError("network_id_required", 400);
329
+ if (!canManageTags(ctx, networkId)) return jsonError("permission_denied", 403);
330
+ let body: Record<string, unknown>;
331
+ try { body = await bodyObject(ctx.req); } catch { return jsonError("invalid_json", 400); }
332
+ const op = parseTagOp(body);
333
+ if ("error" in op) return jsonError(op.error, 400);
334
+ const now = new Date().toISOString();
335
+ const actor = JSON.stringify(actorOf(ctx));
336
+ // 读-改-写整个网络的标签在一个事务里:中途失败一张都不改;颜色表跟着一起挪。
337
+ const result = db.transaction(() => {
338
+ const cards = db.all<{ requirement_id: string; tags_json: string | null }>(
339
+ "SELECT requirement_id, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
340
+ );
341
+ const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
342
+ const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
343
+ const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null);
344
+ if (!known) return null;
345
+ if (op.op === "color") {
346
+ if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
347
+ else db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (network_id, name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at", [networkId, op.tag, op.color, now]);
348
+ return { affected: 0 };
349
+ }
350
+ let affected = 0;
351
+ for (const card of cards) {
352
+ const next = applyTagOp(storedTags(card.tags_json), op);
353
+ if (!next) continue;
354
+ db.run("UPDATE requirements SET tags_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(next), now, actor, card.requirement_id]);
355
+ affected++;
356
+ }
357
+ moveTagColors(networkId, op, colorOf, now);
358
+ return { affected };
359
+ });
360
+ if (!result) return jsonError("tag_not_found", 404);
361
+ if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
362
+ return Response.json({ ok: true, op: op.op, affected: result.affected });
363
+ }
364
+
365
+ /** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
366
+ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) => string | null, now: string): void {
367
+ if (op.op === "color") return;
368
+ const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
369
+ if (op.op !== "delete" && colorOf(op.to) === null) {
370
+ const inherited = sources.map(colorOf).find(color => color !== null);
371
+ if (inherited) db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4)", [networkId, op.to, inherited, now]);
372
+ }
373
+ for (const tag of sources) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, tag]);
374
+ }
375
+
277
376
  function toPublic(row: Row) {
278
377
  return {
279
378
  owner: row.owner_json ? JSON.parse(row.owner_json) : null,
@@ -309,6 +408,11 @@ function toPublic(row: Row) {
309
408
  // 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
310
409
  parent_id: row.parent_id || null,
311
410
  children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
411
+ // 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
412
+ // 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
413
+ completedAt: row.completed_at || null,
414
+ completedAtApprox: !!row.completed_at_approx,
415
+ completedBy: row.completed_by_json ? JSON.parse(row.completed_by_json) : null,
312
416
  };
313
417
  }
314
418
 
@@ -348,8 +452,14 @@ function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
348
452
  const r = ref as { kind?: unknown; id?: unknown };
349
453
  return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
350
454
  }
455
+ // 每个请求一个权限解析器(按网络缓存成员行与项目授权)。
456
+ const permsByCtx = new WeakMap<RequirementsRequestContext, ReturnType<typeof taskPermissionsResolver>>();
351
457
  function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
352
- const pub = toPublic(row);
458
+ let perms = permsByCtx.get(ctx);
459
+ if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
460
+ // viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
461
+ const can = perms(row);
462
+ const pub = can ? { ...toPublic(row), viewer_can: can } : toPublic(row);
353
463
  const hidden = hiddenNodeFilter(ctx, row.network_id);
354
464
  if (!hidden) return pub;
355
465
  return {
@@ -359,10 +469,11 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
359
469
  participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
360
470
  created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
361
471
  updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
472
+ completedBy: isHiddenRef(pub.completedBy, hidden) ? null : pub.completedBy,
362
473
  };
363
474
  }
364
475
 
365
- 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, start_on, seq, " +
476
+ 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, start_on, seq, completed_at, completed_by_json, completed_at_approx, " +
366
477
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
367
478
  "(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";
368
479
 
@@ -428,6 +539,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
428
539
  const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
429
540
  function operationOf(req: Request, url: URL): string {
430
541
  if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
542
+ if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
431
543
  if (req.method === "GET") return "read";
432
544
  if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
433
545
  if (url.pathname === "/api/requirements/upsert") return "upsert";
@@ -444,11 +556,19 @@ function operationOf(req: Request, url: URL): string {
444
556
  */
445
557
  export function conditionalJson(req: Request, payload: unknown): Response {
446
558
  const body = JSON.stringify(payload);
447
- const etag = `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
559
+ return conditionalBody(req, body, bodyEtag(body));
560
+ }
561
+
562
+ function bodyEtag(body: string): string {
563
+ return `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
564
+ }
565
+
566
+ function conditionalBody(req: Request, body: string, etag: string): Response {
448
567
  // private:按用户可见范围生成,不能被共享缓存复用;no-cache:每次都要回来验证,不会拿旧表当新的。
449
568
  const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
450
569
  if (ifNoneMatchHits(req.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
451
- return new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } });
570
+ // ETag 就是正文的哈希:同一份正文的 gzip 结果可以复用(http-gzip.ts)。
571
+ return markGzipReusable(new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } }), etag);
452
572
  }
453
573
 
454
574
  function ifNoneMatchHits(header: string | null, etag: string): boolean {
@@ -457,8 +577,11 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
457
577
  return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
458
578
  }
459
579
 
580
+ // tag_ops:有 POST /api/requirements/tags/ops(标签改名 / 合并 / 删除 / 颜色),GET /api/requirements/tags 带 counts / colors / can_manage。
460
581
  // search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
461
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging"] as const;
582
+ // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
583
+ // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
584
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes"] as const;
462
585
 
463
586
  // ── 子需求 ──
464
587
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -579,18 +702,24 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
579
702
  if (description === null) return jsonError("invalid_description", 400);
580
703
  const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
581
704
  if (checklist === null) return jsonError("invalid_checklist", 400);
705
+ const caller = taskCaller(ctx);
706
+ const scoped = !!caller && isTaskScoped(caller.userId, networkId);
582
707
  let projectId: string | null = null;
583
708
  if (body.project_id !== undefined) {
584
709
  try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
710
+ // scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
711
+ if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
585
712
  }
586
713
  const ref = body.external_ref === undefined ? null : externalRef(body.external_ref);
587
714
  if (ref === undefined) return jsonError("invalid_external_ref", 400);
715
+ // scoped 成员不能带 external_ref:「external_ref_exists」会把别人的卡号回给他。
716
+ if (ref && scoped) return jsonError("external_ref_not_allowed", 403);
588
717
  const extUrl = body.external_url === undefined ? null : externalUrl(body.external_url);
589
718
  if (extUrl === undefined) return jsonError("invalid_external_url", 400);
590
719
  const parentId = body.parent_id === undefined ? null : parentIdOf(body.parent_id);
591
720
  if (parentId === undefined) return jsonError("invalid_parent_id", 400);
592
721
  if (parentId) {
593
- const err = parentError(networkId, parentId, null);
722
+ const err = parentHidden(ctx, parentId) ? "parent_not_found" : parentError(networkId, parentId, null);
594
723
  if (err) return jsonError(err, 400);
595
724
  }
596
725
  if (ref) {
@@ -600,7 +729,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
600
729
  }
601
730
  if (clientId) {
602
731
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
603
- if (existing) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
732
+ // 重放只回调用者看得见的卡;撞上别人的卡(scoped 看不见)= 固定的 409,不把那张卡回给他。
733
+ if (existing) return canSeeTask(caller, existing) ? Response.json({ ok: true, requirement: toPublicFor(ctx, existing) }) : jsonError("client_id_taken", 409);
604
734
  }
605
735
  const id = `req_${crypto.randomUUID()}`;
606
736
  const createdAt = new Date().toISOString();
@@ -611,9 +741,11 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
611
741
  // 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
612
742
  db.transaction(() => db.run(
613
743
  `INSERT INTO requirements
614
- (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, start_on, seq)
615
- 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, ?23, ?24)`,
616
- [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), start || null, nextRequirementSeq(db, networkId)],
744
+ (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, start_on, seq, completed_at, completed_by_json, completed_at_approx)
745
+ 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, ?23, ?24, ?25, ?26, 0)`,
746
+ [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), start || null, nextRequirementSeq(db, networkId),
747
+ // 直接建在「完成」列 = 此刻完成、由建卡的人完成。
748
+ column === "done" ? createdAt : null, column === "done" ? actor : null],
617
749
  ));
618
750
  } catch {
619
751
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
@@ -624,6 +756,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
624
756
  if (!clientId) return jsonError("insert_failed", 500);
625
757
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
626
758
  if (!existing) return jsonError("insert_failed", 500);
759
+ if (!canSeeTask(caller, existing)) return jsonError("client_id_taken", 409);
627
760
  return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
628
761
  }
629
762
  const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
@@ -636,6 +769,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
636
769
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
637
770
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
638
771
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
772
+ const caller = taskCaller(ctx);
773
+ if (!canEditTask(caller, row)) return taskDenied(ctx, row, "task_read_only");
774
+ // scoped 成员不能写 external_ref:唯一索引冲突会泄露别的卡用了这个 ref。
775
+ if (has("external_ref") && caller && isTaskScoped(caller.userId, row.network_id)) return jsonError("external_ref_not_allowed", 403);
639
776
  if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
640
777
  const issues = has("issues") ? normalizeIssues(body.issues) : null;
641
778
  if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
@@ -679,6 +816,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
679
816
  let projectId = row.project_id;
680
817
  if (has("project_id")) {
681
818
  try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
819
+ // scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
820
+ if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
682
821
  }
683
822
  let ref = row.external_ref;
684
823
  if (has("external_ref")) {
@@ -701,7 +840,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
701
840
  const next = parentIdOf(body.parent_id);
702
841
  if (next === undefined) return jsonError("invalid_parent_id", 400);
703
842
  if (next) {
704
- const err = parentError(row.network_id, next, row.requirement_id);
843
+ const err = parentHidden(ctx, next) ? "parent_not_found" : parentError(row.network_id, next, row.requirement_id);
705
844
  if (err) return jsonError(err, 400);
706
845
  }
707
846
  parentId = next;
@@ -712,12 +851,20 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
712
851
  archived = body.archived ? 1 : 0;
713
852
  }
714
853
  const updatedAt = new Date().toISOString();
854
+ const column = has("column") ? String(body.column) : row.column_name;
855
+ const actor = JSON.stringify(actorOf(ctx));
856
+ // 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
857
+ const completed = column !== "done" ? { at: null, by: null, approx: 0 }
858
+ : row.column_name !== "done" ? { at: updatedAt, by: actor, approx: 0 }
859
+ : { at: row.completed_at, by: row.completed_by_json, approx: row.completed_at_approx ? 1 : 0 };
715
860
  db.run(
716
861
  `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,
717
- 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, start_on = ?21
862
+ 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, start_on = ?21,
863
+ completed_at = ?22, completed_by_json = ?23, completed_at_approx = ?24
718
864
  WHERE requirement_id = ?3`,
719
- [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,
720
- 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, start || null],
865
+ [column, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
866
+ has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, actor, parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null,
867
+ completed.at, completed.by, completed.approx],
721
868
  );
722
869
  const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
723
870
  return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
@@ -748,6 +895,8 @@ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response
748
895
  const params: unknown[] = [seq ?? id];
749
896
  let sql = `SELECT ${SELECT} FROM requirements WHERE ${seq === null ? "requirement_id" : "seq"} = ?1`;
750
897
  sql = addHumanNetworkScope(sql, params, ctx.scope);
898
+ // 看不见的卡与不存在的卡同一个 404(逐字节相同);#N 的 ambiguous_seq 也只在看得见的行里判定。
899
+ sql = addTaskVisibilityScope(sql, params, taskCaller(ctx));
751
900
  const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
752
901
  if (rows.length > 1) {
753
902
  const networks = rows.map(r => r.network_id);
@@ -793,8 +942,9 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
793
942
  if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
794
943
  if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
795
944
  // Explicit archived-only wins over the legacy include-all switch.
945
+ // changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
796
946
  if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
797
- else if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
947
+ else if (q.get("include_archived") !== "1" && q.get("changes") !== "1") sql += " AND COALESCE(archived, 0) = 0";
798
948
  return sql;
799
949
  }
800
950
 
@@ -857,9 +1007,106 @@ function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string):
857
1007
  return false;
858
1008
  }
859
1009
 
1010
+ // ── 列表省流(2026-09-30,App「连接较慢 · 数据可能稍有延迟」)──
1011
+ // 生产:425 张卡、1447 条子任务,整张表 1 MB(gzip 240 KB),任务页每 15 s 读一次;中国经 RELAY 的链路上
1012
+ // 一次要好几秒。三件事,都是加法,旧 App 不带新参数就与原来逐字相同:
1013
+ // 1. view=summary:不带描述正文和子任务条目(打开一张卡再 GET /api/requirements/:id 读全文);
1014
+ // 2. changes=1 + updated_since:只回这之后改过的卡(含归档的),加上删掉的卡的 id 和下次用的 server_time;
1015
+ // 3. 表没变时不重算:按「调用者 + 查询」缓存上一次的正文和 ETag,需求表一有写入就作废(见 requirementsGeneration)。
1016
+
1017
+ /** view=summary 的一行:去掉 description / checklist,换成 has_description 和 checklist_count。 */
1018
+ function toSummary<T extends { description: string; checklist: ChecklistItem[] }>(pub: T): Omit<T, "description" | "checklist"> & { has_description: boolean; checklist_count: { total: number; done: number } } {
1019
+ const { description, checklist, ...rest } = pub;
1020
+ return { ...rest, has_description: !!description, checklist_count: { total: checklist.length, done: checklist.filter(item => item.done).length } };
1021
+ }
1022
+
1023
+ /** 墓碑保留多久。changes 的 updated_since 早于这之前,删除就可能漏报 —— 响应里的 tombstones_since 告诉客户端这个下限。 */
1024
+ export const TOMBSTONE_RETENTION_MS = 30 * 86_400_000;
1025
+
1026
+ /** 删卡时留一条墓碑(连同判断「谁看得见」要用的列,受限成员只收到他本来看得见的卡的删除)。 */
1027
+ function recordTombstone(row: Row, deletedAt: string): void {
1028
+ db.run(
1029
+ `INSERT INTO requirement_tombstones (requirement_id, network_id, deleted_at, project_id, owner_json, participants_json, created_by, created_by_json)
1030
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1031
+ ON CONFLICT(requirement_id) DO UPDATE SET deleted_at = excluded.deleted_at`,
1032
+ [row.requirement_id, row.network_id, deletedAt, row.project_id || null, row.owner_json || null, row.participants_json || null, row.created_by || null, row.created_by_json || null],
1033
+ );
1034
+ db.run("DELETE FROM requirement_tombstones WHERE deleted_at < ?1", [new Date(Date.parse(deletedAt) - TOMBSTONE_RETENTION_MS).toISOString()]);
1035
+ }
1036
+
1037
+ type TombstoneRow = { requirement_id: string; network_id: string; deleted_at: string; project_id: string | null; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null };
1038
+
1039
+ /** updated_since 之后删掉的、调用者看得见的卡。同 id 又出现在表里的(不该有)不算删除。 */
1040
+ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string[] {
1041
+ const params: unknown[] = [sinceIso];
1042
+ let sql = "SELECT requirement_id, network_id, deleted_at, project_id, owner_json, participants_json, created_by, created_by_json FROM requirement_tombstones WHERE deleted_at >= ?1";
1043
+ sql = addHumanNetworkScope(sql, params, ctx.scope);
1044
+ sql += " AND requirement_id NOT IN (SELECT requirement_id FROM requirements) ORDER BY deleted_at, requirement_id";
1045
+ const caller = taskCaller(ctx);
1046
+ return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
1047
+ }
1048
+
1049
+ /**
1050
+ * 需求表的写入代数:每个经过 handleRequirementsRequest 的非 GET 请求处理完就 +1(REST 和 MCP 的写入都走它,
1051
+ * tools.ts 调的是同一个处理函数)。列表缓存只在代数没变时复用。进程重启 = 缓存清空。
1052
+ */
1053
+ let requirementsGeneration = 0;
1054
+ /** 缓存条目最长信任这么久:兜住不经过处理函数的改动(启动迁移、运维脚本直接改库)。 */
1055
+ export const LIST_CACHE_MAX_AGE_MS = 60_000;
1056
+ /** 只缓存不带搜索词的列表(搜索每打一个字就是一个新键),总共最多这么多字符(条目按最近使用淘汰)。 */
1057
+ const LIST_CACHE_MAX_CHARS = 16 * 1024 * 1024;
1058
+ type ListCacheEntry = { generation: number; acl: string; at: number; body: string; etag: string };
1059
+ const listCache = new Map<string, ListCacheEntry>();
1060
+ let listCacheChars = 0;
1061
+ function rememberList(key: string, entry: ListCacheEntry): void {
1062
+ const old = listCache.get(key);
1063
+ if (old) { listCache.delete(key); listCacheChars -= old.body.length; }
1064
+ if (entry.body.length > LIST_CACHE_MAX_CHARS / 4) return;
1065
+ listCache.set(key, entry);
1066
+ listCacheChars += entry.body.length;
1067
+ for (const [k, v] of listCache) {
1068
+ if (listCacheChars <= LIST_CACHE_MAX_CHARS) break;
1069
+ listCache.delete(k);
1070
+ listCacheChars -= v.body.length;
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * 缓存能不能用、以及调用者权限的指纹。null = 这个调用者不缓存:受限成员(看得见哪些 Agent 取决于节点表)
1076
+ * 和「只看相关任务」的成员(看得见哪些卡取决于项目授权)每次照旧现算。
1077
+ * 其余调用者的输出只取决于需求表本身 + 他的角色 / 成员行:这些都放进指纹,变了就不命中。
1078
+ */
1079
+ function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1080
+ const caller = taskCaller(ctx);
1081
+ if (!caller) return `open:${ctx.isAdmin ? 1 : 0}:${ctx.isNodeToken ? 1 : 0}`;
1082
+ if (restrictedNetworkIds(caller.userId).length > 0) return null;
1083
+ const members = db.all<Record<string, unknown>>("SELECT * FROM network_members WHERE user_id = ?1 ORDER BY network_id", caller.userId);
1084
+ const networks = members.map(m => String(m.network_id));
1085
+ if (networks.some(networkId => isTaskScoped(caller.userId, networkId))) return null;
1086
+ const role = db.get<{ role: string | null }>("SELECT role FROM users WHERE user_id = ?1", caller.userId)?.role ?? null;
1087
+ return JSON.stringify({ role, members });
1088
+ }
1089
+
1090
+ function listCacheKey(ctx: RequirementsRequestContext): string {
1091
+ const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1092
+ return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted });
1093
+ }
1094
+
1095
+ /** Test-only. */
1096
+ export function __requirementsListCacheForTest() {
1097
+ return { size: () => listCache.size, clear: () => { listCache.clear(); listCacheChars = 0; }, generation: () => requirementsGeneration };
1098
+ }
1099
+
860
1100
  export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
861
- const { req, url } = ctx;
1101
+ const { url } = ctx;
862
1102
  if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
1103
+ if (ctx.req.method === "GET") return handleRequirementsRequestInner(ctx);
1104
+ // 写完(成功与否)才 +1:之后的 GET 一定看得见这次写入。
1105
+ try { return await handleRequirementsRequestInner(ctx); } finally { requirementsGeneration++; }
1106
+ }
1107
+
1108
+ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext): Promise<Response | null> {
1109
+ const { req, url } = ctx;
863
1110
  // 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
864
1111
  // 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
865
1112
  if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
@@ -869,9 +1116,40 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
869
1116
  if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
870
1117
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
871
1118
  if (!networkId) return jsonError("network_id_required", 400);
872
- const rows = db.all<{ tags_json: string | null }>("SELECT tags_json FROM requirements WHERE network_id=?1", networkId);
873
- const tags = [...new Set(rows.flatMap(row => storedTags(row.tags_json)))].sort();
874
- return Response.json({ ok: true, networkId, tags });
1119
+ const tagParams: unknown[] = [networkId];
1120
+ const tagSql = addTaskVisibilityScope("SELECT tags_json FROM requirements WHERE network_id=?1", tagParams, taskCaller(ctx));
1121
+ const rows = db.all<{ tags_json: string | null }>(tagSql, ...tagParams);
1122
+ // counts:调用者看得见的卡里(含归档)每个标签用了几张;colors:只回这些标签的颜色(看不见的卡上的标签不外泄)。
1123
+ // can_manage:这个调用者能不能用 /tags/ops。旧 App 只读 tags,多出的字段忽略。
1124
+ const counts: Record<string, number> = {};
1125
+ for (const row of rows) for (const tag of storedTags(row.tags_json)) counts[tag] = (counts[tag] ?? 0) + 1;
1126
+ const tags = Object.keys(counts).sort();
1127
+ const colors: Record<string, string> = {};
1128
+ for (const row of db.all<{ name: string; color: string }>("SELECT name, color FROM network_tags WHERE network_id = ?1", networkId)) {
1129
+ if (counts[row.name]) colors[row.name] = row.color;
1130
+ }
1131
+ return Response.json({ ok: true, networkId, tags, counts, colors, can_manage: canManageTags(ctx, networkId) });
1132
+ }
1133
+
1134
+ if (url.pathname === "/api/requirements/tags/ops") {
1135
+ if (req.method !== "POST") return jsonError("not_found", 404);
1136
+ return handleTagOp(ctx);
1137
+ }
1138
+
1139
+ // 仪表盘聚合(requirements-stats.ts):一个网络、调用者看得见的卡(与列表同一个可见范围),含归档的卡 ——
1140
+ // 完成的卡常被归档,不算它们「今天完成了多少」就会偏少。
1141
+ if (url.pathname === "/api/requirements/stats" && req.method === "GET") {
1142
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1143
+ if (!networkId) return jsonError("network_id_required", 400);
1144
+ const sq = parseStatsQuery(url.searchParams, Date.now());
1145
+ if ("error" in sq) return jsonError(sq.error, 400);
1146
+ const statsParams: unknown[] = [networkId];
1147
+ const statsSql = addTaskVisibilityScope(
1148
+ "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
1149
+ statsParams, taskCaller(ctx),
1150
+ );
1151
+ const rows = db.all<StatsRow>(statsSql, ...statsParams);
1152
+ return conditionalJson(ctx.req, { ok: true, networkId, ...aggregateStats(rows, sq, hiddenNodeFilter(ctx, networkId)) });
875
1153
  }
876
1154
 
877
1155
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
@@ -892,15 +1170,43 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
892
1170
  // 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
893
1171
  let sql = "FROM requirements WHERE 1=1";
894
1172
  sql = addHumanNetworkScope(sql, params, ctx.scope);
1173
+ sql = addTaskVisibilityScope(sql, params, taskCaller(ctx));
895
1174
  const filtered = listFilters(url, sql, params, ctx);
896
1175
  if (typeof filtered !== "string") return filtered;
897
1176
  // q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
898
1177
  const lq = parseListQuery(url.searchParams);
899
1178
  if ("error" in lq) return jsonError(lq.error, 400);
1179
+ const view = url.searchParams.get("view");
1180
+ if (view !== null && view !== "full" && view !== "summary") return jsonError("invalid_view", 400);
1181
+ const changes = url.searchParams.get("changes") === "1";
1182
+ if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
1183
+ // changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
1184
+ const serverTime = new Date().toISOString();
1185
+ const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
1186
+ const acl = cacheKey ? listCacheAcl(ctx) : null;
1187
+ const generation = requirementsGeneration;
1188
+ const cached = cacheKey && acl !== null ? listCache.get(cacheKey) : undefined;
1189
+ if (cached && cached.generation === generation && cached.acl === acl && Date.now() - cached.at < LIST_CACHE_MAX_AGE_MS) {
1190
+ listCache.delete(cacheKey!);
1191
+ listCache.set(cacheKey!, cached);
1192
+ return conditionalBody(ctx.req, cached.body, cached.etag);
1193
+ }
900
1194
  const page = listPage(ctx, filtered, params, lq);
1195
+ const rows = page.rows.map(row => toPublicFor(ctx, row));
901
1196
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
902
1197
  // has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
903
- return conditionalJson(ctx.req, { ok: true, requirements: page.rows.map(row => toPublicFor(ctx, row)), capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor });
1198
+ const payload: Record<string, unknown> = { ok: true, requirements: view === "summary" ? rows.map(toSummary) : rows, capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor };
1199
+ if (view === "summary") payload.view = "summary";
1200
+ if (changes) {
1201
+ const since = new Date(Date.parse(url.searchParams.get("updated_since")!)).toISOString();
1202
+ payload.deleted = deletedSince(ctx, since);
1203
+ payload.server_time = serverTime;
1204
+ payload.tombstones_since = new Date(Date.parse(serverTime) - TOMBSTONE_RETENTION_MS).toISOString();
1205
+ }
1206
+ const body = JSON.stringify(payload);
1207
+ const etag = bodyEtag(body);
1208
+ if (cacheKey && acl !== null) rememberList(cacheKey, { generation, acl, at: Date.now(), body, etag });
1209
+ return conditionalBody(ctx.req, body, etag);
904
1210
  }
905
1211
 
906
1212
  if (url.pathname === "/api/requirements" && req.method === "POST") {
@@ -919,6 +1225,8 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
919
1225
  const networkId = writeNetwork(body, ctx);
920
1226
  if (!networkId) return jsonError("network_id_required", 400);
921
1227
  if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
1228
+ // scoped 成员不能用同步接口:external_ref 的唯一索引会把「这个 ref 已经有卡」泄露出去。固定错误,不看 ref。
1229
+ if (taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId)) return jsonError("upsert_not_allowed", 403);
922
1230
  const existing = rowByExternalRef(networkId, ref);
923
1231
  if (!existing) {
924
1232
  const res = await createRequirement(ctx, body);
@@ -943,6 +1251,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
943
1251
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
944
1252
  if (row instanceof Response) return row;
945
1253
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1254
+ if (!canEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
946
1255
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
947
1256
  const items = storedChecklist(row.checklist_json);
948
1257
  const itemId = decodeURIComponent(itemMatch[2]);
@@ -966,9 +1275,14 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
966
1275
  const row = scopedRow(ctx, id);
967
1276
  if (row instanceof Response) return row;
968
1277
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1278
+ if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
969
1279
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
970
- db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
1280
+ const deletedAt = new Date().toISOString();
1281
+ db.run("UPDATE requirements SET parent_id = NULL, updated_at = ?2 WHERE parent_id = ?1", [row.requirement_id, deletedAt]);
971
1282
  db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
1283
+ recordTombstone(row, deletedAt);
1284
+ // 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
1285
+ if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
972
1286
  return Response.json({ ok: true, deleted: row.requirement_id });
973
1287
  }
974
1288
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -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", "agent_access",
34
+ "created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access", "task_access",
35
35
  ],
36
36
  networkDetail: [
37
37
  "network_id", "network_name", "owner_id", "description", "settings",