@desplega.ai/agent-swarm 1.78.1 → 1.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/openapi.json +542 -1
  2. package/package.json +1 -1
  3. package/plugin/skills/artifacts/SKILL.md +151 -0
  4. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  5. package/plugin/skills/pages/SKILL.md +274 -0
  6. package/src/artifact-sdk/browser-sdk.ts +105 -20
  7. package/src/be/db.ts +239 -0
  8. package/src/be/migrations/059_pages.sql +34 -0
  9. package/src/be/migrations/060_page_versions.sql +19 -0
  10. package/src/commands/artifact.ts +17 -11
  11. package/src/http/index.ts +7 -1
  12. package/src/http/page-proxy.ts +208 -0
  13. package/src/http/pages-public.ts +466 -0
  14. package/src/http/pages.ts +608 -0
  15. package/src/http/utils.ts +68 -5
  16. package/src/pages/version.ts +44 -0
  17. package/src/prompts/session-templates.ts +51 -0
  18. package/src/server.ts +10 -1
  19. package/src/tests/artifact-commands.test.ts +92 -0
  20. package/src/tests/artifact-sdk.test.ts +80 -74
  21. package/src/tests/create-page-tool.test.ts +197 -0
  22. package/src/tests/fixtures/sample-json-page.json +52 -0
  23. package/src/tests/launch-password-rejection.test.ts +139 -0
  24. package/src/tests/page-proxy-authed.test.ts +146 -0
  25. package/src/tests/page-proxy.test.ts +266 -0
  26. package/src/tests/page-session.test.ts +164 -0
  27. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  28. package/src/tests/pages-authed-mode.test.ts +207 -0
  29. package/src/tests/pages-http.test.ts +193 -0
  30. package/src/tests/pages-list-endpoint.test.ts +149 -0
  31. package/src/tests/pages-password-hash.test.ts +57 -0
  32. package/src/tests/pages-password-mode.test.ts +265 -0
  33. package/src/tests/pages-public-authed-401.test.ts +102 -0
  34. package/src/tests/pages-public-html.test.ts +151 -0
  35. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  36. package/src/tests/pages-storage.test.ts +196 -0
  37. package/src/tests/pages-versioning.test.ts +231 -0
  38. package/src/tests/prompt-template-session.test.ts +3 -2
  39. package/src/tests/skill-update-scope.test.ts +165 -0
  40. package/src/tests/workflow-wait-event.test.ts +4 -7
  41. package/src/tools/create-page.ts +263 -0
  42. package/src/tools/skills/skill-update.ts +26 -0
  43. package/src/tools/tool-config.ts +3 -0
  44. package/src/types.ts +54 -0
  45. package/src/utils/page-session.ts +254 -0
  46. package/plugin/skills/artifacts/skill.md +0 -70
@@ -0,0 +1,164 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { parseCookieHeader, signPageSession, verifyPageSession } from "../utils/page-session";
3
+
4
+ const ORIGINAL_SECRET = process.env.PAGE_SESSION_SECRET;
5
+ const ORIGINAL_API_KEY = process.env.API_KEY;
6
+
7
+ beforeAll(() => {
8
+ process.env.PAGE_SESSION_SECRET = "test-secret-fixed-vector-key";
9
+ });
10
+
11
+ afterAll(() => {
12
+ if (ORIGINAL_SECRET !== undefined) process.env.PAGE_SESSION_SECRET = ORIGINAL_SECRET;
13
+ else delete process.env.PAGE_SESSION_SECRET;
14
+ if (ORIGINAL_API_KEY !== undefined) process.env.API_KEY = ORIGINAL_API_KEY;
15
+ else delete process.env.API_KEY;
16
+ });
17
+
18
+ describe("page-session HMAC helpers", () => {
19
+ test("sign produces deterministic output for fixed payload + secret", async () => {
20
+ const payload = { pageId: "deadbeefcafef00d", exp: 1893456000 };
21
+ const a = await signPageSession(payload);
22
+ const b = await signPageSession(payload);
23
+ expect(a).toBe(b);
24
+ // Shape: two base64url parts joined by `.`, no padding `=`.
25
+ expect(a).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
26
+ });
27
+
28
+ test("round-trip: verify returns the original payload", async () => {
29
+ const payload = { pageId: "abc123", exp: Math.floor(Date.now() / 1000) + 3600 };
30
+ const token = await signPageSession(payload);
31
+ const got = await verifyPageSession(token);
32
+ expect(got).toEqual(payload);
33
+ });
34
+
35
+ test("expired token (exp in the past) returns null", async () => {
36
+ const payload = { pageId: "abc123", exp: Math.floor(Date.now() / 1000) - 1 };
37
+ const token = await signPageSession(payload);
38
+ const got = await verifyPageSession(token);
39
+ expect(got).toBeNull();
40
+ });
41
+
42
+ test("tampered payload returns null", async () => {
43
+ const payload = { pageId: "abc123", exp: Math.floor(Date.now() / 1000) + 3600 };
44
+ const token = await signPageSession(payload);
45
+ const [head, sig] = token.split(".");
46
+ // Re-encode a different payload with the SAME signature — must fail.
47
+ const evil = Buffer.from(JSON.stringify({ pageId: "evil", exp: payload.exp }))
48
+ .toString("base64")
49
+ .replace(/\+/g, "-")
50
+ .replace(/\//g, "_")
51
+ .replace(/=+$/, "");
52
+ expect(head).toBeDefined();
53
+ const tampered = `${evil}.${sig}`;
54
+ expect(await verifyPageSession(tampered)).toBeNull();
55
+ });
56
+
57
+ test("tampered signature (single-bit flip) returns null", async () => {
58
+ const payload = { pageId: "abc123", exp: Math.floor(Date.now() / 1000) + 3600 };
59
+ const token = await signPageSession(payload);
60
+ const [head, sig] = token.split(".");
61
+ expect(sig).toBeDefined();
62
+ // Flip the last character — keeps length identical so we exercise the
63
+ // constant-time compare branch (not the length-mismatch early-return).
64
+ const lastChar = sig!.slice(-1);
65
+ const flipped = lastChar === "A" ? "B" : "A";
66
+ const tamperedSig = sig!.slice(0, -1) + flipped;
67
+ const tampered = `${head}.${tamperedSig}`;
68
+ expect(await verifyPageSession(tampered)).toBeNull();
69
+ });
70
+
71
+ test("malformed token (no dot) returns null", async () => {
72
+ expect(await verifyPageSession("not-a-token")).toBeNull();
73
+ });
74
+
75
+ test("empty / null / undefined token returns null", async () => {
76
+ expect(await verifyPageSession("")).toBeNull();
77
+ expect(await verifyPageSession(null)).toBeNull();
78
+ expect(await verifyPageSession(undefined)).toBeNull();
79
+ });
80
+
81
+ test("token signed with different secret is rejected", async () => {
82
+ const payload = { pageId: "abc123", exp: Math.floor(Date.now() / 1000) + 3600 };
83
+ const token = await signPageSession(payload);
84
+
85
+ process.env.PAGE_SESSION_SECRET = "different-secret-after-rotation";
86
+ try {
87
+ const got = await verifyPageSession(token);
88
+ expect(got).toBeNull();
89
+ } finally {
90
+ process.env.PAGE_SESSION_SECRET = "test-secret-fixed-vector-key";
91
+ }
92
+ });
93
+
94
+ test("falls back to API_KEY when PAGE_SESSION_SECRET unset", async () => {
95
+ delete process.env.PAGE_SESSION_SECRET;
96
+ process.env.API_KEY = "fallback-api-key-for-test";
97
+ try {
98
+ const payload = { pageId: "fallback", exp: Math.floor(Date.now() / 1000) + 3600 };
99
+ const token = await signPageSession(payload);
100
+ const got = await verifyPageSession(token);
101
+ expect(got).toEqual(payload);
102
+ } finally {
103
+ process.env.PAGE_SESSION_SECRET = "test-secret-fixed-vector-key";
104
+ }
105
+ });
106
+
107
+ test("refuses to sign when both PAGE_SESSION_SECRET and API_KEY are unset", async () => {
108
+ delete process.env.PAGE_SESSION_SECRET;
109
+ const savedApiKey = process.env.API_KEY;
110
+ delete process.env.API_KEY;
111
+ try {
112
+ await expect(
113
+ signPageSession({ pageId: "x", exp: Math.floor(Date.now() / 1000) + 60 }),
114
+ ).rejects.toThrow(/PAGE_SESSION_SECRET|API_KEY/);
115
+ } finally {
116
+ process.env.PAGE_SESSION_SECRET = "test-secret-fixed-vector-key";
117
+ if (savedApiKey !== undefined) process.env.API_KEY = savedApiKey;
118
+ }
119
+ });
120
+
121
+ test("known-vector regression: payload {pageId:'abc',exp:1893456000} with secret 'test-secret-fixed-vector-key' verifies", async () => {
122
+ const payload = { pageId: "abc", exp: 1893456000 };
123
+ const token = await signPageSession(payload);
124
+ // We don't pin the exact bytes here (Buffer base64url ordering is stable
125
+ // but the test value would be brittle to refactor); instead we re-verify
126
+ // and check the payload survives the round-trip — this exercises the
127
+ // full sign+verify pipeline against a known vector.
128
+ expect(await verifyPageSession(token)).toEqual(payload);
129
+ });
130
+ });
131
+
132
+ describe("parseCookieHeader", () => {
133
+ test("returns undefined when header is absent", () => {
134
+ expect(parseCookieHeader(undefined, "page_session")).toBeUndefined();
135
+ expect(parseCookieHeader("", "page_session")).toBeUndefined();
136
+ });
137
+
138
+ test("parses a single cookie", () => {
139
+ expect(parseCookieHeader("page_session=abc.def", "page_session")).toBe("abc.def");
140
+ });
141
+
142
+ test("parses one cookie among many", () => {
143
+ const header = "foo=1; page_session=abc.def; bar=2";
144
+ expect(parseCookieHeader(header, "page_session")).toBe("abc.def");
145
+ });
146
+
147
+ test("returns first match when duplicate cookies present", () => {
148
+ const header = "page_session=first; page_session=second";
149
+ expect(parseCookieHeader(header, "page_session")).toBe("first");
150
+ });
151
+
152
+ test("handles array headers (Node's http types allow string[])", () => {
153
+ expect(parseCookieHeader(["page_session=array-value"], "page_session")).toBe("array-value");
154
+ });
155
+
156
+ test("returns undefined when target cookie not in header", () => {
157
+ expect(parseCookieHeader("foo=1; bar=2", "page_session")).toBeUndefined();
158
+ });
159
+
160
+ test("does NOT match a cookie whose name is a suffix of another", () => {
161
+ // `xxpage_session=evil` must not be returned for name `page_session`.
162
+ expect(parseCookieHeader("xxpage_session=evil; other=ok", "page_session")).toBeUndefined();
163
+ });
164
+ });
@@ -0,0 +1,102 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import { closeDb, initDb } from "../be/db";
10
+ import { handlePages } from "../http/pages";
11
+ import { getPathSegments, parseQueryParams } from "../http/utils";
12
+
13
+ const TEST_DB_PATH = "./test-pages-actions-endpoint.sqlite";
14
+ const TEST_PORT = 13062;
15
+ const baseUrl = `http://localhost:${TEST_PORT}`;
16
+
17
+ interface ActionListResponse {
18
+ actions: Array<{
19
+ name: string;
20
+ description: string;
21
+ params: Record<string, unknown>;
22
+ sdkMethods?: string[];
23
+ }>;
24
+ }
25
+
26
+ function createTestServer(): Server {
27
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
28
+ res.setHeader("Content-Type", "application/json");
29
+ const pathSegments = getPathSegments(req.url || "");
30
+ const queryParams = parseQueryParams(req.url || "");
31
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
32
+ const handled = await handlePages(req, res, pathSegments, queryParams, myAgentId);
33
+ if (!handled) {
34
+ res.writeHead(404);
35
+ res.end(JSON.stringify({ error: "Not found" }));
36
+ }
37
+ });
38
+ }
39
+
40
+ describe("GET /api/pages/actions — JSON-page action allowlist", () => {
41
+ let server: Server;
42
+
43
+ beforeAll(async () => {
44
+ process.env.DB_PATH = TEST_DB_PATH;
45
+ initDb();
46
+ server = createTestServer();
47
+ await new Promise<void>((r) => server.listen(TEST_PORT, r));
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await new Promise<void>((r) => server.close(() => r()));
52
+ closeDb();
53
+ try {
54
+ await unlink(TEST_DB_PATH);
55
+ } catch {
56
+ /* ok */
57
+ }
58
+ });
59
+
60
+ test("returns both swarm.sdk and swarm.call action descriptors", async () => {
61
+ const res = await fetch(`${baseUrl}/api/pages/actions`);
62
+ expect(res.status).toBe(200);
63
+ const json = (await res.json()) as ActionListResponse;
64
+ const names = json.actions.map((a) => a.name);
65
+ expect(names).toContain("swarm.sdk");
66
+ expect(names).toContain("swarm.call");
67
+ });
68
+
69
+ test("swarm.sdk descriptor surfaces the full SDK method allowlist", async () => {
70
+ const res = await fetch(`${baseUrl}/api/pages/actions`);
71
+ expect(res.status).toBe(200);
72
+ const json = (await res.json()) as ActionListResponse;
73
+ const sdk = json.actions.find((a) => a.name === "swarm.sdk");
74
+ expect(sdk).toBeDefined();
75
+ expect(sdk?.sdkMethods).toEqual([
76
+ "createTask",
77
+ "getTasks",
78
+ "getTaskDetails",
79
+ "storeProgress",
80
+ "postMessage",
81
+ "readMessages",
82
+ "getSwarm",
83
+ "listServices",
84
+ "slackReply",
85
+ ]);
86
+ // params is a JSON Schema 7 object with `sdk` enum + optional `args`
87
+ expect(sdk?.params).toBeDefined();
88
+ expect((sdk?.params as { type?: string }).type).toBe("object");
89
+ });
90
+
91
+ test("swarm.call descriptor describes the {method, endpoint, body} shape", async () => {
92
+ const res = await fetch(`${baseUrl}/api/pages/actions`);
93
+ const json = (await res.json()) as ActionListResponse;
94
+ const call = json.actions.find((a) => a.name === "swarm.call");
95
+ expect(call).toBeDefined();
96
+ const params = call?.params as { properties?: Record<string, unknown> };
97
+ expect(params.properties).toBeDefined();
98
+ expect(params.properties).toHaveProperty("method");
99
+ expect(params.properties).toHaveProperty("endpoint");
100
+ expect(params.properties).toHaveProperty("body");
101
+ });
102
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * End-to-end coverage for `auth_mode='authed'` on `/p/:id` (step-4):
3
+ *
4
+ * 1. Create an authed HTML page.
5
+ * 2. `GET /p/:id` without cookie → 401.
6
+ * 3. `POST /api/pages/:id/launch` (bearer) → 204 + Set-Cookie.
7
+ * 4. `GET /p/:id` with cookie → 200 + SDK injected.
8
+ * 5. `GET /p/:id` with cookie scoped to a DIFFERENT page id → 403.
9
+ * 6. `GET /p/:id.json` with cookie → 200 + JSON metadata.
10
+ *
11
+ * Uses the same in-process handler wiring as `pages-public-html.test.ts` so
12
+ * we don't have to boot the full http server. The proxy in
13
+ * `page-proxy.test.ts` exercises the spawned-server bearer-gate path; here
14
+ * we're just verifying the cookie-gate at `/p/:id`.
15
+ */
16
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17
+ import crypto from "node:crypto";
18
+ import { unlink } from "node:fs/promises";
19
+ import {
20
+ createServer as createHttpServer,
21
+ type IncomingMessage,
22
+ type Server,
23
+ type ServerResponse,
24
+ } from "node:http";
25
+ import { closeDb, initDb } from "../be/db";
26
+ import { handlePages } from "../http/pages";
27
+ import { handlePagesPublic } from "../http/pages-public";
28
+ import { getPathSegments, parseQueryParams } from "../http/utils";
29
+ import { signPageSession } from "../utils/page-session";
30
+
31
+ const TEST_DB_PATH = "./test-pages-authed-mode.sqlite";
32
+ const TEST_PORT = 13049;
33
+ const BASE = `http://localhost:${TEST_PORT}`;
34
+
35
+ function createTestServer(): Server {
36
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
37
+ const pathSegments = getPathSegments(req.url || "");
38
+ const queryParams = parseQueryParams(req.url || "");
39
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
40
+ if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
41
+ if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
42
+ res.writeHead(404);
43
+ res.end("not found");
44
+ });
45
+ }
46
+
47
+ /** Pull the cookie value out of a Set-Cookie header line. */
48
+ function extractCookieValue(setCookie: string | null): string {
49
+ expect(setCookie).toBeTruthy();
50
+ const match = /page_session=([^;]+)/.exec(setCookie!);
51
+ expect(match).toBeTruthy();
52
+ return match![1]!;
53
+ }
54
+
55
+ describe("GET /p/:id — authed mode cookie gate (step-4)", () => {
56
+ let server: Server;
57
+ const agentId = crypto.randomUUID();
58
+ const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
59
+
60
+ // Set the page-session secret BEFORE the server boots so signPageSession()
61
+ // in the launch handler picks it up. The test re-uses API_KEY fallback too.
62
+ beforeAll(async () => {
63
+ process.env.PAGE_SESSION_SECRET = "test-authed-mode-secret-xyz";
64
+ for (const suffix of ["", "-wal", "-shm"]) {
65
+ try {
66
+ await unlink(`${TEST_DB_PATH}${suffix}`);
67
+ } catch {}
68
+ }
69
+ initDb(TEST_DB_PATH);
70
+ server = createTestServer();
71
+ await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await new Promise<void>((resolve) => server.close(() => resolve()));
76
+ closeDb();
77
+ for (const suffix of ["", "-wal", "-shm"]) {
78
+ try {
79
+ await unlink(`${TEST_DB_PATH}${suffix}`);
80
+ } catch {}
81
+ }
82
+ });
83
+
84
+ async function createAuthedPage(slug: string, body = "<h1>secret</h1>"): Promise<string> {
85
+ const post = await fetch(`${BASE}/api/pages`, {
86
+ method: "POST",
87
+ headers,
88
+ body: JSON.stringify({
89
+ slug,
90
+ title: `Authed ${slug}`,
91
+ contentType: "text/html",
92
+ authMode: "authed",
93
+ body,
94
+ }),
95
+ });
96
+ expect(post.status).toBe(201);
97
+ const { id } = (await post.json()) as { id: string };
98
+ return id;
99
+ }
100
+
101
+ test("no cookie → 401 with cookie-required guidance", async () => {
102
+ const id = await createAuthedPage("no-cookie");
103
+ const res = await fetch(`${BASE}/p/${id}`);
104
+ expect(res.status).toBe(401);
105
+ const body = (await res.json()) as { error: string };
106
+ expect(body.error).toContain("cookie");
107
+ });
108
+
109
+ test("launch issues cookie → /p/:id with cookie → 200 + SDK", async () => {
110
+ const id = await createAuthedPage(
111
+ "with-cookie",
112
+ "<!doctype html><body><h1>private</h1></body>",
113
+ );
114
+
115
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
116
+ method: "POST",
117
+ headers: { "X-Agent-ID": agentId },
118
+ });
119
+ expect(launch.status).toBe(204);
120
+ const cookieValue = extractCookieValue(launch.headers.get("set-cookie"));
121
+
122
+ const res = await fetch(`${BASE}/p/${id}`, {
123
+ headers: { Cookie: `page_session=${cookieValue}` },
124
+ });
125
+ expect(res.status).toBe(200);
126
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("text/html");
127
+ const text = await res.text();
128
+ expect(text).toContain("<h1>private</h1>");
129
+ // BROWSER_SDK_JS sentinel — confirms injection happened on the authed
130
+ // branch, not just the public one.
131
+ expect(text).toContain("class SwarmSDK");
132
+ });
133
+
134
+ test("cookie scoped to a different page id → 403", async () => {
135
+ const idA = await createAuthedPage("page-a");
136
+ const idB = await createAuthedPage("page-b");
137
+
138
+ // Mint a cookie for page A …
139
+ const exp = Math.floor(Date.now() / 1000) + 3600;
140
+ const tokenForA = await signPageSession({ pageId: idA, exp });
141
+
142
+ // … and try to use it for page B.
143
+ const res = await fetch(`${BASE}/p/${idB}`, {
144
+ headers: { Cookie: `page_session=${tokenForA}` },
145
+ });
146
+ expect(res.status).toBe(403);
147
+ const body = (await res.json()) as { error: string };
148
+ expect(body.error).toContain("different page id");
149
+ });
150
+
151
+ test("/p/:id.json with cookie → 200 + JSON metadata", async () => {
152
+ const id = await createAuthedPage("json-meta");
153
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
154
+ method: "POST",
155
+ headers: { "X-Agent-ID": agentId },
156
+ });
157
+ expect(launch.status).toBe(204);
158
+ const cookieValue = extractCookieValue(launch.headers.get("set-cookie"));
159
+
160
+ const res = await fetch(`${BASE}/p/${id}.json`, {
161
+ headers: { Cookie: `page_session=${cookieValue}` },
162
+ });
163
+ expect(res.status).toBe(200);
164
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
165
+ const json = (await res.json()) as {
166
+ id: string;
167
+ authMode: string;
168
+ contentType: string;
169
+ body: string;
170
+ };
171
+ expect(json.id).toBe(id);
172
+ expect(json.authMode).toBe("authed");
173
+ expect(json.contentType).toBe("text/html");
174
+ expect(json.body).toContain("<h1>secret</h1>");
175
+ });
176
+
177
+ test("/p/:id.json WITHOUT cookie → 401", async () => {
178
+ const id = await createAuthedPage("json-no-cookie");
179
+ const res = await fetch(`${BASE}/p/${id}.json`);
180
+ expect(res.status).toBe(401);
181
+ });
182
+
183
+ test("expired cookie → 401 (HMAC actually verified, not just presence)", async () => {
184
+ const id = await createAuthedPage("expired");
185
+ const expired = await signPageSession({
186
+ pageId: id,
187
+ exp: Math.floor(Date.now() / 1000) - 60,
188
+ });
189
+ const res = await fetch(`${BASE}/p/${id}`, {
190
+ headers: { Cookie: `page_session=${expired}` },
191
+ });
192
+ expect(res.status).toBe(401);
193
+ });
194
+
195
+ test("tampered signature → 401", async () => {
196
+ const id = await createAuthedPage("tampered");
197
+ const exp = Math.floor(Date.now() / 1000) + 3600;
198
+ const good = await signPageSession({ pageId: id, exp });
199
+ const [head, sig] = good.split(".");
200
+ const tamperedSig = `${sig!.slice(0, -1)}${sig!.slice(-1) === "A" ? "B" : "A"}`;
201
+ const bad = `${head}.${tamperedSig}`;
202
+ const res = await fetch(`${BASE}/p/${id}`, {
203
+ headers: { Cookie: `page_session=${bad}` },
204
+ });
205
+ expect(res.status).toBe(401);
206
+ });
207
+ });
@@ -0,0 +1,193 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import crypto from "node:crypto";
3
+ import { unlink } from "node:fs/promises";
4
+ import {
5
+ createServer as createHttpServer,
6
+ type IncomingMessage,
7
+ type Server,
8
+ type ServerResponse,
9
+ } from "node:http";
10
+ import { closeDb, initDb } from "../be/db";
11
+ import { handlePages } from "../http/pages";
12
+ import { getPathSegments, parseQueryParams } from "../http/utils";
13
+ import type { Page } from "../types";
14
+
15
+ const TEST_DB_PATH = "./test-pages-http.sqlite";
16
+ const TEST_PORT = 13037;
17
+ const baseUrl = `http://localhost:${TEST_PORT}`;
18
+
19
+ function createTestServer(): Server {
20
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
21
+ res.setHeader("Content-Type", "application/json");
22
+ const pathSegments = getPathSegments(req.url || "");
23
+ const queryParams = parseQueryParams(req.url || "");
24
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
25
+
26
+ const handled = await handlePages(req, res, pathSegments, queryParams, myAgentId);
27
+ if (!handled) {
28
+ res.writeHead(404);
29
+ res.end(JSON.stringify({ error: "Not found" }));
30
+ }
31
+ });
32
+ }
33
+
34
+ describe("Pages HTTP API", () => {
35
+ let server: Server;
36
+ const agentId = crypto.randomUUID();
37
+ const headers = {
38
+ "Content-Type": "application/json",
39
+ "X-Agent-ID": agentId,
40
+ };
41
+
42
+ beforeAll(async () => {
43
+ try {
44
+ await unlink(TEST_DB_PATH);
45
+ } catch {}
46
+ initDb(TEST_DB_PATH);
47
+
48
+ server = createTestServer();
49
+ await new Promise<void>((resolve) => {
50
+ server.listen(TEST_PORT, () => resolve());
51
+ });
52
+ });
53
+
54
+ afterAll(async () => {
55
+ await new Promise<void>((resolve) => server.close(() => resolve()));
56
+ closeDb();
57
+ for (const suffix of ["", "-wal", "-shm"]) {
58
+ try {
59
+ await unlink(`${TEST_DB_PATH}${suffix}`);
60
+ } catch {}
61
+ }
62
+ });
63
+
64
+ test("POST /api/pages creates a page and returns {id, version}", async () => {
65
+ const res = await fetch(`${baseUrl}/api/pages`, {
66
+ method: "POST",
67
+ headers,
68
+ body: JSON.stringify({
69
+ title: "Hello",
70
+ contentType: "text/html",
71
+ authMode: "public",
72
+ body: "<h1>hi</h1>",
73
+ }),
74
+ });
75
+
76
+ expect(res.status).toBe(201);
77
+ const json = (await res.json()) as { id: string; version: number };
78
+ expect(json.id).toMatch(/^[0-9a-f]{32}$/);
79
+ expect(json.version).toBe(1);
80
+
81
+ // Round-trip via GET
82
+ const got = await fetch(`${baseUrl}/api/pages/${json.id}`, { headers });
83
+ expect(got.status).toBe(200);
84
+ const page = (await got.json()) as Page;
85
+ expect(page.title).toBe("Hello");
86
+ expect(page.body).toBe("<h1>hi</h1>");
87
+ expect(page.agentId).toBe(agentId);
88
+ expect(page.slug).toBe("hello"); // auto-slug from title
89
+ expect(page.contentType).toBe("text/html");
90
+ expect(page.authMode).toBe("public");
91
+ });
92
+
93
+ test("POST /api/pages with full HTML document body is stored verbatim", async () => {
94
+ const fullDoc =
95
+ "<!doctype html><html><head><title>x</title></head><body><h1>hi</h1></body></html>";
96
+ const res = await fetch(`${baseUrl}/api/pages`, {
97
+ method: "POST",
98
+ headers,
99
+ body: JSON.stringify({
100
+ slug: "full-doc",
101
+ title: "Full Doc",
102
+ contentType: "text/html",
103
+ authMode: "public",
104
+ body: fullDoc,
105
+ }),
106
+ });
107
+ expect(res.status).toBe(201);
108
+ const { id } = (await res.json()) as { id: string };
109
+ const got = await fetch(`${baseUrl}/api/pages/${id}`, { headers });
110
+ const page = (await got.json()) as Page;
111
+ expect(page.body).toBe(fullDoc);
112
+ });
113
+
114
+ test("POST /api/pages with password hashes the password", async () => {
115
+ const password = "open-sesame-9";
116
+ const res = await fetch(`${baseUrl}/api/pages`, {
117
+ method: "POST",
118
+ headers,
119
+ body: JSON.stringify({
120
+ slug: "pw-page",
121
+ title: "Pw",
122
+ contentType: "text/html",
123
+ authMode: "password",
124
+ password,
125
+ body: "<h1>secret</h1>",
126
+ }),
127
+ });
128
+ expect(res.status).toBe(201);
129
+ const { id } = (await res.json()) as { id: string };
130
+
131
+ const got = await fetch(`${baseUrl}/api/pages/${id}`, { headers });
132
+ const page = (await got.json()) as Page;
133
+ expect(page.passwordHash).toBeDefined();
134
+ expect(page.passwordHash).not.toBe(password);
135
+ expect(await Bun.password.verify(password, page.passwordHash!)).toBe(true);
136
+ });
137
+
138
+ test("POST /api/pages with duplicate slug → 409", async () => {
139
+ const body = {
140
+ slug: "dup-slug",
141
+ title: "First",
142
+ contentType: "text/html" as const,
143
+ authMode: "public" as const,
144
+ body: "<h1>1</h1>",
145
+ };
146
+ const first = await fetch(`${baseUrl}/api/pages`, {
147
+ method: "POST",
148
+ headers,
149
+ body: JSON.stringify(body),
150
+ });
151
+ expect(first.status).toBe(201);
152
+
153
+ const second = await fetch(`${baseUrl}/api/pages`, {
154
+ method: "POST",
155
+ headers,
156
+ body: JSON.stringify(body),
157
+ });
158
+ expect(second.status).toBe(409);
159
+ });
160
+
161
+ test("POST /api/pages without X-Agent-ID → 400", async () => {
162
+ const res = await fetch(`${baseUrl}/api/pages`, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({
166
+ title: "Anonymous",
167
+ contentType: "text/html",
168
+ authMode: "public",
169
+ body: "<h1>hi</h1>",
170
+ }),
171
+ });
172
+ expect(res.status).toBe(400);
173
+ });
174
+
175
+ test("POST /api/pages with bad contentType → 400", async () => {
176
+ const res = await fetch(`${baseUrl}/api/pages`, {
177
+ method: "POST",
178
+ headers,
179
+ body: JSON.stringify({
180
+ title: "Bad",
181
+ contentType: "image/png",
182
+ authMode: "public",
183
+ body: "x",
184
+ }),
185
+ });
186
+ expect(res.status).toBe(400);
187
+ });
188
+
189
+ test("GET /api/pages/:id → 404 for unknown id", async () => {
190
+ const res = await fetch(`${baseUrl}/api/pages/${"0".repeat(32)}`, { headers });
191
+ expect(res.status).toBe(404);
192
+ });
193
+ });