@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.31
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.
- package/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3508 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2312 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- 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
|
+
});
|