@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,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
+ }
@@ -0,0 +1,135 @@
1
+ // #495 — DEV_OPEN mode null-owner allowance (Test B from PR #500 CR
2
+ // checklist). Sibling to file-download-authz.test.ts.
3
+ //
4
+ // Why a separate file: DEV_OPEN is captured at server.ts module-load
5
+ // time (`const DEV_OPEN = process.argv.includes("--dev-open") ||
6
+ // process.env.COMMHUB_DEV_OPEN === "1"`). Once the singleton is
7
+ // loaded with DEV_OPEN=false, no `process.env` change can flip it
8
+ // back inside the same Bun process. So the DEV_OPEN test lives in
9
+ // its own file that sets `COMMHUB_DEV_OPEN=1` BEFORE importing.
10
+ //
11
+ // Skip discipline: if this file runs in aggregate `bun test src/`
12
+ // AFTER file-download-authz.test.ts has already loaded server.ts
13
+ // with DEV_OPEN=false, our env change is ignored. We detect that at
14
+ // module load via a boot probe and `test.skipIf` the whole file so
15
+ // the runner reports "skipped" (NOT "passed"). Standalone run
16
+ // (`bun test src/file-download-authz-dev-open.test.ts`) always
17
+ // works — CI must include a standalone target for this file to
18
+ // guarantee it actually executes.
19
+
20
+ import { describe, expect, test, afterAll } from "bun:test";
21
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
22
+ import { join } from "path";
23
+ import { tmpdir } from "os";
24
+
25
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-495-devopen-db-")) + "/commhub.db";
26
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-devopen-fs-"));
27
+
28
+ // Set env BEFORE importing anything that touches db-adapter.ts or
29
+ // server.ts (both capture env at module load).
30
+ process.env.COMMHUB_DB ||= SERVER_DB;
31
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
32
+ process.env.HOST = "127.0.0.1";
33
+ process.env.COMMHUB_DEV_OPEN = "1";
34
+ delete process.env.COMMHUB_AUTH_TOKEN;
35
+
36
+ const { register } = await import("./auth.js");
37
+ const { db } = await import("./db.js");
38
+
39
+ // #500 CR2 DEV_OPEN 假门 fix — register a FIRST user (userA) so any
40
+ // "first-user-auto-admin" logic falls on userA. Then register userB
41
+ // as the non-admin download-attempt actor. Without this two-user
42
+ // split, the download test would short-circuit through the admin
43
+ // allow-list branch instead of exercising the DEV_OPEN branch — the
44
+ // mutation test (remove DEV_OPEN branch → expect RED) would silently
45
+ // still pass.
46
+ const nameA = `useraaa_devopen_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
47
+ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
48
+ if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
49
+
50
+ const nameB = `userbbb_devopen_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
51
+ const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
52
+ if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`);
53
+ const userBToken = rB.token!;
54
+ const userBUserId = rB.user!.user_id;
55
+
56
+ // Fixture-drift guard: userB must NOT be admin. If a future auth.ts
57
+ // change grants admin to more than the first user (or the DB seeder
58
+ // promotes both), the DEV_OPEN test would silently short-circuit
59
+ // through the admin branch of authorizeFileDownload. Fail loudly.
60
+ {
61
+ const roleRow = db.get<{ role: string }>(
62
+ "SELECT role FROM users WHERE user_id = ?1",
63
+ userBUserId,
64
+ );
65
+ if (!roleRow) throw new Error("userB not found in users table after register");
66
+ if (roleRow.role === "admin") {
67
+ throw new Error(
68
+ `[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` +
69
+ `(role=${roleRow.role}) in DEV_OPEN sibling test. Refusing to run: ` +
70
+ `the DEV_OPEN branch would not actually be exercised — the download ` +
71
+ `would short-circuit through the admin allow-list and validate nothing.`,
72
+ );
73
+ }
74
+ }
75
+
76
+ const { bootServer } = await import("./server.js");
77
+ const server = bootServer({ port: 0, hostname: "127.0.0.1" });
78
+ const BASE = `http://127.0.0.1:${server.port}`;
79
+ await new Promise((r) => setTimeout(r, 100));
80
+
81
+ // Probe: is DEV_OPEN active in the loaded server module? Upload a file
82
+ // with no auth — DEV_OPEN allows anon; production would 401.
83
+ async function probeDevOpen(): Promise<boolean> {
84
+ const form = new FormData();
85
+ form.append("file", new Blob([new TextEncoder().encode("probe")], { type: "application/octet-stream" }), "probe.bin");
86
+ const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
87
+ return res.status === 200;
88
+ }
89
+ const devOpenActive = await probeDevOpen();
90
+
91
+ afterAll(() => {
92
+ try { server?.stop?.(true); } catch {}
93
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
94
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
95
+ delete process.env.COMMHUB_DEV_OPEN;
96
+ });
97
+
98
+ async function uploadAnon(content: Uint8Array, filename: string): Promise<string> {
99
+ const form = new FormData();
100
+ form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
101
+ const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
102
+ expect(res.status).toBe(200);
103
+ const body: any = await res.json();
104
+ expect(typeof body.file_id).toBe("string");
105
+ return body.file_id;
106
+ }
107
+
108
+ async function downloadAs(token: string | null, fileId: string): Promise<Response> {
109
+ const headers: Record<string, string> = {};
110
+ if (token) headers.Authorization = `Bearer ${token}`;
111
+ return fetch(`${BASE}/api/files/${fileId}`, { headers });
112
+ }
113
+
114
+ describe.skipIf(!devOpenActive)("#495 — DEV_OPEN null-owner allowance (Test B)", () => {
115
+ test("null-owner uploaded via anonymous DEV_OPEN request → non-admin userB can download (200) via DEV_OPEN branch", async () => {
116
+ const devOpenFileId = await uploadAnon(new TextEncoder().encode("dev-open-blob"), "dev.bin");
117
+
118
+ // Verify the upload really produced null-owner (matches the shape
119
+ // production callers might see if DEV_OPEN were misconfigured).
120
+ const { readFileSync } = await import("fs");
121
+ const entryPath = join(UPLOADS_DIR, ".index", `${devOpenFileId}.json`);
122
+ if (existsSync(entryPath)) {
123
+ const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
124
+ expect(entry.owner_id).toBeNull();
125
+ }
126
+
127
+ // userB is non-admin (guarded at module load). If DEV_OPEN branch
128
+ // is removed from authorizeFileDownload, this returns 404 instead
129
+ // of 200 — the assertion below turns red, catching the regression
130
+ // structurally rather than via admin-branch short-circuit.
131
+ const res = await downloadAs(userBToken, devOpenFileId);
132
+ expect(res.status).toBe(200);
133
+ expect(await res.text()).toBe("dev-open-blob");
134
+ });
135
+ });