@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
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);
|
package/src/commands/artifact.ts
CHANGED
|
@@ -154,12 +154,15 @@ async function artifactList() {
|
|
|
154
154
|
process.exit(1);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|