@desplega.ai/agent-swarm 1.78.1 → 1.79.0

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 (46) hide show
  1. package/openapi.json +542 -1
  2. package/package.json +1 -1
  3. package/plugin/skills/artifacts/SKILL.md +151 -0
  4. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  5. package/plugin/skills/pages/SKILL.md +274 -0
  6. package/src/artifact-sdk/browser-sdk.ts +105 -20
  7. package/src/be/db.ts +239 -0
  8. package/src/be/migrations/059_pages.sql +34 -0
  9. package/src/be/migrations/060_page_versions.sql +19 -0
  10. package/src/commands/artifact.ts +17 -11
  11. package/src/http/index.ts +7 -1
  12. package/src/http/page-proxy.ts +208 -0
  13. package/src/http/pages-public.ts +466 -0
  14. package/src/http/pages.ts +608 -0
  15. package/src/http/utils.ts +68 -5
  16. package/src/pages/version.ts +44 -0
  17. package/src/prompts/session-templates.ts +51 -0
  18. package/src/server.ts +10 -1
  19. package/src/tests/artifact-commands.test.ts +92 -0
  20. package/src/tests/artifact-sdk.test.ts +80 -74
  21. package/src/tests/create-page-tool.test.ts +197 -0
  22. package/src/tests/fixtures/sample-json-page.json +52 -0
  23. package/src/tests/launch-password-rejection.test.ts +139 -0
  24. package/src/tests/page-proxy-authed.test.ts +146 -0
  25. package/src/tests/page-proxy.test.ts +266 -0
  26. package/src/tests/page-session.test.ts +164 -0
  27. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  28. package/src/tests/pages-authed-mode.test.ts +207 -0
  29. package/src/tests/pages-http.test.ts +193 -0
  30. package/src/tests/pages-list-endpoint.test.ts +149 -0
  31. package/src/tests/pages-password-hash.test.ts +57 -0
  32. package/src/tests/pages-password-mode.test.ts +265 -0
  33. package/src/tests/pages-public-authed-401.test.ts +102 -0
  34. package/src/tests/pages-public-html.test.ts +151 -0
  35. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  36. package/src/tests/pages-storage.test.ts +196 -0
  37. package/src/tests/pages-versioning.test.ts +231 -0
  38. package/src/tests/prompt-template-session.test.ts +3 -2
  39. package/src/tests/skill-update-scope.test.ts +165 -0
  40. package/src/tests/workflow-wait-event.test.ts +4 -7
  41. package/src/tools/create-page.ts +263 -0
  42. package/src/tools/skills/skill-update.ts +26 -0
  43. package/src/tools/tool-config.ts +3 -0
  44. package/src/types.ts +54 -0
  45. package/src/utils/page-session.ts +254 -0
  46. package/plugin/skills/artifacts/skill.md +0 -70
@@ -0,0 +1,608 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ createPage,
5
+ deletePage,
6
+ getPage,
7
+ getPageVersion,
8
+ getPageVersions,
9
+ listAllPages,
10
+ listPagesByAgent,
11
+ updatePage,
12
+ } from "../be/db";
13
+ import { snapshotPage } from "../pages/version";
14
+ import { PageAuthModeSchema, PageContentTypeSchema } from "../types";
15
+ import { issuePageSessionCookie } from "../utils/page-session";
16
+ import { route } from "./route-def";
17
+ import { BODY_TOO_LARGE, enforceContentLengthCap, json, jsonError } from "./utils";
18
+
19
+ /**
20
+ * Per-page body-size cap. Page bodies are stored as a TEXT column with no
21
+ * per-instance quota, so we bound individual writes here. 5 MiB comfortably
22
+ * holds a JSON-render spec or static HTML report; anything larger is almost
23
+ * certainly an agent runaway. Bumping requires careful thought about the
24
+ * SQLite write-amplification (full body is snapshotted into page_versions on
25
+ * every update).
26
+ */
27
+ const MAX_PAGE_BODY_BYTES = 5 * 1024 * 1024;
28
+
29
+ // ─── Helpers ────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Lightweight kebab-case slug generator. Lowercases, replaces any run of
33
+ * non-alphanumeric chars with a single hyphen, trims hyphens, falls back to
34
+ * "page" if the result is empty (e.g. a title of "!!!").
35
+ */
36
+ function slugify(input: string): string {
37
+ const slug = input
38
+ .toLowerCase()
39
+ .normalize("NFKD")
40
+ .replace(/[^a-z0-9]+/g, "-")
41
+ .replace(/^-+|-+$/g, "");
42
+ return slug || "page";
43
+ }
44
+
45
+ // ─── Route Definitions ──────────────────────────────────────────────────────
46
+
47
+ const createPageRoute = route({
48
+ method: "post",
49
+ path: "/api/pages",
50
+ pattern: ["api", "pages"],
51
+ summary: "Create a new page",
52
+ tags: ["Pages"],
53
+ body: z.object({
54
+ slug: z.string().min(1).optional(),
55
+ title: z.string().min(1),
56
+ description: z.string().optional(),
57
+ contentType: PageContentTypeSchema,
58
+ authMode: PageAuthModeSchema,
59
+ password: z.string().min(1).optional(),
60
+ body: z.string(),
61
+ needsCredentials: z.array(z.string()).optional(),
62
+ }),
63
+ responses: {
64
+ 201: { description: "Page created" },
65
+ 400: { description: "Invalid body" },
66
+ 409: { description: "Slug already exists for this agent" },
67
+ },
68
+ });
69
+
70
+ const getPageRoute = route({
71
+ method: "get",
72
+ path: "/api/pages/{id}",
73
+ pattern: ["api", "pages", null],
74
+ summary: "Get a page by ID",
75
+ tags: ["Pages"],
76
+ params: z.object({ id: z.string() }),
77
+ responses: {
78
+ 200: { description: "Page row" },
79
+ 404: { description: "Page not found" },
80
+ },
81
+ });
82
+
83
+ /**
84
+ * Issue a page-session cookie for a given page id. Bearer-authed.
85
+ *
86
+ * Per auth_mode:
87
+ * - `public`: cookie issued (uniform path — even public pages can be loaded
88
+ * with cookie context if desired).
89
+ * - `authed`: cookie issued (normal flow).
90
+ * - `password`: rejected with 400 — password pages must be unlocked via
91
+ * `?key=` query / HTTP Basic on `/p/:id` directly (step-5). Bearer-side
92
+ * issuance would bypass the password check entirely.
93
+ *
94
+ * Response: 204 No Content + `Set-Cookie: page_session=<signed>; HttpOnly; ...`.
95
+ */
96
+ const launchPageRoute = route({
97
+ method: "post",
98
+ path: "/api/pages/{id}/launch",
99
+ pattern: ["api", "pages", null, "launch"],
100
+ summary: "Launch a page session (issues HttpOnly cookie)",
101
+ tags: ["Pages"],
102
+ params: z.object({ id: z.string() }),
103
+ responses: {
104
+ 204: { description: "Cookie issued" },
105
+ 400: { description: "Launch not supported for this page (e.g. password mode)" },
106
+ 404: { description: "Page not found" },
107
+ },
108
+ });
109
+
110
+ /**
111
+ * PUT /api/pages/:id — update an existing page. Body is the same shape as
112
+ * POST minus `slug` (slug is immutable post-create to keep the URL stable);
113
+ * any subset of the other fields may be sent. Snapshot of the pre-update
114
+ * state is captured BEFORE applying the patch (mirrors snapshotWorkflow at
115
+ * src/http/workflows.ts:483).
116
+ */
117
+ const updatePageRoute = route({
118
+ method: "put",
119
+ path: "/api/pages/{id}",
120
+ pattern: ["api", "pages", null],
121
+ summary: "Update an existing page",
122
+ tags: ["Pages"],
123
+ params: z.object({ id: z.string() }),
124
+ body: z.object({
125
+ title: z.string().min(1).optional(),
126
+ description: z.string().nullable().optional(),
127
+ contentType: PageContentTypeSchema.optional(),
128
+ authMode: PageAuthModeSchema.optional(),
129
+ password: z.string().min(1).nullable().optional(),
130
+ body: z.string().optional(),
131
+ needsCredentials: z.array(z.string()).nullable().optional(),
132
+ }),
133
+ responses: {
134
+ 200: { description: "Page updated" },
135
+ 404: { description: "Page not found" },
136
+ 413: { description: "Payload too large" },
137
+ },
138
+ });
139
+
140
+ const deletePageRoute = route({
141
+ method: "delete",
142
+ path: "/api/pages/{id}",
143
+ pattern: ["api", "pages", null],
144
+ summary: "Delete a page (and all version history)",
145
+ tags: ["Pages"],
146
+ params: z.object({ id: z.string() }),
147
+ responses: {
148
+ 204: { description: "Page deleted" },
149
+ 404: { description: "Page not found" },
150
+ },
151
+ });
152
+
153
+ const listPagesRoute = route({
154
+ method: "get",
155
+ path: "/api/pages",
156
+ pattern: ["api", "pages"],
157
+ summary: "List pages",
158
+ tags: ["Pages"],
159
+ query: z.object({
160
+ agentId: z.string().min(1).optional(),
161
+ limit: z.coerce.number().int().min(1).max(500).optional(),
162
+ offset: z.coerce.number().int().min(0).optional(),
163
+ }),
164
+ responses: {
165
+ 200: { description: "Page list with totals + share-URL pointers" },
166
+ },
167
+ });
168
+
169
+ /**
170
+ * GET /api/pages/actions — discovery endpoint for the JSON-page action
171
+ * allowlist (step-7 of the db-backed-pages plan). Returns the full set of
172
+ * action types that a JSON page can declare, plus a JSON-Schema rendering of
173
+ * each action's params (derived from the same Zod schemas the SPA uses, so
174
+ * the contract is single-source-of-truth).
175
+ *
176
+ * Used by tools that generate pages programmatically (agents, fixtures,
177
+ * future MCP tooling) to introspect what's supported without scraping the
178
+ * skill markdown.
179
+ */
180
+ const listPageActionsRoute = route({
181
+ method: "get",
182
+ path: "/api/pages/actions",
183
+ pattern: ["api", "pages", "actions"],
184
+ summary: "List JSON-page action allowlist (with param JSON Schemas)",
185
+ tags: ["Pages"],
186
+ responses: {
187
+ 200: { description: "Action allowlist" },
188
+ },
189
+ });
190
+
191
+ /**
192
+ * Action-param schemas duplicated from `ui/src/pages/pages/[id]/json-page-renderer.tsx`.
193
+ * Kept here (not imported from `ui/`) because the API server must not depend on
194
+ * the SPA build. If you change one side, update the other — there's an
195
+ * end-to-end test in step-7's qa-use scenario that exercises both action paths,
196
+ * so drift surfaces fast in practice.
197
+ */
198
+ const SDK_METHODS = [
199
+ "createTask",
200
+ "getTasks",
201
+ "getTaskDetails",
202
+ "storeProgress",
203
+ "postMessage",
204
+ "readMessages",
205
+ "getSwarm",
206
+ "listServices",
207
+ "slackReply",
208
+ ] as const;
209
+
210
+ const swarmSdkActionParamsSchema = z.object({
211
+ sdk: z.enum(SDK_METHODS),
212
+ args: z.record(z.string(), z.unknown()).optional(),
213
+ });
214
+
215
+ const swarmCallActionParamsSchema = z.object({
216
+ method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]),
217
+ endpoint: z.string(),
218
+ body: z.record(z.string(), z.unknown()).optional(),
219
+ });
220
+
221
+ const listPageVersionsRoute = route({
222
+ method: "get",
223
+ path: "/api/pages/{id}/versions",
224
+ pattern: ["api", "pages", null, "versions"],
225
+ summary: "List version snapshots for a page",
226
+ tags: ["Pages"],
227
+ params: z.object({ id: z.string() }),
228
+ responses: {
229
+ 200: { description: "Version list (newest first)" },
230
+ 404: { description: "Page not found" },
231
+ },
232
+ });
233
+
234
+ const getPageVersionRoute = route({
235
+ method: "get",
236
+ path: "/api/pages/{id}/versions/{version}",
237
+ pattern: ["api", "pages", null, "versions", null],
238
+ summary: "Get a single page-version snapshot",
239
+ tags: ["Pages"],
240
+ params: z.object({ id: z.string(), version: z.coerce.number().int().min(1) }),
241
+ responses: {
242
+ 200: { description: "Version snapshot" },
243
+ 404: { description: "Page or version not found" },
244
+ },
245
+ });
246
+
247
+ /**
248
+ * Cookie issuance moved to `src/utils/page-session.ts::issuePageSessionCookie`
249
+ * so the password-flow on `/p/:id` (step-5) can mint cookies via the same
250
+ * helper. `dev=true` softens the cookie for `http://localhost` (no Secure
251
+ * required; SameSite=Lax). Detected by `isDevRequest()` below.
252
+ */
253
+
254
+ /**
255
+ * Apply CORS headers needed for the cross-origin launch call. The SPA on
256
+ * `localhost:5274` calls `localhost:3013` with `credentials: 'include'`,
257
+ * which requires:
258
+ * - `Access-Control-Allow-Origin: <exact origin>` (NOT `*`)
259
+ * - `Access-Control-Allow-Credentials: true`
260
+ *
261
+ * Production paths typically use a shared parent domain (cookie scoped via
262
+ * `Domain=`), but for local dev we have to be explicit.
263
+ */
264
+ function applyLaunchCors(req: IncomingMessage, res: ServerResponse): void {
265
+ const origin = (req.headers.origin as string | undefined) ?? "";
266
+ if (origin) {
267
+ res.setHeader("Access-Control-Allow-Origin", origin);
268
+ res.setHeader("Vary", "Origin");
269
+ res.setHeader("Access-Control-Allow-Credentials", "true");
270
+ }
271
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
272
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
273
+ }
274
+
275
+ /**
276
+ * Resolve the public API base URL used to build a page's `api_url` share
277
+ * pointer. Falls back to `http://localhost:<PORT>` when `MCP_BASE_URL` is
278
+ * unset (same convention as src/tools/memory-rate.ts, etc.). Trailing slashes
279
+ * are stripped so callers can concatenate `/p/:id` directly.
280
+ */
281
+ function getApiBaseUrl(): string {
282
+ const env = process.env.MCP_BASE_URL?.trim();
283
+ if (env) return env.replace(/\/+$/, "");
284
+ return `http://localhost:${process.env.PORT || "3013"}`;
285
+ }
286
+
287
+ /**
288
+ * Resolve the SPA / dashboard base URL used to build a page's `app_url` share
289
+ * pointer (→ `/pages/:id`). `APP_URL` is the canonical env (matches the
290
+ * request-human-input tool); falls back to the local dev port `5274`.
291
+ */
292
+ function getAppBaseUrl(): string {
293
+ const env = process.env.APP_URL?.trim();
294
+ if (env) return env.replace(/\/+$/, "");
295
+ return "http://localhost:5274";
296
+ }
297
+
298
+ /** Decorate a page row with share-URL pointers. */
299
+ function withShareUrls<T extends { id: string }>(
300
+ page: T,
301
+ ): T & { app_url: string; api_url: string } {
302
+ return {
303
+ ...page,
304
+ api_url: `${getApiBaseUrl()}/p/${page.id}`,
305
+ app_url: `${getAppBaseUrl()}/pages/${page.id}`,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Compute the page's "edit counter" — `MAX(page_versions.version) + 1`. Means
311
+ * "this is the N-th edit since the page was created". After the first PUT the
312
+ * value is 2 (one snapshot row → version 1 → counter becomes 2). This is the
313
+ * value POST and PUT return as `version` on the wire.
314
+ */
315
+ function pageEditCounter(pageId: string): number {
316
+ const versions = getPageVersions(pageId);
317
+ return versions.length > 0 ? versions[0]!.version + 1 : 1;
318
+ }
319
+
320
+ function isDevRequest(req: IncomingMessage): boolean {
321
+ if (process.env.NODE_ENV === "production") return false;
322
+ // We want `SameSite=Lax` only when the request itself comes from the same
323
+ // local-`http://localhost` origin as the API — same-site loads tolerate
324
+ // Lax without Secure. Anything else (including portless `*.localhost`
325
+ // setups talking from HTTPS to the HTTP API) must use `SameSite=None;
326
+ // Secure` so the cookie travels on cross-site fetches; Chrome treats
327
+ // localhost as a secure origin so the Secure flag is fine on HTTP.
328
+ const origin = (req.headers.origin as string | undefined) ?? "";
329
+ return (
330
+ origin === "" || origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1")
331
+ );
332
+ }
333
+
334
+ // ─── Handler ────────────────────────────────────────────────────────────────
335
+
336
+ export async function handlePages(
337
+ req: IncomingMessage,
338
+ res: ServerResponse,
339
+ pathSegments: string[],
340
+ queryParams: URLSearchParams,
341
+ myAgentId: string | undefined,
342
+ ): Promise<boolean> {
343
+ if (createPageRoute.match(req.method, pathSegments)) {
344
+ // Body-size cap. Page bodies land in SQLite TEXT — cap large writes.
345
+ if (enforceContentLengthCap(req, res, MAX_PAGE_BODY_BYTES) === BODY_TOO_LARGE) return true;
346
+ const parsed = await createPageRoute.parse(req, res, pathSegments, queryParams);
347
+ if (!parsed) return true;
348
+
349
+ if (!myAgentId) {
350
+ jsonError(res, "X-Agent-ID header required", 400);
351
+ return true;
352
+ }
353
+
354
+ const slug = parsed.body.slug ?? slugify(parsed.body.title);
355
+
356
+ // Hash password if provided. Bun.password.hash is async (Argon2 by default;
357
+ // we explicitly select bcrypt to keep hashes short + portable).
358
+ let passwordHash: string | undefined;
359
+ if (parsed.body.password) {
360
+ passwordHash = await Bun.password.hash(parsed.body.password, "bcrypt");
361
+ }
362
+
363
+ try {
364
+ const page = createPage({
365
+ agentId: myAgentId,
366
+ slug,
367
+ title: parsed.body.title,
368
+ description: parsed.body.description,
369
+ contentType: parsed.body.contentType,
370
+ authMode: parsed.body.authMode,
371
+ passwordHash,
372
+ body: parsed.body.body,
373
+ needsCredentials: parsed.body.needsCredentials,
374
+ });
375
+ // First write has no prior snapshot — version 1 is implicit (the parent
376
+ // IS v1). Subsequent edits land via PUT and bump the counter.
377
+ json(
378
+ res,
379
+ {
380
+ id: page.id,
381
+ version: 1,
382
+ api_url: `${getApiBaseUrl()}/p/${page.id}`,
383
+ app_url: `${getAppBaseUrl()}/pages/${page.id}`,
384
+ },
385
+ 201,
386
+ );
387
+ } catch (err) {
388
+ const msg = err instanceof Error ? err.message : String(err);
389
+ if (msg.includes("UNIQUE")) {
390
+ jsonError(res, `Page with slug "${slug}" already exists for this agent`, 409);
391
+ return true;
392
+ }
393
+ throw err;
394
+ }
395
+ return true;
396
+ }
397
+
398
+ // GET /api/pages/actions — JSON-page action allowlist. MUST come BEFORE
399
+ // getPageRoute (which matches `["api", "pages", null]`) because otherwise
400
+ // the `null`-wildcard slot would capture "actions" as a page id.
401
+ if (listPageActionsRoute.match(req.method, pathSegments)) {
402
+ const sdkSchema = z.toJSONSchema(swarmSdkActionParamsSchema, { target: "draft-7" });
403
+ const callSchema = z.toJSONSchema(swarmCallActionParamsSchema, { target: "draft-7" });
404
+ json(res, {
405
+ actions: [
406
+ {
407
+ name: "swarm.sdk",
408
+ description: "Invoke a method on the in-SPA Swarm SDK with the viewer's bearer.",
409
+ params: sdkSchema,
410
+ sdkMethods: SDK_METHODS,
411
+ },
412
+ {
413
+ name: "swarm.call",
414
+ description: "Raw HTTP call to a swarm /api/* endpoint with the viewer's bearer.",
415
+ params: callSchema,
416
+ },
417
+ ],
418
+ });
419
+ return true;
420
+ }
421
+
422
+ // GET /api/pages — listing. MUST come BEFORE getPageRoute because both
423
+ // patterns start with `["api", "pages"]` and the list pattern is shorter.
424
+ // Optional `agentId` query filter narrows to a single owner — used by the
425
+ // SPA's "My pages only" toggle. Omitting it returns all pages visible to
426
+ // the caller (no per-row ACL in v1).
427
+ if (listPagesRoute.match(req.method, pathSegments)) {
428
+ const parsed = await listPagesRoute.parse(req, res, pathSegments, queryParams);
429
+ if (!parsed) return true;
430
+ const limit = parsed.query.limit ?? 50;
431
+ const offset = parsed.query.offset ?? 0;
432
+ const pages = parsed.query.agentId
433
+ ? listPagesByAgent(parsed.query.agentId, limit, offset)
434
+ : listAllPages(limit, offset);
435
+ json(res, {
436
+ pages: pages.map(withShareUrls),
437
+ total: pages.length,
438
+ });
439
+ return true;
440
+ }
441
+
442
+ // GET /api/pages/:id/versions/:version — single-version snapshot. Match
443
+ // BEFORE the listVersions / getPage routes because it has the deepest path.
444
+ if (getPageVersionRoute.match(req.method, pathSegments)) {
445
+ const parsed = await getPageVersionRoute.parse(req, res, pathSegments, queryParams);
446
+ if (!parsed) return true;
447
+ const page = getPage(parsed.params.id);
448
+ if (!page) {
449
+ res.writeHead(404);
450
+ res.end();
451
+ return true;
452
+ }
453
+ const version = getPageVersion(parsed.params.id, parsed.params.version);
454
+ if (!version) {
455
+ res.writeHead(404);
456
+ res.end();
457
+ return true;
458
+ }
459
+ json(res, version);
460
+ return true;
461
+ }
462
+
463
+ // GET /api/pages/:id/versions — full version history (newest first).
464
+ if (listPageVersionsRoute.match(req.method, pathSegments)) {
465
+ const parsed = await listPageVersionsRoute.parse(req, res, pathSegments, queryParams);
466
+ if (!parsed) return true;
467
+ const page = getPage(parsed.params.id);
468
+ if (!page) {
469
+ res.writeHead(404);
470
+ res.end();
471
+ return true;
472
+ }
473
+ const versions = getPageVersions(parsed.params.id);
474
+ json(res, { versions });
475
+ return true;
476
+ }
477
+
478
+ if (getPageRoute.match(req.method, pathSegments)) {
479
+ const parsed = await getPageRoute.parse(req, res, pathSegments, queryParams);
480
+ if (!parsed) return true;
481
+ const page = getPage(parsed.params.id);
482
+ if (!page) {
483
+ res.writeHead(404);
484
+ res.end();
485
+ return true;
486
+ }
487
+ json(res, withShareUrls(page));
488
+ return true;
489
+ }
490
+
491
+ // PUT /api/pages/:id — update an existing page. Snapshot BEFORE update.
492
+ if (updatePageRoute.match(req.method, pathSegments)) {
493
+ if (enforceContentLengthCap(req, res, MAX_PAGE_BODY_BYTES) === BODY_TOO_LARGE) return true;
494
+ const parsed = await updatePageRoute.parse(req, res, pathSegments, queryParams);
495
+ if (!parsed) return true;
496
+
497
+ const existing = getPage(parsed.params.id);
498
+ if (!existing) {
499
+ res.writeHead(404);
500
+ res.end();
501
+ return true;
502
+ }
503
+
504
+ // Hash password if a new one was provided. `null` → clear the hash.
505
+ let passwordHashUpdate: string | null | undefined;
506
+ if (parsed.body.password === null) {
507
+ passwordHashUpdate = null;
508
+ } else if (parsed.body.password !== undefined) {
509
+ passwordHashUpdate = await Bun.password.hash(parsed.body.password, "bcrypt");
510
+ }
511
+
512
+ // Snapshot first — failure must NOT block the update (mirrors workflows.ts).
513
+ try {
514
+ snapshotPage(parsed.params.id, myAgentId);
515
+ } catch {
516
+ // intentional empty
517
+ }
518
+
519
+ const updated = updatePage(parsed.params.id, {
520
+ title: parsed.body.title,
521
+ description: parsed.body.description ?? undefined,
522
+ contentType: parsed.body.contentType,
523
+ authMode: parsed.body.authMode,
524
+ passwordHash: passwordHashUpdate,
525
+ body: parsed.body.body,
526
+ needsCredentials: parsed.body.needsCredentials ?? undefined,
527
+ });
528
+ if (!updated) {
529
+ res.writeHead(404);
530
+ res.end();
531
+ return true;
532
+ }
533
+ json(res, { id: updated.id, version: pageEditCounter(updated.id) });
534
+ return true;
535
+ }
536
+
537
+ // DELETE /api/pages/:id — page_versions cascade via FK ON DELETE CASCADE.
538
+ if (deletePageRoute.match(req.method, pathSegments)) {
539
+ const parsed = await deletePageRoute.parse(req, res, pathSegments, queryParams);
540
+ if (!parsed) return true;
541
+ const ok = deletePage(parsed.params.id);
542
+ if (!ok) {
543
+ res.writeHead(404);
544
+ res.end();
545
+ return true;
546
+ }
547
+ res.writeHead(204);
548
+ res.end();
549
+ return true;
550
+ }
551
+
552
+ // CORS preflight for the launch endpoint. The SPA on localhost:5274 sends
553
+ // an OPTIONS preflight before the credentialed POST. Match the same path
554
+ // pattern (`api/pages/<id>/launch`) so we only respond for this one route.
555
+ if (
556
+ req.method === "OPTIONS" &&
557
+ pathSegments.length === 4 &&
558
+ pathSegments[0] === "api" &&
559
+ pathSegments[1] === "pages" &&
560
+ pathSegments[3] === "launch"
561
+ ) {
562
+ applyLaunchCors(req, res);
563
+ res.writeHead(204);
564
+ res.end();
565
+ return true;
566
+ }
567
+
568
+ if (launchPageRoute.match(req.method, pathSegments)) {
569
+ const parsed = await launchPageRoute.parse(req, res, pathSegments, queryParams);
570
+ if (!parsed) return true;
571
+
572
+ const page = getPage(parsed.params.id);
573
+ if (!page) {
574
+ applyLaunchCors(req, res);
575
+ res.writeHead(404);
576
+ res.end();
577
+ return true;
578
+ }
579
+
580
+ // Password mode bypasses the bearer-launch path entirely. Otherwise a
581
+ // caller with API_KEY could mint a cookie for a password-protected page
582
+ // without ever knowing the password. step-5 issues the password-mode
583
+ // cookie from the public `/p/:id?key=...` route, where the password is
584
+ // actually verified.
585
+ if (page.authMode === "password") {
586
+ applyLaunchCors(req, res);
587
+ jsonError(res, "use ?key= or Basic auth on /p/:id directly", 400);
588
+ return true;
589
+ }
590
+
591
+ // public + authed both mint a cookie here. No per-page ACL in v1: the
592
+ // bearer is the API_KEY, same trust as the rest of the API.
593
+ const cookie = await issuePageSessionCookie(page.id, { dev: isDevRequest(req) });
594
+
595
+ applyLaunchCors(req, res);
596
+ res.setHeader("Set-Cookie", cookie);
597
+ res.writeHead(204);
598
+ res.end();
599
+ return true;
600
+ }
601
+
602
+ return false;
603
+ }
604
+
605
+ // `snapshotPage` is re-exported so step-3's PUT route handler can call it
606
+ // before invoking `updatePage`. Mirrors how src/http/workflows.ts re-uses
607
+ // `snapshotWorkflow` from `src/workflows/version.ts`.
608
+ export { snapshotPage };
package/src/http/utils.ts CHANGED
@@ -1,11 +1,37 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { getActiveTaskCount } from "../be/db";
3
3
 
4
- export function setCorsHeaders(res: ServerResponse) {
5
- res.setHeader("Access-Control-Allow-Origin", "*");
6
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
7
- res.setHeader("Access-Control-Allow-Headers", "*");
8
- res.setHeader("Access-Control-Expose-Headers", "*");
4
+ export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
5
+ // Echo the request Origin (rather than emitting `*`) so credentialed fetches
6
+ // — e.g. the SPA's `credentials: 'include'` calls to `/p/:id.json` and the
7
+ // page-session cookie endpoints — pass the browser's CORS check. A wildcard
8
+ // would force the browser to reject any credentialed cross-origin response.
9
+ const rawOrigin = req.headers.origin;
10
+ const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
11
+ if (origin) {
12
+ res.setHeader("Access-Control-Allow-Origin", origin);
13
+ res.setHeader("Vary", "Origin");
14
+ res.setHeader("Access-Control-Allow-Credentials", "true");
15
+ // When credentials are involved the spec disallows wildcards in
16
+ // Allow-Headers / Allow-Methods / Expose-Headers — they must be
17
+ // explicit. Echo whatever the preflight asked for (defensive default
18
+ // covers Authorization + the common app headers).
19
+ const reqHeaders = req.headers["access-control-request-headers"];
20
+ const askedHeaders = Array.isArray(reqHeaders) ? reqHeaders.join(", ") : reqHeaders;
21
+ res.setHeader(
22
+ "Access-Control-Allow-Headers",
23
+ askedHeaders ?? "Authorization, Content-Type, X-Agent-ID, X-Requested-With",
24
+ );
25
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
26
+ res.setHeader("Access-Control-Expose-Headers", "Content-Type, Content-Length, ETag, Location");
27
+ } else {
28
+ // No Origin (curl / direct browser nav) — wildcards are fine and avoid
29
+ // breaking non-browser callers.
30
+ res.setHeader("Access-Control-Allow-Origin", "*");
31
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
32
+ res.setHeader("Access-Control-Allow-Headers", "*");
33
+ res.setHeader("Access-Control-Expose-Headers", "*");
34
+ }
9
35
  }
10
36
 
11
37
  export function parseQueryParams(url: string): URLSearchParams {
@@ -45,6 +71,43 @@ export async function parseBody<T = unknown>(req: IncomingMessage): Promise<T> {
45
71
  return JSON.parse(Buffer.concat(chunks).toString()) as T;
46
72
  }
47
73
 
74
+ /**
75
+ * Sentinel returned by `enforceContentLengthCap` when the request exceeds the
76
+ * provided byte cap. The caller has already received a `413` response — it
77
+ * should stop processing the request immediately.
78
+ */
79
+ export const BODY_TOO_LARGE = Symbol("body-too-large");
80
+
81
+ /**
82
+ * Reject the request with `413 Payload Too Large` when its `Content-Length`
83
+ * header exceeds `maxBytes`. Returns `BODY_TOO_LARGE` after writing the
84
+ * response (caller short-circuits); otherwise returns `null` and processing
85
+ * continues.
86
+ *
87
+ * This is a cheap pre-flight; downstream `parseBody`/streamed parsers can be
88
+ * a second defence if a malicious client lies about Content-Length.
89
+ *
90
+ * Used by `/api/pages` POST/PUT to bound the per-row body size — page bodies
91
+ * land in SQLite as a TEXT column and there is no per-instance quota yet.
92
+ */
93
+ export function enforceContentLengthCap(
94
+ req: IncomingMessage,
95
+ res: ServerResponse,
96
+ maxBytes: number,
97
+ ): typeof BODY_TOO_LARGE | null {
98
+ const raw = req.headers["content-length"];
99
+ const val = Array.isArray(raw) ? raw[0] : raw;
100
+ if (!val) return null; // No header — best-effort; parseBody will still buffer.
101
+ const n = Number(val);
102
+ if (!Number.isFinite(n) || n < 0) return null;
103
+ if (n > maxBytes) {
104
+ res.writeHead(413, { "Content-Type": "application/json" });
105
+ res.end(JSON.stringify({ error: `Payload too large (max ${maxBytes} bytes)` }));
106
+ return BODY_TOO_LARGE;
107
+ }
108
+ return null;
109
+ }
110
+
48
111
  /** Send JSON response */
49
112
  export function json(res: ServerResponse, data: unknown, status = 200) {
50
113
  res.writeHead(status, { "Content-Type": "application/json" });