@desplega.ai/agent-swarm 1.78.0 → 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.
- package/openapi.json +542 -1
- package/package.json +1 -1
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/pages/SKILL.md +274 -0
- package/src/artifact-sdk/browser-sdk.ts +105 -20
- package/src/be/db.ts +239 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/commands/artifact.ts +17 -11
- package/src/http/index.ts +7 -1
- package/src/http/page-proxy.ts +208 -0
- package/src/http/pages-public.ts +466 -0
- package/src/http/pages.ts +608 -0
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/server.ts +10 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/error-tracker.test.ts +30 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/launch-password-rejection.test.ts +139 -0
- package/src/tests/page-proxy-authed.test.ts +146 -0
- package/src/tests/page-proxy.test.ts +266 -0
- package/src/tests/page-session.test.ts +164 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +207 -0
- package/src/tests/pages-http.test.ts +193 -0
- package/src/tests/pages-list-endpoint.test.ts +149 -0
- package/src/tests/pages-password-hash.test.ts +57 -0
- package/src/tests/pages-password-mode.test.ts +265 -0
- package/src/tests/pages-public-authed-401.test.ts +102 -0
- package/src/tests/pages-public-html.test.ts +151 -0
- package/src/tests/pages-public-json-redirect.test.ts +86 -0
- package/src/tests/pages-storage.test.ts +196 -0
- package/src/tests/pages-versioning.test.ts +231 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +3 -0
- package/src/types.ts +54 -0
- package/src/utils/error-tracker.ts +55 -1
- package/src/utils/page-session.ts +254 -0
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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" });
|