@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.
Files changed (48) 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/error-tracker.test.ts +30 -0
  23. package/src/tests/fixtures/sample-json-page.json +52 -0
  24. package/src/tests/launch-password-rejection.test.ts +139 -0
  25. package/src/tests/page-proxy-authed.test.ts +146 -0
  26. package/src/tests/page-proxy.test.ts +266 -0
  27. package/src/tests/page-session.test.ts +164 -0
  28. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  29. package/src/tests/pages-authed-mode.test.ts +207 -0
  30. package/src/tests/pages-http.test.ts +193 -0
  31. package/src/tests/pages-list-endpoint.test.ts +149 -0
  32. package/src/tests/pages-password-hash.test.ts +57 -0
  33. package/src/tests/pages-password-mode.test.ts +265 -0
  34. package/src/tests/pages-public-authed-401.test.ts +102 -0
  35. package/src/tests/pages-public-html.test.ts +151 -0
  36. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  37. package/src/tests/pages-storage.test.ts +196 -0
  38. package/src/tests/pages-versioning.test.ts +231 -0
  39. package/src/tests/prompt-template-session.test.ts +3 -2
  40. package/src/tests/skill-update-scope.test.ts +165 -0
  41. package/src/tests/workflow-wait-event.test.ts +4 -7
  42. package/src/tools/create-page.ts +263 -0
  43. package/src/tools/skills/skill-update.ts +26 -0
  44. package/src/tools/tool-config.ts +3 -0
  45. package/src/types.ts +54 -0
  46. package/src/utils/error-tracker.ts +55 -1
  47. package/src/utils/page-session.ts +254 -0
  48. package/plugin/skills/artifacts/skill.md +0 -70
package/src/be/db.ts CHANGED
@@ -38,6 +38,11 @@ import type {
38
38
  McpServerScope,
39
39
  McpServerTransport,
40
40
  McpServerWithInstallInfo,
41
+ Page,
42
+ PageAuthMode,
43
+ PageContentType,
44
+ PageSnapshot,
45
+ PageVersion,
41
46
  PricingProvider,
42
47
  PricingRow,
43
48
  PricingTokenClass,
@@ -6237,6 +6242,240 @@ export function getWorkflowVersion(workflowId: string, version: number): Workflo
6237
6242
  return row ? rowToWorkflowVersion(row) : null;
6238
6243
  }
6239
6244
 
6245
+ // ============================================================================
6246
+ // Pages CRUD + version history
6247
+ // ----------------------------------------------------------------------------
6248
+ // DB-backed lightweight artifacts. Mirrors the workflow versioning pattern:
6249
+ // parent table `pages` holds the CURRENT state, history table `page_versions`
6250
+ // holds pre-update snapshots. snapshotPage() (src/pages/version.ts) MUST be
6251
+ // called BEFORE updatePage() so the snapshot freezes pre-update content.
6252
+ // ============================================================================
6253
+
6254
+ type PageRow = {
6255
+ id: string;
6256
+ agentId: string;
6257
+ slug: string;
6258
+ title: string;
6259
+ description: string | null;
6260
+ contentType: string;
6261
+ authMode: string;
6262
+ passwordHash: string | null;
6263
+ body: string;
6264
+ needsCredentials: string | null;
6265
+ createdAt: string;
6266
+ updatedAt: string;
6267
+ };
6268
+
6269
+ function rowToPage(row: PageRow): Page {
6270
+ return {
6271
+ id: row.id,
6272
+ agentId: row.agentId,
6273
+ slug: row.slug,
6274
+ title: row.title,
6275
+ description: row.description ?? undefined,
6276
+ contentType: row.contentType as PageContentType,
6277
+ authMode: row.authMode as PageAuthMode,
6278
+ passwordHash: row.passwordHash ?? undefined,
6279
+ body: row.body,
6280
+ needsCredentials: row.needsCredentials
6281
+ ? (JSON.parse(row.needsCredentials) as string[])
6282
+ : undefined,
6283
+ createdAt: normalizeDateRequired(row.createdAt),
6284
+ updatedAt: normalizeDateRequired(row.updatedAt),
6285
+ };
6286
+ }
6287
+
6288
+ export function createPage(data: {
6289
+ agentId: string;
6290
+ slug: string;
6291
+ title: string;
6292
+ description?: string;
6293
+ contentType: PageContentType;
6294
+ authMode: PageAuthMode;
6295
+ passwordHash?: string;
6296
+ body: string;
6297
+ needsCredentials?: string[];
6298
+ }): Page {
6299
+ const row = getDb()
6300
+ .prepare<
6301
+ PageRow,
6302
+ [string, string, string, string | null, string, string, string | null, string, string | null]
6303
+ >(
6304
+ `INSERT INTO pages (agentId, slug, title, description, contentType, authMode, passwordHash, body, needsCredentials)
6305
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
6306
+ )
6307
+ .get(
6308
+ data.agentId,
6309
+ data.slug,
6310
+ data.title,
6311
+ data.description ?? null,
6312
+ data.contentType,
6313
+ data.authMode,
6314
+ data.passwordHash ?? null,
6315
+ data.body,
6316
+ data.needsCredentials ? JSON.stringify(data.needsCredentials) : null,
6317
+ );
6318
+ if (!row) throw new Error("Failed to create page");
6319
+ return rowToPage(row);
6320
+ }
6321
+
6322
+ export function getPage(id: string): Page | null {
6323
+ const row = getDb().prepare<PageRow, [string]>("SELECT * FROM pages WHERE id = ?").get(id);
6324
+ return row ? rowToPage(row) : null;
6325
+ }
6326
+
6327
+ export function getPageBySlug(agentId: string, slug: string): Page | null {
6328
+ const row = getDb()
6329
+ .prepare<PageRow, [string, string]>("SELECT * FROM pages WHERE agentId = ? AND slug = ?")
6330
+ .get(agentId, slug);
6331
+ return row ? rowToPage(row) : null;
6332
+ }
6333
+
6334
+ export function listPagesByAgent(agentId: string, limit = 100, offset = 0): Page[] {
6335
+ return getDb()
6336
+ .prepare<PageRow, [string, number, number]>(
6337
+ "SELECT * FROM pages WHERE agentId = ? ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6338
+ )
6339
+ .all(agentId, limit, offset)
6340
+ .map(rowToPage);
6341
+ }
6342
+
6343
+ export function listAllPages(limit = 100, offset = 0): Page[] {
6344
+ return getDb()
6345
+ .prepare<PageRow, [number, number]>(
6346
+ "SELECT * FROM pages ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6347
+ )
6348
+ .all(limit, offset)
6349
+ .map(rowToPage);
6350
+ }
6351
+
6352
+ /**
6353
+ * Apply a patch to a page. Does NOT snapshot — caller must invoke
6354
+ * `snapshotPage(id, agentId)` BEFORE calling this to preserve pre-update
6355
+ * state (mirrors the workflow update pattern at src/http/workflows.ts:483).
6356
+ *
6357
+ * Always bumps `updatedAt` even if no other field changed (keeps the index
6358
+ * useful for list ordering).
6359
+ */
6360
+ export function updatePage(
6361
+ id: string,
6362
+ data: {
6363
+ title?: string;
6364
+ description?: string | null;
6365
+ contentType?: PageContentType;
6366
+ authMode?: PageAuthMode;
6367
+ passwordHash?: string | null;
6368
+ body?: string;
6369
+ needsCredentials?: string[] | null;
6370
+ slug?: string;
6371
+ },
6372
+ ): Page | null {
6373
+ const updates: string[] = [];
6374
+ const params: (string | number | null)[] = [];
6375
+ if (data.title !== undefined) {
6376
+ updates.push("title = ?");
6377
+ params.push(data.title);
6378
+ }
6379
+ if (data.description !== undefined) {
6380
+ updates.push("description = ?");
6381
+ params.push(data.description ?? null);
6382
+ }
6383
+ if (data.contentType !== undefined) {
6384
+ updates.push("contentType = ?");
6385
+ params.push(data.contentType);
6386
+ }
6387
+ if (data.authMode !== undefined) {
6388
+ updates.push("authMode = ?");
6389
+ params.push(data.authMode);
6390
+ }
6391
+ if (data.passwordHash !== undefined) {
6392
+ updates.push("passwordHash = ?");
6393
+ params.push(data.passwordHash ?? null);
6394
+ }
6395
+ if (data.body !== undefined) {
6396
+ updates.push("body = ?");
6397
+ params.push(data.body);
6398
+ }
6399
+ if (data.needsCredentials !== undefined) {
6400
+ updates.push("needsCredentials = ?");
6401
+ params.push(data.needsCredentials ? JSON.stringify(data.needsCredentials) : null);
6402
+ }
6403
+ if (data.slug !== undefined) {
6404
+ updates.push("slug = ?");
6405
+ params.push(data.slug);
6406
+ }
6407
+ if (updates.length === 0) return getPage(id);
6408
+ updates.push("updatedAt = ?");
6409
+ params.push(new Date().toISOString());
6410
+ params.push(id);
6411
+ const row = getDb()
6412
+ .prepare<PageRow, (string | number | null)[]>(
6413
+ `UPDATE pages SET ${updates.join(", ")} WHERE id = ? RETURNING *`,
6414
+ )
6415
+ .get(...params);
6416
+ return row ? rowToPage(row) : null;
6417
+ }
6418
+
6419
+ export function deletePage(id: string): boolean {
6420
+ // ON DELETE CASCADE on page_versions.pageId handles history cleanup.
6421
+ const result = getDb().run("DELETE FROM pages WHERE id = ?", [id]);
6422
+ return result.changes > 0;
6423
+ }
6424
+
6425
+ type PageVersionRow = {
6426
+ id: string;
6427
+ pageId: string;
6428
+ version: number;
6429
+ snapshot: string;
6430
+ changedByAgentId: string | null;
6431
+ createdAt: string;
6432
+ };
6433
+
6434
+ function rowToPageVersion(row: PageVersionRow): PageVersion {
6435
+ return {
6436
+ id: row.id,
6437
+ pageId: row.pageId,
6438
+ version: row.version,
6439
+ snapshot: JSON.parse(row.snapshot) as PageSnapshot,
6440
+ changedByAgentId: row.changedByAgentId ?? undefined,
6441
+ createdAt: normalizeDateRequired(row.createdAt),
6442
+ };
6443
+ }
6444
+
6445
+ export function createPageVersion(data: {
6446
+ pageId: string;
6447
+ version: number;
6448
+ snapshot: PageSnapshot;
6449
+ changedByAgentId?: string;
6450
+ }): PageVersion {
6451
+ const row = getDb()
6452
+ .prepare<PageVersionRow, [string, number, string, string | null]>(
6453
+ `INSERT INTO page_versions (pageId, version, snapshot, changedByAgentId)
6454
+ VALUES (?, ?, ?, ?) RETURNING *`,
6455
+ )
6456
+ .get(data.pageId, data.version, JSON.stringify(data.snapshot), data.changedByAgentId ?? null);
6457
+ if (!row) throw new Error("Failed to create page version");
6458
+ return rowToPageVersion(row);
6459
+ }
6460
+
6461
+ export function getPageVersions(pageId: string): PageVersion[] {
6462
+ return getDb()
6463
+ .prepare<PageVersionRow, [string]>(
6464
+ "SELECT * FROM page_versions WHERE pageId = ? ORDER BY version DESC",
6465
+ )
6466
+ .all(pageId)
6467
+ .map(rowToPageVersion);
6468
+ }
6469
+
6470
+ export function getPageVersion(pageId: string, version: number): PageVersion | null {
6471
+ const row = getDb()
6472
+ .prepare<PageVersionRow, [string, number]>(
6473
+ "SELECT * FROM page_versions WHERE pageId = ? AND version = ?",
6474
+ )
6475
+ .get(pageId, version);
6476
+ return row ? rowToPageVersion(row) : null;
6477
+ }
6478
+
6240
6479
  // ============================================================================
6241
6480
  // Prompt Template Operations
6242
6481
  // ============================================================================
@@ -0,0 +1,34 @@
1
+ -- DB-backed Pages (parent table) — lightweight artifacts stored in SQLite and
2
+ -- served as HTML or JSON. Replaces PM2+localtunnel for static cases.
3
+ --
4
+ -- `body` stores agent-emitted content verbatim. For contentType='text/html',
5
+ -- callers MAY pass either a fragment (`<h1>hi</h1>`) or a full document
6
+ -- (`<!doctype html>...<html>...`). Step-3's `/p/:id` serving logic detects
7
+ -- `<head>` and injects BROWSER_SDK_JS after it; if absent, it prepends.
8
+ -- For contentType='application/json', the body is a JSON-render-compatible
9
+ -- spec stored as a string (parsed at render time).
10
+ --
11
+ -- `needsCredentials` is reserved for follow-up credential capture work; the
12
+ -- v1 renderer ignores it (Zod accepts, no UI prompt).
13
+ --
14
+ -- `authMode` CHECK constraint MUST stay in sync with PageAuthModeSchema in
15
+ -- src/types.ts.
16
+
17
+ CREATE TABLE IF NOT EXISTS pages (
18
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
19
+ agentId TEXT NOT NULL,
20
+ slug TEXT NOT NULL,
21
+ title TEXT NOT NULL,
22
+ description TEXT,
23
+ contentType TEXT NOT NULL CHECK (contentType IN ('text/html','application/json')),
24
+ authMode TEXT NOT NULL DEFAULT 'public' CHECK (authMode IN ('public','authed','password')),
25
+ passwordHash TEXT,
26
+ body TEXT NOT NULL,
27
+ needsCredentials TEXT,
28
+ createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ updatedAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
30
+ UNIQUE (agentId, slug)
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_pages_agentId ON pages(agentId);
34
+ CREATE INDEX IF NOT EXISTS idx_pages_updatedAt ON pages(updatedAt DESC);
@@ -0,0 +1,19 @@
1
+ -- DB-backed Pages (history table). Mirrors workflow_versions
2
+ -- (008_workflow_redesign.sql:74-82) — parent table holds CURRENT state, this
3
+ -- table holds the pre-update snapshot taken by snapshotPage() before each
4
+ -- updatePage(). Head pointer is derived from MAX(version); no head_version
5
+ -- column on the parent.
6
+ --
7
+ -- ON DELETE CASCADE: deleting a page removes its version history.
8
+
9
+ CREATE TABLE IF NOT EXISTS page_versions (
10
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
11
+ pageId TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
12
+ version INTEGER NOT NULL,
13
+ snapshot TEXT NOT NULL, -- JSON: PageSnapshot
14
+ changedByAgentId TEXT,
15
+ createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
+ UNIQUE (pageId, version)
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS idx_page_versions_pageId ON page_versions(pageId);
@@ -154,12 +154,15 @@ async function artifactList() {
154
154
  process.exit(1);
155
155
  }
156
156
 
157
- const services = (await res.json()) as Array<{
158
- name: string;
159
- agentId: string;
160
- status: string;
161
- metadata?: { type?: string; artifactName?: string; port?: number; publicUrl?: string };
162
- }>;
157
+ const body = (await res.json()) as {
158
+ services?: Array<{
159
+ name: string;
160
+ agentId: string;
161
+ status: string;
162
+ metadata?: { type?: string; artifactName?: string; port?: number; publicUrl?: string };
163
+ }>;
164
+ };
165
+ const services = body.services ?? [];
163
166
 
164
167
  const artifacts = services.filter((s) => s.metadata?.type === "artifact");
165
168
 
@@ -214,11 +217,14 @@ async function artifactStop(args: ArtifactArgs) {
214
217
  });
215
218
 
216
219
  if (res.ok) {
217
- const services = (await res.json()) as Array<{
218
- id: string;
219
- name: string;
220
- metadata?: { type?: string; artifactName?: string };
221
- }>;
220
+ const body = (await res.json()) as {
221
+ services?: Array<{
222
+ id: string;
223
+ name: string;
224
+ metadata?: { type?: string; artifactName?: string };
225
+ }>;
226
+ };
227
+ const services = body.services ?? [];
222
228
  const service = services.find(
223
229
  (s) => s.metadata?.type === "artifact" && s.metadata?.artifactName === name,
224
230
  );
package/src/http/index.ts CHANGED
@@ -35,6 +35,9 @@ import { handleMcp } from "./mcp";
35
35
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
36
36
  import { handleMcpServers } from "./mcp-servers";
37
37
  import { handleMemory } from "./memory";
38
+ import { handlePageProxy } from "./page-proxy";
39
+ import { handlePages } from "./pages";
40
+ import { handlePagesPublic } from "./pages-public";
38
41
  import { handlePoll } from "./poll";
39
42
  import { handlePricing } from "./pricing";
40
43
  import { handlePromptTemplates } from "./prompt-templates";
@@ -109,7 +112,7 @@ const httpServer = createHttpServer(async (req, res) => {
109
112
  console.error(`[HTTP] ❌ ${req.method} ${req.url} → Error: ${err.message}`);
110
113
  });
111
114
 
112
- setCorsHeaders(res);
115
+ setCorsHeaders(req, res);
113
116
 
114
117
  // ── Core routes (OPTIONS, health, auth, /me, /cancelled-tasks, /ping, /close) ──
115
118
  if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, apiKey)) return;
@@ -147,6 +150,9 @@ const httpServer = createHttpServer(async (req, res) => {
147
150
  () => handleMcpServers(req, res, pathSegments, queryParams),
148
151
  () => handleMcpOAuth(req, res, pathSegments, queryParams),
149
152
  () => handleMemory(req, res, pathSegments, myAgentId),
153
+ () => handlePagesPublic(req, res, pathSegments, queryParams),
154
+ () => handlePageProxy(req, res),
155
+ () => handlePages(req, res, pathSegments, queryParams, myAgentId),
150
156
  () => handleApiKeys(req, res, pathSegments, queryParams),
151
157
  () => handleHeartbeat(req, res, pathSegments),
152
158
  () => handleEvents(req, res, pathSegments, queryParams, myAgentId),
@@ -0,0 +1,208 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { getPage } from "../be/db";
3
+ import { extractAndVerifyCookie } from "../utils/page-session";
4
+ import { route } from "./route-def";
5
+ import { jsonError } from "./utils";
6
+
7
+ /**
8
+ * `/@swarm/api/*` proxy. Cookie-gated equivalent of the artifact-sdk proxy
9
+ * (src/artifact-sdk/server.ts:42-69), but lives on the MAIN API server and
10
+ * authenticates via the page-session cookie instead of basic-auth + a per-
11
+ * artifact tunnel.
12
+ *
13
+ * Flow:
14
+ * 1. Browser hits `/@swarm/api/<rest>` from inside an iframe of `/p/:id`.
15
+ * 2. We parse the `page_session` cookie, verify HMAC + expiry.
16
+ * 3. Look up the page; map `agentId` → `X-Agent-ID`.
17
+ * 4. Re-issue the request to the same API server's `/api/<rest>` with
18
+ * `Authorization: Bearer ${API_KEY}` and `X-Agent-ID: ${page.agentId}`
19
+ * injected server-side. The bearer NEVER touches the browser.
20
+ *
21
+ * Cookie IS the auth — this route opts out of the global bearer gate via
22
+ * `route({ auth: { apiKey: false } })`. Unknown paths fail closed (the
23
+ * `isPublicRoute` check defaults to bearer-required), so we must declare the
24
+ * route here even though we don't use route().match() for the actual
25
+ * dispatch (we use a startsWith check below — segment-based matching gets
26
+ * unwieldy for an arbitrary suffix).
27
+ */
28
+
29
+ // Registered purely so the global bearer-gate (`isPublicRoute`) skips the
30
+ // API-key check for /@swarm/api/* requests. The handler below does its own
31
+ // cookie validation. The path pattern uses a single dynamic segment as a
32
+ // stand-in; the actual route accepts ANY suffix and is matched manually via
33
+ // `req.url.startsWith("/@swarm/api/")` for clarity.
34
+ route({
35
+ method: "get",
36
+ path: "/@swarm/api/{path}",
37
+ pattern: ["@swarm", "api", null],
38
+ exact: false,
39
+ summary: "Cookie-gated proxy to the swarm API (used by db-backed page iframes)",
40
+ tags: ["Pages"],
41
+ responses: {
42
+ 200: { description: "Proxied response from the underlying /api/* endpoint" },
43
+ 401: { description: "No or invalid page-session cookie" },
44
+ 404: { description: "Page referenced by the cookie no longer exists" },
45
+ },
46
+ auth: { apiKey: false },
47
+ });
48
+ route({
49
+ method: "post",
50
+ path: "/@swarm/api/{path}",
51
+ pattern: ["@swarm", "api", null],
52
+ exact: false,
53
+ summary: "Cookie-gated proxy to the swarm API (POST)",
54
+ tags: ["Pages"],
55
+ responses: {
56
+ 200: { description: "Proxied response" },
57
+ 401: { description: "No or invalid page-session cookie" },
58
+ },
59
+ auth: { apiKey: false },
60
+ });
61
+ route({
62
+ method: "put",
63
+ path: "/@swarm/api/{path}",
64
+ pattern: ["@swarm", "api", null],
65
+ exact: false,
66
+ summary: "Cookie-gated proxy to the swarm API (PUT)",
67
+ tags: ["Pages"],
68
+ responses: {
69
+ 200: { description: "Proxied response" },
70
+ 401: { description: "No or invalid page-session cookie" },
71
+ },
72
+ auth: { apiKey: false },
73
+ });
74
+ route({
75
+ method: "delete",
76
+ path: "/@swarm/api/{path}",
77
+ pattern: ["@swarm", "api", null],
78
+ exact: false,
79
+ summary: "Cookie-gated proxy to the swarm API (DELETE)",
80
+ tags: ["Pages"],
81
+ responses: {
82
+ 200: { description: "Proxied response" },
83
+ 401: { description: "No or invalid page-session cookie" },
84
+ },
85
+ auth: { apiKey: false },
86
+ });
87
+ route({
88
+ method: "patch",
89
+ path: "/@swarm/api/{path}",
90
+ pattern: ["@swarm", "api", null],
91
+ exact: false,
92
+ summary: "Cookie-gated proxy to the swarm API (PATCH)",
93
+ tags: ["Pages"],
94
+ responses: {
95
+ 200: { description: "Proxied response" },
96
+ 401: { description: "No or invalid page-session cookie" },
97
+ },
98
+ auth: { apiKey: false },
99
+ });
100
+
101
+ const PROXY_PREFIX = "/@swarm/api/";
102
+
103
+ /**
104
+ * Handle `/@swarm/api/*` requests. Returns `true` if the request was handled
105
+ * (response sent), `false` if not — caller continues to the next handler.
106
+ *
107
+ * Place BEFORE `handlePages` in the central `handlers` array — `/@swarm/api/*`
108
+ * does not overlap `/api/pages/*` segment-wise (different first segment), but
109
+ * we keep the ordering explicit.
110
+ */
111
+ export async function handlePageProxy(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
112
+ const url = req.url ?? "";
113
+ if (!url.startsWith(PROXY_PREFIX)) return false;
114
+
115
+ // Strip query string for cookie/path checks, but preserve it for the
116
+ // forwarded URL so callers like `/me?include=inbox` still work.
117
+ const queryIdx = url.indexOf("?");
118
+ const pathPart = queryIdx === -1 ? url : url.slice(0, queryIdx);
119
+ const queryPart = queryIdx === -1 ? "" : url.slice(queryIdx);
120
+
121
+ // ─── Cookie validation ────────────────────────────────────────────────────
122
+ const payload = await extractAndVerifyCookie(req);
123
+ if (!payload) {
124
+ jsonError(res, "no page session", 401);
125
+ return true;
126
+ }
127
+
128
+ const page = getPage(payload.pageId);
129
+ if (!page) {
130
+ // Cookie was issued before the page was deleted. Treat as a stale session
131
+ // rather than 404 so the client knows to refresh / re-launch.
132
+ jsonError(res, "page session no longer valid", 401);
133
+ return true;
134
+ }
135
+
136
+ // ─── Rewrite + forward ─────────────────────────────────────────────────────
137
+ // `/@swarm/api/me` → `/api/me`. Preserve query string.
138
+ //
139
+ // This is an IN-PROCESS proxy — we always dispatch to the same server we're
140
+ // running in. We deliberately do NOT use `deriveApiBaseUrl(req)`: that would
141
+ // honour `MCP_BASE_URL` (which may be an ngrok tunnel or other external host
142
+ // pointing back at us), forcing a network round-trip through the public
143
+ // surface and breaking when `localhost:<PORT>` is reachable but the public
144
+ // URL isn't (test envs, offline dev, restrictive networks).
145
+ const rewrittenPath = pathPart.replace(PROXY_PREFIX, "/api/");
146
+ const port = process.env.PORT || "3013";
147
+ const baseUrl = `http://127.0.0.1:${port}`;
148
+ const targetUrl = `${baseUrl}${rewrittenPath}${queryPart}`;
149
+
150
+ const apiKey = process.env.API_KEY ?? "";
151
+ const headers: Record<string, string> = {
152
+ Authorization: `Bearer ${apiKey}`,
153
+ "X-Agent-ID": page.agentId,
154
+ };
155
+
156
+ // Forward content-type / accept verbatim for non-GET so JSON bodies work.
157
+ const reqContentType = req.headers["content-type"];
158
+ if (reqContentType) {
159
+ headers["Content-Type"] = Array.isArray(reqContentType) ? reqContentType[0]! : reqContentType;
160
+ }
161
+ const reqAccept = req.headers.accept;
162
+ if (reqAccept) {
163
+ headers.Accept = Array.isArray(reqAccept) ? reqAccept[0]! : reqAccept;
164
+ }
165
+
166
+ // Pull the body if there is one. We DO buffer here — page traffic is
167
+ // expected to be small JSON payloads, and streaming would complicate cookie
168
+ // failure-mode handling.
169
+ const method = (req.method ?? "GET").toUpperCase();
170
+ let body: Buffer | undefined;
171
+ if (method !== "GET" && method !== "HEAD") {
172
+ const chunks: Buffer[] = [];
173
+ for await (const chunk of req) chunks.push(chunk as Buffer);
174
+ if (chunks.length > 0) body = Buffer.concat(chunks);
175
+ }
176
+
177
+ let upstream: Response;
178
+ try {
179
+ upstream = await fetch(targetUrl, {
180
+ method,
181
+ headers,
182
+ body,
183
+ // Prevent the runtime from following redirects — surface them to the
184
+ // caller as-is so the SDK sees the same response it would direct-fetching.
185
+ redirect: "manual",
186
+ });
187
+ } catch (err) {
188
+ // Don't echo the underlying error to the client (could leak target URL
189
+ // shape under some env-var typos). Log to server, send generic 502.
190
+ console.error("[page-proxy] upstream fetch failed:", err);
191
+ jsonError(res, "upstream error", 502);
192
+ return true;
193
+ }
194
+
195
+ // Pipe upstream response back. Preserve status + content-type.
196
+ const upstreamCt = upstream.headers.get("content-type");
197
+ if (upstreamCt) res.setHeader("Content-Type", upstreamCt);
198
+
199
+ // Copy a small allowlist of useful headers. Don't blanket-forward — upstream
200
+ // may include `Set-Cookie` we don't want to re-emit, etc.
201
+ const cacheControl = upstream.headers.get("cache-control");
202
+ if (cacheControl) res.setHeader("Cache-Control", cacheControl);
203
+
204
+ res.writeHead(upstream.status);
205
+ const buf = Buffer.from(await upstream.arrayBuffer());
206
+ res.end(buf);
207
+ return true;
208
+ }