@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public `/p/:id` for HTML content. Verifies that:
|
|
3
|
+
* - the response body contains the original HTML verbatim
|
|
4
|
+
* - the `BROWSER_SDK_JS` is injected as an inline `<script>` (we look for
|
|
5
|
+
* `class SwarmSDK` which is unique to the constant)
|
|
6
|
+
* - the response `Content-Type` is `text/html`
|
|
7
|
+
* - a `Content-Security-Policy` header is set
|
|
8
|
+
*/
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { unlink } from "node:fs/promises";
|
|
12
|
+
import {
|
|
13
|
+
createServer as createHttpServer,
|
|
14
|
+
type IncomingMessage,
|
|
15
|
+
type Server,
|
|
16
|
+
type ServerResponse,
|
|
17
|
+
} from "node:http";
|
|
18
|
+
import { closeDb, initDb } from "../be/db";
|
|
19
|
+
import { handlePages } from "../http/pages";
|
|
20
|
+
import { handlePagesPublic } from "../http/pages-public";
|
|
21
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
22
|
+
|
|
23
|
+
const TEST_DB_PATH = "./test-pages-public-html.sqlite";
|
|
24
|
+
const TEST_PORT = 13042;
|
|
25
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
26
|
+
|
|
27
|
+
function createTestServer(): Server {
|
|
28
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
29
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
30
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
31
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
32
|
+
// Try public first, then bearer REST.
|
|
33
|
+
if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
|
|
34
|
+
if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
35
|
+
res.writeHead(404);
|
|
36
|
+
res.end("not found");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("GET /p/:id — HTML public path", () => {
|
|
41
|
+
let server: Server;
|
|
42
|
+
const agentId = crypto.randomUUID();
|
|
43
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
47
|
+
try {
|
|
48
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
initDb(TEST_DB_PATH);
|
|
52
|
+
server = createTestServer();
|
|
53
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(async () => {
|
|
57
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
58
|
+
closeDb();
|
|
59
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
60
|
+
try {
|
|
61
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("public HTML page renders with SDK injection + CSP", async () => {
|
|
67
|
+
const html =
|
|
68
|
+
"<!doctype html><html><head><title>X</title></head><body><h1>Hello</h1></body></html>";
|
|
69
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
slug: "public-html",
|
|
74
|
+
title: "Public HTML",
|
|
75
|
+
contentType: "text/html",
|
|
76
|
+
authMode: "public",
|
|
77
|
+
body: html,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
expect(post.status).toBe(201);
|
|
81
|
+
const { id } = (await post.json()) as { id: string };
|
|
82
|
+
|
|
83
|
+
const res = await fetch(`${BASE}/p/${id}`);
|
|
84
|
+
expect(res.status).toBe(200);
|
|
85
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("text/html");
|
|
86
|
+
expect(res.headers.get("content-security-policy")).toBeTruthy();
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
expect(text).toContain("<h1>Hello</h1>");
|
|
89
|
+
expect(text).toContain("class SwarmSDK"); // BROWSER_SDK_JS sentinel
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("public JSON page 302-redirects to SPA artifact route", async () => {
|
|
93
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers,
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
slug: "public-json",
|
|
98
|
+
title: "Public JSON",
|
|
99
|
+
contentType: "application/json",
|
|
100
|
+
authMode: "public",
|
|
101
|
+
body: JSON.stringify({ kind: "spec" }),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const { id } = (await post.json()) as { id: string };
|
|
105
|
+
|
|
106
|
+
const res = await fetch(`${BASE}/p/${id}`, { redirect: "manual" });
|
|
107
|
+
expect(res.status).toBe(302);
|
|
108
|
+
const loc = res.headers.get("location");
|
|
109
|
+
expect(loc).toContain(`/pages/${id}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("/p/:id.json returns page metadata + body as JSON", async () => {
|
|
113
|
+
const html = "<h1>jsonable</h1>";
|
|
114
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
slug: "public-json-renderer",
|
|
119
|
+
title: "JSON Endpoint",
|
|
120
|
+
description: "for the SPA renderer",
|
|
121
|
+
contentType: "text/html",
|
|
122
|
+
authMode: "public",
|
|
123
|
+
body: html,
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
const { id } = (await post.json()) as { id: string };
|
|
127
|
+
|
|
128
|
+
const res = await fetch(`${BASE}/p/${id}.json`);
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
131
|
+
const json = (await res.json()) as {
|
|
132
|
+
id: string;
|
|
133
|
+
title: string;
|
|
134
|
+
contentType: string;
|
|
135
|
+
authMode: string;
|
|
136
|
+
body: string;
|
|
137
|
+
description?: string;
|
|
138
|
+
};
|
|
139
|
+
expect(json.id).toBe(id);
|
|
140
|
+
expect(json.title).toBe("JSON Endpoint");
|
|
141
|
+
expect(json.description).toBe("for the SPA renderer");
|
|
142
|
+
expect(json.contentType).toBe("text/html");
|
|
143
|
+
expect(json.authMode).toBe("public");
|
|
144
|
+
expect(json.body).toBe(html);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("404 for unknown page id", async () => {
|
|
148
|
+
const res = await fetch(`${BASE}/p/${"0".repeat(32)}`);
|
|
149
|
+
expect(res.status).toBe(404);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public `/p/:id` for JSON content. JSON pages do NOT render at the API —
|
|
3
|
+
* the renderer lives in the SPA at `/pages/:id` (step-6/7). The API
|
|
4
|
+
* responds with a 302 to `${APP_URL}/pages/:id`.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
9
|
+
import {
|
|
10
|
+
createServer as createHttpServer,
|
|
11
|
+
type IncomingMessage,
|
|
12
|
+
type Server,
|
|
13
|
+
type ServerResponse,
|
|
14
|
+
} from "node:http";
|
|
15
|
+
import { closeDb, initDb } from "../be/db";
|
|
16
|
+
import { handlePages } from "../http/pages";
|
|
17
|
+
import { handlePagesPublic } from "../http/pages-public";
|
|
18
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
19
|
+
|
|
20
|
+
const TEST_DB_PATH = "./test-pages-public-json.sqlite";
|
|
21
|
+
const TEST_PORT = 13043;
|
|
22
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
23
|
+
|
|
24
|
+
function createTestServer(): Server {
|
|
25
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
26
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
27
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
28
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
29
|
+
if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
|
|
30
|
+
if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
31
|
+
res.writeHead(404);
|
|
32
|
+
res.end("not found");
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("GET /p/:id — JSON page redirect", () => {
|
|
37
|
+
let server: Server;
|
|
38
|
+
const agentId = crypto.randomUUID();
|
|
39
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
40
|
+
const ORIG_APP = process.env.APP_URL;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
44
|
+
try {
|
|
45
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
initDb(TEST_DB_PATH);
|
|
49
|
+
// Pin APP_URL so the redirect is deterministic across hosts.
|
|
50
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
51
|
+
server = createTestServer();
|
|
52
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
57
|
+
closeDb();
|
|
58
|
+
if (ORIG_APP === undefined) delete process.env.APP_URL;
|
|
59
|
+
else process.env.APP_URL = ORIG_APP;
|
|
60
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
61
|
+
try {
|
|
62
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("JSON content redirects to ${APP_URL}/pages/:id", async () => {
|
|
68
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
slug: "redir",
|
|
73
|
+
title: "Redirect Me",
|
|
74
|
+
contentType: "application/json",
|
|
75
|
+
authMode: "public",
|
|
76
|
+
body: JSON.stringify({ kind: "spec", nodes: [] }),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
expect(post.status).toBe(201);
|
|
80
|
+
const { id } = (await post.json()) as { id: string };
|
|
81
|
+
|
|
82
|
+
const res = await fetch(`${BASE}/p/${id}`, { redirect: "manual" });
|
|
83
|
+
expect(res.status).toBe(302);
|
|
84
|
+
expect(res.headers.get("location")).toBe(`http://localhost:5274/pages/${id}`);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { unlinkSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createPage,
|
|
7
|
+
deletePage,
|
|
8
|
+
getPage,
|
|
9
|
+
getPageBySlug,
|
|
10
|
+
getPageVersion,
|
|
11
|
+
getPageVersions,
|
|
12
|
+
initDb,
|
|
13
|
+
listAllPages,
|
|
14
|
+
listPagesByAgent,
|
|
15
|
+
updatePage,
|
|
16
|
+
} from "../be/db";
|
|
17
|
+
import { snapshotPage } from "../pages/version";
|
|
18
|
+
|
|
19
|
+
const TEST_DB_PATH = "./test-pages-storage.sqlite";
|
|
20
|
+
|
|
21
|
+
function makeAgentId() {
|
|
22
|
+
return `agent-${crypto.randomUUID().slice(0, 8)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(TEST_DB_PATH);
|
|
28
|
+
} catch {}
|
|
29
|
+
initDb(TEST_DB_PATH);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
closeDb();
|
|
34
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("pages storage CRUD", () => {
|
|
42
|
+
test("create → get → list → delete cascades to versions", () => {
|
|
43
|
+
const agentId = makeAgentId();
|
|
44
|
+
const created = createPage({
|
|
45
|
+
agentId,
|
|
46
|
+
slug: "hello",
|
|
47
|
+
title: "Hello",
|
|
48
|
+
contentType: "text/html",
|
|
49
|
+
authMode: "public",
|
|
50
|
+
body: "<h1>hi</h1>",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(created.id).toMatch(/^[0-9a-f]{32}$/); // 32-char random hex from migration default
|
|
54
|
+
expect(created.agentId).toBe(agentId);
|
|
55
|
+
expect(created.slug).toBe("hello");
|
|
56
|
+
expect(created.contentType).toBe("text/html");
|
|
57
|
+
expect(created.authMode).toBe("public");
|
|
58
|
+
|
|
59
|
+
const fetched = getPage(created.id);
|
|
60
|
+
expect(fetched).not.toBeNull();
|
|
61
|
+
expect(fetched?.title).toBe("Hello");
|
|
62
|
+
|
|
63
|
+
const bySlug = getPageBySlug(agentId, "hello");
|
|
64
|
+
expect(bySlug?.id).toBe(created.id);
|
|
65
|
+
|
|
66
|
+
const byAgent = listPagesByAgent(agentId);
|
|
67
|
+
expect(byAgent.map((p) => p.id)).toContain(created.id);
|
|
68
|
+
|
|
69
|
+
const all = listAllPages();
|
|
70
|
+
expect(all.map((p) => p.id)).toContain(created.id);
|
|
71
|
+
|
|
72
|
+
// Create a version so we can verify cascade
|
|
73
|
+
const snap = snapshotPage(created.id, agentId);
|
|
74
|
+
expect(snap.version).toBe(1);
|
|
75
|
+
expect(getPageVersions(created.id)).toHaveLength(1);
|
|
76
|
+
|
|
77
|
+
const deleted = deletePage(created.id);
|
|
78
|
+
expect(deleted).toBe(true);
|
|
79
|
+
expect(getPage(created.id)).toBeNull();
|
|
80
|
+
// Cascade: version rows gone
|
|
81
|
+
expect(getPageVersions(created.id)).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("snapshotPage captures PRE-update content; post-update lives on parent", () => {
|
|
85
|
+
const agentId = makeAgentId();
|
|
86
|
+
const page = createPage({
|
|
87
|
+
agentId,
|
|
88
|
+
slug: "pre-update",
|
|
89
|
+
title: "Original Title",
|
|
90
|
+
contentType: "text/html",
|
|
91
|
+
authMode: "public",
|
|
92
|
+
body: "<h1>v1 body</h1>",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 1. Snapshot first — captures v1 (pre-update) content
|
|
96
|
+
snapshotPage(page.id, agentId);
|
|
97
|
+
// 2. Then update — new content goes on parent
|
|
98
|
+
const updated = updatePage(page.id, {
|
|
99
|
+
title: "Updated Title",
|
|
100
|
+
body: "<h1>v2 body</h1>",
|
|
101
|
+
});
|
|
102
|
+
expect(updated?.title).toBe("Updated Title");
|
|
103
|
+
expect(updated?.body).toBe("<h1>v2 body</h1>");
|
|
104
|
+
|
|
105
|
+
const v1 = getPageVersion(page.id, 1);
|
|
106
|
+
expect(v1).not.toBeNull();
|
|
107
|
+
expect(v1?.snapshot.title).toBe("Original Title");
|
|
108
|
+
expect(v1?.snapshot.body).toBe("<h1>v1 body</h1>");
|
|
109
|
+
|
|
110
|
+
// Repeat — snapshot then update should produce v2 with the latest
|
|
111
|
+
// pre-update state (i.e. "Updated Title").
|
|
112
|
+
snapshotPage(page.id, agentId);
|
|
113
|
+
updatePage(page.id, { title: "Third Title", body: "<h1>v3 body</h1>" });
|
|
114
|
+
|
|
115
|
+
const v2 = getPageVersion(page.id, 2);
|
|
116
|
+
expect(v2?.snapshot.title).toBe("Updated Title");
|
|
117
|
+
expect(v2?.snapshot.body).toBe("<h1>v2 body</h1>");
|
|
118
|
+
|
|
119
|
+
// Versions list ordered DESC
|
|
120
|
+
const all = getPageVersions(page.id);
|
|
121
|
+
expect(all.map((v) => v.version)).toEqual([2, 1]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("UNIQUE(agentId, slug) is enforced", () => {
|
|
125
|
+
const agentId = makeAgentId();
|
|
126
|
+
createPage({
|
|
127
|
+
agentId,
|
|
128
|
+
slug: "dup",
|
|
129
|
+
title: "First",
|
|
130
|
+
contentType: "text/html",
|
|
131
|
+
authMode: "public",
|
|
132
|
+
body: "<h1>1</h1>",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(() =>
|
|
136
|
+
createPage({
|
|
137
|
+
agentId,
|
|
138
|
+
slug: "dup",
|
|
139
|
+
title: "Second",
|
|
140
|
+
contentType: "text/html",
|
|
141
|
+
authMode: "public",
|
|
142
|
+
body: "<h1>2</h1>",
|
|
143
|
+
}),
|
|
144
|
+
).toThrow(/UNIQUE/);
|
|
145
|
+
|
|
146
|
+
// Different agent — same slug is fine
|
|
147
|
+
const otherAgent = makeAgentId();
|
|
148
|
+
const ok = createPage({
|
|
149
|
+
agentId: otherAgent,
|
|
150
|
+
slug: "dup",
|
|
151
|
+
title: "Other agent",
|
|
152
|
+
contentType: "text/html",
|
|
153
|
+
authMode: "public",
|
|
154
|
+
body: "<h1>x</h1>",
|
|
155
|
+
});
|
|
156
|
+
expect(ok.id).toBeTruthy();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("password hash is not equal to plaintext", async () => {
|
|
160
|
+
const plaintext = "hunter2-secret";
|
|
161
|
+
const hash = await Bun.password.hash(plaintext, "bcrypt");
|
|
162
|
+
expect(hash).not.toBe(plaintext);
|
|
163
|
+
expect(hash.length).toBeGreaterThan(20);
|
|
164
|
+
|
|
165
|
+
const page = createPage({
|
|
166
|
+
agentId: makeAgentId(),
|
|
167
|
+
slug: "secret",
|
|
168
|
+
title: "Secret",
|
|
169
|
+
contentType: "text/html",
|
|
170
|
+
authMode: "password",
|
|
171
|
+
passwordHash: hash,
|
|
172
|
+
body: "<h1>secret</h1>",
|
|
173
|
+
});
|
|
174
|
+
expect(page.passwordHash).toBe(hash);
|
|
175
|
+
expect(page.passwordHash).not.toBe(plaintext);
|
|
176
|
+
expect(await Bun.password.verify(plaintext, page.passwordHash!)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("needsCredentials roundtrips as JSON array", () => {
|
|
180
|
+
const page = createPage({
|
|
181
|
+
agentId: makeAgentId(),
|
|
182
|
+
slug: "needs-creds",
|
|
183
|
+
title: "Needs",
|
|
184
|
+
contentType: "application/json",
|
|
185
|
+
authMode: "public",
|
|
186
|
+
body: "{}",
|
|
187
|
+
needsCredentials: ["GITHUB_TOKEN", "OPENAI_API_KEY"],
|
|
188
|
+
});
|
|
189
|
+
const fetched = getPage(page.id);
|
|
190
|
+
expect(fetched?.needsCredentials).toEqual(["GITHUB_TOKEN", "OPENAI_API_KEY"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("snapshotPage throws on missing parent", () => {
|
|
194
|
+
expect(() => snapshotPage("0".repeat(32), "agent-x")).toThrow(/not found/);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /api/pages/:id versioning behavior.
|
|
3
|
+
*
|
|
4
|
+
* Per the plan (step-3 §1): each PUT calls `snapshotPage(id)` BEFORE
|
|
5
|
+
* `updatePage(id, …)`, so each row in `page_versions` holds the parent
|
|
6
|
+
* state as it stood IMMEDIATELY BEFORE that PUT. After three PUTs of a
|
|
7
|
+
* page created with body "v0", the version table should hold:
|
|
8
|
+
* v1: snapshot.body = "v0" (state before PUT #1)
|
|
9
|
+
* v2: snapshot.body = "v1" (state before PUT #2)
|
|
10
|
+
* v3: snapshot.body = "v2" (state before PUT #3)
|
|
11
|
+
* And the parent (final state) holds "v3".
|
|
12
|
+
*
|
|
13
|
+
* Edit-counter returned to the caller is `MAX(version) + 1`.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { unlink } from "node:fs/promises";
|
|
18
|
+
import {
|
|
19
|
+
createServer as createHttpServer,
|
|
20
|
+
type IncomingMessage,
|
|
21
|
+
type Server,
|
|
22
|
+
type ServerResponse,
|
|
23
|
+
} from "node:http";
|
|
24
|
+
import { closeDb, initDb } from "../be/db";
|
|
25
|
+
import { handlePages } from "../http/pages";
|
|
26
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
27
|
+
import type { PageVersion } from "../types";
|
|
28
|
+
|
|
29
|
+
const TEST_DB_PATH = "./test-pages-versioning.sqlite";
|
|
30
|
+
const TEST_PORT = 13041;
|
|
31
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
32
|
+
|
|
33
|
+
function createTestServer(): Server {
|
|
34
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
35
|
+
res.setHeader("Content-Type", "application/json");
|
|
36
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
37
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
38
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
39
|
+
const handled = await handlePages(req, res, pathSegments, queryParams, myAgentId);
|
|
40
|
+
if (!handled) {
|
|
41
|
+
res.writeHead(404);
|
|
42
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("Pages versioning (PUT /api/pages/:id)", () => {
|
|
48
|
+
let server: Server;
|
|
49
|
+
const agentId = crypto.randomUUID();
|
|
50
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
54
|
+
try {
|
|
55
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
initDb(TEST_DB_PATH);
|
|
59
|
+
server = createTestServer();
|
|
60
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
65
|
+
closeDb();
|
|
66
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
67
|
+
try {
|
|
68
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("three PUTs produce three version snapshots holding pre-update content", async () => {
|
|
74
|
+
// Create with body v0.
|
|
75
|
+
const created = await fetch(`${BASE}/api/pages`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers,
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
slug: "vers",
|
|
80
|
+
title: "Versioning",
|
|
81
|
+
contentType: "text/html",
|
|
82
|
+
authMode: "public",
|
|
83
|
+
body: "v0",
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
expect(created.status).toBe(201);
|
|
87
|
+
const { id } = (await created.json()) as { id: string; version: number };
|
|
88
|
+
|
|
89
|
+
// PUT v1, v2, v3.
|
|
90
|
+
for (const body of ["v1", "v2", "v3"]) {
|
|
91
|
+
const res = await fetch(`${BASE}/api/pages/${id}`, {
|
|
92
|
+
method: "PUT",
|
|
93
|
+
headers,
|
|
94
|
+
body: JSON.stringify({ body }),
|
|
95
|
+
});
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Final state is "v3".
|
|
100
|
+
const head = await fetch(`${BASE}/api/pages/${id}`, { headers });
|
|
101
|
+
expect(head.status).toBe(200);
|
|
102
|
+
const headPage = (await head.json()) as { body: string };
|
|
103
|
+
expect(headPage.body).toBe("v3");
|
|
104
|
+
|
|
105
|
+
// Versions: newest-first, so v3..v1 holding v2, v1, v0 respectively.
|
|
106
|
+
const versionsRes = await fetch(`${BASE}/api/pages/${id}/versions`, { headers });
|
|
107
|
+
expect(versionsRes.status).toBe(200);
|
|
108
|
+
const { versions } = (await versionsRes.json()) as { versions: PageVersion[] };
|
|
109
|
+
expect(versions).toHaveLength(3);
|
|
110
|
+
expect(versions[0]!.version).toBe(3);
|
|
111
|
+
expect(versions[0]!.snapshot.body).toBe("v2");
|
|
112
|
+
expect(versions[1]!.version).toBe(2);
|
|
113
|
+
expect(versions[1]!.snapshot.body).toBe("v1");
|
|
114
|
+
expect(versions[2]!.version).toBe(1);
|
|
115
|
+
expect(versions[2]!.snapshot.body).toBe("v0");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("PUT response includes monotonically-increasing edit-counter version", async () => {
|
|
119
|
+
const created = await fetch(`${BASE}/api/pages`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers,
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
slug: "counter",
|
|
124
|
+
title: "Counter",
|
|
125
|
+
contentType: "text/html",
|
|
126
|
+
authMode: "public",
|
|
127
|
+
body: "x0",
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
const { id, version: createdVersion } = (await created.json()) as {
|
|
131
|
+
id: string;
|
|
132
|
+
version: number;
|
|
133
|
+
};
|
|
134
|
+
expect(createdVersion).toBe(1);
|
|
135
|
+
|
|
136
|
+
const observed: number[] = [];
|
|
137
|
+
for (const body of ["x1", "x2", "x3"]) {
|
|
138
|
+
const res = await fetch(`${BASE}/api/pages/${id}`, {
|
|
139
|
+
method: "PUT",
|
|
140
|
+
headers,
|
|
141
|
+
body: JSON.stringify({ body }),
|
|
142
|
+
});
|
|
143
|
+
const json = (await res.json()) as { version: number };
|
|
144
|
+
observed.push(json.version);
|
|
145
|
+
}
|
|
146
|
+
expect(observed).toEqual([2, 3, 4]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("PUT 404 for unknown id", async () => {
|
|
150
|
+
const res = await fetch(`${BASE}/api/pages/${"0".repeat(32)}`, {
|
|
151
|
+
method: "PUT",
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify({ body: "noop" }),
|
|
154
|
+
});
|
|
155
|
+
expect(res.status).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("GET /api/pages/:id/versions/:version returns single snapshot", async () => {
|
|
159
|
+
const created = await fetch(`${BASE}/api/pages`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers,
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
slug: "byversion",
|
|
164
|
+
title: "ByVersion",
|
|
165
|
+
contentType: "text/html",
|
|
166
|
+
authMode: "public",
|
|
167
|
+
body: "alpha",
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
const { id } = (await created.json()) as { id: string };
|
|
171
|
+
await fetch(`${BASE}/api/pages/${id}`, {
|
|
172
|
+
method: "PUT",
|
|
173
|
+
headers,
|
|
174
|
+
body: JSON.stringify({ body: "beta" }),
|
|
175
|
+
});
|
|
176
|
+
const single = await fetch(`${BASE}/api/pages/${id}/versions/1`, { headers });
|
|
177
|
+
expect(single.status).toBe(200);
|
|
178
|
+
const row = (await single.json()) as PageVersion;
|
|
179
|
+
expect(row.version).toBe(1);
|
|
180
|
+
expect(row.snapshot.body).toBe("alpha");
|
|
181
|
+
|
|
182
|
+
const missing = await fetch(`${BASE}/api/pages/${id}/versions/999`, { headers });
|
|
183
|
+
expect(missing.status).toBe(404);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("DELETE /api/pages/:id removes parent + cascades version rows", async () => {
|
|
187
|
+
const created = await fetch(`${BASE}/api/pages`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers,
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
slug: "doomed",
|
|
192
|
+
title: "Doomed",
|
|
193
|
+
contentType: "text/html",
|
|
194
|
+
authMode: "public",
|
|
195
|
+
body: "x",
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
const { id } = (await created.json()) as { id: string };
|
|
199
|
+
|
|
200
|
+
// Force a version snapshot via a PUT.
|
|
201
|
+
await fetch(`${BASE}/api/pages/${id}`, {
|
|
202
|
+
method: "PUT",
|
|
203
|
+
headers,
|
|
204
|
+
body: JSON.stringify({ body: "y" }),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const del = await fetch(`${BASE}/api/pages/${id}`, { method: "DELETE", headers });
|
|
208
|
+
expect(del.status).toBe(204);
|
|
209
|
+
|
|
210
|
+
const after = await fetch(`${BASE}/api/pages/${id}`, { headers });
|
|
211
|
+
expect(after.status).toBe(404);
|
|
212
|
+
|
|
213
|
+
const afterVersions = await fetch(`${BASE}/api/pages/${id}/versions`, { headers });
|
|
214
|
+
expect(afterVersions.status).toBe(404);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("GET /api/pages lists with share-URL pointers + total", async () => {
|
|
218
|
+
const listRes = await fetch(`${BASE}/api/pages?limit=10&offset=0`, { headers });
|
|
219
|
+
expect(listRes.status).toBe(200);
|
|
220
|
+
const json = (await listRes.json()) as {
|
|
221
|
+
pages: Array<{ id: string; api_url: string; app_url: string }>;
|
|
222
|
+
total: number;
|
|
223
|
+
};
|
|
224
|
+
expect(Array.isArray(json.pages)).toBe(true);
|
|
225
|
+
expect(typeof json.total).toBe("number");
|
|
226
|
+
for (const p of json.pages) {
|
|
227
|
+
expect(p.api_url).toContain(`/p/${p.id}`);
|
|
228
|
+
expect(p.app_url).toContain(`/pages/${p.id}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -69,6 +69,7 @@ describe("Session templates — registration", () => {
|
|
|
69
69
|
"system.agent.system",
|
|
70
70
|
"system.agent.services",
|
|
71
71
|
"system.agent.artifacts",
|
|
72
|
+
"system.agent.share_urls",
|
|
72
73
|
];
|
|
73
74
|
|
|
74
75
|
for (const eventType of systemTemplates) {
|
|
@@ -88,10 +89,10 @@ describe("Session templates — registration", () => {
|
|
|
88
89
|
}
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
test("total of
|
|
92
|
+
test("total of 18 session/system templates registered", () => {
|
|
92
93
|
const all = getAllTemplateDefinitions();
|
|
93
94
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
94
|
-
expect(sessionSystem.length).toBe(
|
|
95
|
+
expect(sessionSystem.length).toBe(18);
|
|
95
96
|
});
|
|
96
97
|
});
|
|
97
98
|
|