@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
@@ -443,6 +443,36 @@ describe("parseRateLimitResetTime", () => {
443
443
  expect(parsed.getUTCHours()).toBe(0);
444
444
  });
445
445
 
446
+ test("parses 'resets May 14, 5pm (UTC)' with date prefix", () => {
447
+ const result = parseRateLimitResetTime("You've hit your limit · resets May 14, 5pm (UTC)");
448
+ expect(result).toBeDefined();
449
+ const parsed = new Date(result!);
450
+ expect(parsed.getUTCMonth()).toBe(4); // May = index 4
451
+ expect(parsed.getUTCDate()).toBe(14);
452
+ expect(parsed.getUTCHours()).toBe(17);
453
+ expect(parsed.getUTCMinutes()).toBe(0);
454
+ });
455
+
456
+ test("parses dated reset without comma", () => {
457
+ const result = parseRateLimitResetTime("resets Jan 3 9:30am (UTC)");
458
+ expect(result).toBeDefined();
459
+ const parsed = new Date(result!);
460
+ expect(parsed.getUTCMonth()).toBe(0);
461
+ expect(parsed.getUTCDate()).toBe(3);
462
+ expect(parsed.getUTCHours()).toBe(9);
463
+ expect(parsed.getUTCMinutes()).toBe(30);
464
+ });
465
+
466
+ test("dated reset in the past rolls to next year", () => {
467
+ const now = new Date();
468
+ // Pick a month/day in the past relative to "now"
469
+ const pastMonth = now.getUTCMonth() === 0 ? "December" : "January";
470
+ const result = parseRateLimitResetTime(`resets ${pastMonth} 1, 12pm (UTC)`);
471
+ expect(result).toBeDefined();
472
+ const parsed = new Date(result!);
473
+ expect(parsed.getTime()).toBeGreaterThan(now.getTime());
474
+ });
475
+
446
476
  test("parses 'retry after N seconds'", () => {
447
477
  const before = Date.now();
448
478
  const result = parseRateLimitResetTime("Rate limited. retry after 60 seconds");
@@ -0,0 +1,52 @@
1
+ {
2
+ "root": "root",
3
+ "elements": {
4
+ "root": {
5
+ "type": "Container",
6
+ "props": { "direction": "column", "gap": "md" },
7
+ "children": ["heading", "intro", "sdkBtn", "callBtn"]
8
+ },
9
+ "heading": {
10
+ "type": "Heading",
11
+ "props": { "text": "Sample JSON page", "level": "h1" },
12
+ "children": []
13
+ },
14
+ "intro": {
15
+ "type": "Text",
16
+ "props": {
17
+ "content": "Two buttons below dispatch swarm.sdk and swarm.call actions. Click each to exercise the renderer's action wiring.",
18
+ "tone": "muted"
19
+ },
20
+ "children": []
21
+ },
22
+ "sdkBtn": {
23
+ "type": "Button",
24
+ "props": { "label": "Create task via SDK" },
25
+ "children": [],
26
+ "on": {
27
+ "press": {
28
+ "action": "swarm.sdk",
29
+ "params": {
30
+ "sdk": "createTask",
31
+ "args": { "description": "from-json-page" }
32
+ }
33
+ }
34
+ }
35
+ },
36
+ "callBtn": {
37
+ "type": "Button",
38
+ "props": { "label": "Create channel via raw call", "variant": "secondary" },
39
+ "children": [],
40
+ "on": {
41
+ "press": {
42
+ "action": "swarm.call",
43
+ "params": {
44
+ "method": "POST",
45
+ "endpoint": "/api/channels",
46
+ "body": { "name": "from-json-page" }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `POST /api/pages/:id/launch` must reject `auth_mode='password'` pages with
3
+ * 400 — password pages mint their own cookie out of the public `/p/:id`
4
+ * route (step-5) after verifying the password. Letting a bearer-only caller
5
+ * mint a cookie via `/launch` would bypass the password check entirely.
6
+ *
7
+ * In-process variant of the launch endpoint; matches the test wiring used by
8
+ * `pages-public-html.test.ts` and friends.
9
+ */
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import crypto from "node:crypto";
12
+ import { unlink } from "node:fs/promises";
13
+ import {
14
+ createServer as createHttpServer,
15
+ type IncomingMessage,
16
+ type Server,
17
+ type ServerResponse,
18
+ } from "node:http";
19
+ import { closeDb, initDb } from "../be/db";
20
+ import { handlePages } from "../http/pages";
21
+ import { handlePagesPublic } from "../http/pages-public";
22
+ import { getPathSegments, parseQueryParams } from "../http/utils";
23
+
24
+ const TEST_DB_PATH = "./test-launch-password-rejection.sqlite";
25
+ const TEST_PORT = 13050;
26
+ const BASE = `http://localhost:${TEST_PORT}`;
27
+
28
+ function createTestServer(): Server {
29
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
30
+ const pathSegments = getPathSegments(req.url || "");
31
+ const queryParams = parseQueryParams(req.url || "");
32
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
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("POST /api/pages/:id/launch — password mode rejection (step-4)", () => {
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
+ process.env.PAGE_SESSION_SECRET = "test-launch-password-rejection-secret";
47
+ for (const suffix of ["", "-wal", "-shm"]) {
48
+ try {
49
+ await unlink(`${TEST_DB_PATH}${suffix}`);
50
+ } catch {}
51
+ }
52
+ initDb(TEST_DB_PATH);
53
+ server = createTestServer();
54
+ await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await new Promise<void>((resolve) => server.close(() => resolve()));
59
+ closeDb();
60
+ for (const suffix of ["", "-wal", "-shm"]) {
61
+ try {
62
+ await unlink(`${TEST_DB_PATH}${suffix}`);
63
+ } catch {}
64
+ }
65
+ });
66
+
67
+ test("password page → launch returns 400 with explanatory error", async () => {
68
+ const post = await fetch(`${BASE}/api/pages`, {
69
+ method: "POST",
70
+ headers,
71
+ body: JSON.stringify({
72
+ slug: "password-reject",
73
+ title: "Password",
74
+ contentType: "text/html",
75
+ authMode: "password",
76
+ password: "swordfish",
77
+ body: "<h1>locked</h1>",
78
+ }),
79
+ });
80
+ expect(post.status).toBe(201);
81
+ const { id } = (await post.json()) as { id: string };
82
+
83
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
84
+ method: "POST",
85
+ headers: { "X-Agent-ID": agentId },
86
+ });
87
+ expect(launch.status).toBe(400);
88
+ const body = (await launch.json()) as { error: string };
89
+ expect(body.error).toContain("use ?key=");
90
+ // Confirm no cookie was issued on the rejected launch.
91
+ expect(launch.headers.get("set-cookie")).toBeNull();
92
+ });
93
+
94
+ test("authed page → launch still issues a cookie (negative control)", async () => {
95
+ const post = await fetch(`${BASE}/api/pages`, {
96
+ method: "POST",
97
+ headers,
98
+ body: JSON.stringify({
99
+ slug: "authed-ok",
100
+ title: "Authed",
101
+ contentType: "text/html",
102
+ authMode: "authed",
103
+ body: "<h1>ok</h1>",
104
+ }),
105
+ });
106
+ expect(post.status).toBe(201);
107
+ const { id } = (await post.json()) as { id: string };
108
+
109
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
110
+ method: "POST",
111
+ headers: { "X-Agent-ID": agentId },
112
+ });
113
+ expect(launch.status).toBe(204);
114
+ expect(launch.headers.get("set-cookie")).toContain("page_session=");
115
+ });
116
+
117
+ test("public page → launch still issues a cookie (uniform path)", async () => {
118
+ const post = await fetch(`${BASE}/api/pages`, {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify({
122
+ slug: "public-ok",
123
+ title: "Public",
124
+ contentType: "text/html",
125
+ authMode: "public",
126
+ body: "<h1>open</h1>",
127
+ }),
128
+ });
129
+ expect(post.status).toBe(201);
130
+ const { id } = (await post.json()) as { id: string };
131
+
132
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
133
+ method: "POST",
134
+ headers: { "X-Agent-ID": agentId },
135
+ });
136
+ expect(launch.status).toBe(204);
137
+ expect(launch.headers.get("set-cookie")).toContain("page_session=");
138
+ });
139
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Extends the step-2 `page-proxy.test.ts` coverage to authed-mode pages.
3
+ *
4
+ * The proxy's auth model is "cookie is the auth" — it does NOT care whether
5
+ * the underlying page is `public`, `authed`, or `password`. This test simply
6
+ * confirms that an `auth_mode='authed'` page survives the same cookie flow:
7
+ * launch → cookie → /@swarm/api/agents/:id → 200 with the page owner's
8
+ * agent record.
9
+ *
10
+ * Spawns the real `src/http.ts` server (mirrors step-2's pattern) so we
11
+ * exercise the bearer gate + cookie + proxy in the same shape production
12
+ * runs.
13
+ */
14
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { randomUUID } from "node:crypto";
16
+ import { unlink } from "node:fs/promises";
17
+ import type { Subprocess } from "bun";
18
+
19
+ const TEST_PORT = 19881;
20
+ const TEST_DB_PATH = `/tmp/test-page-proxy-authed-${Date.now()}.sqlite`;
21
+ const BASE = `http://localhost:${TEST_PORT}`;
22
+ const API_KEY = "test-page-proxy-authed-key";
23
+ const PAGE_SECRET = "test-page-proxy-authed-secret";
24
+
25
+ let serverProc: Subprocess;
26
+ const agentId = randomUUID();
27
+
28
+ async function waitForServer(url: string, timeoutMs = 15000) {
29
+ const start = Date.now();
30
+ while (Date.now() - start < timeoutMs) {
31
+ try {
32
+ const r = await fetch(url);
33
+ if (r.ok) return;
34
+ } catch {}
35
+ await Bun.sleep(50);
36
+ }
37
+ throw new Error(`Server did not start within ${timeoutMs}ms`);
38
+ }
39
+
40
+ beforeAll(async () => {
41
+ for (const suffix of ["", "-wal", "-shm"]) {
42
+ try {
43
+ await unlink(`${TEST_DB_PATH}${suffix}`);
44
+ } catch {}
45
+ }
46
+
47
+ process.env.PAGE_SESSION_SECRET = PAGE_SECRET;
48
+
49
+ serverProc = Bun.spawn(["bun", "src/http.ts"], {
50
+ cwd: `${import.meta.dir}/../..`,
51
+ env: {
52
+ ...process.env,
53
+ PORT: String(TEST_PORT),
54
+ DATABASE_PATH: TEST_DB_PATH,
55
+ API_KEY,
56
+ PAGE_SESSION_SECRET: PAGE_SECRET,
57
+ MCP_BASE_URL: `http://127.0.0.1:${TEST_PORT}`,
58
+ CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory",
59
+ SLACK_BOT_TOKEN: "",
60
+ GITHUB_WEBHOOK_SECRET: "",
61
+ AGENTMAIL_API_KEY: "",
62
+ },
63
+ stdout: "ignore",
64
+ stderr: "ignore",
65
+ });
66
+ await waitForServer(`${BASE}/health`);
67
+
68
+ const reg = await fetch(`${BASE}/api/agents`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ Authorization: `Bearer ${API_KEY}`,
73
+ "X-Agent-ID": agentId,
74
+ },
75
+ body: JSON.stringify({
76
+ name: "AuthedPageOwner",
77
+ isLead: false,
78
+ description: "Owner of the authed test page",
79
+ role: "worker",
80
+ capabilities: ["core"],
81
+ maxTasks: 1,
82
+ }),
83
+ });
84
+ if (reg.status !== 201 && reg.status !== 200) {
85
+ throw new Error(`Failed to register agent: ${reg.status} ${await reg.text()}`);
86
+ }
87
+ }, 20000);
88
+
89
+ afterAll(async () => {
90
+ if (serverProc) {
91
+ serverProc.kill();
92
+ try {
93
+ await serverProc.exited;
94
+ } catch {}
95
+ }
96
+ await Bun.sleep(50);
97
+ for (const suffix of ["", "-wal", "-shm"]) {
98
+ try {
99
+ await unlink(`${TEST_DB_PATH}${suffix}`);
100
+ } catch {}
101
+ }
102
+ });
103
+
104
+ describe("/@swarm/api/* proxy — authed-mode page", () => {
105
+ test("authed page: launch → cookie → proxy /agents/:id resolves to page owner", async () => {
106
+ // Create an authed HTML page owned by `agentId`.
107
+ const createRes = await fetch(`${BASE}/api/pages`, {
108
+ method: "POST",
109
+ headers: {
110
+ "Content-Type": "application/json",
111
+ Authorization: `Bearer ${API_KEY}`,
112
+ "X-Agent-ID": agentId,
113
+ },
114
+ body: JSON.stringify({
115
+ slug: `authed-${randomUUID().slice(0, 8)}`,
116
+ title: "Authed Proxy Test",
117
+ contentType: "text/html",
118
+ authMode: "authed",
119
+ body: "<h1>authed</h1>",
120
+ }),
121
+ });
122
+ expect(createRes.status).toBe(201);
123
+ const { id } = (await createRes.json()) as { id: string };
124
+
125
+ // Launch.
126
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
127
+ method: "POST",
128
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
129
+ });
130
+ expect(launch.status).toBe(204);
131
+ const setCookie = launch.headers.get("set-cookie");
132
+ expect(setCookie).toBeTruthy();
133
+ const cookieValue = /page_session=([^;]+)/.exec(setCookie!)?.[1];
134
+ expect(cookieValue).toBeTruthy();
135
+
136
+ // Drive the proxy with the cookie — should resolve to the page owner's
137
+ // /agents/:id record (not a 401, not a different identity).
138
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
139
+ headers: { Cookie: `page_session=${cookieValue}` },
140
+ });
141
+ expect(res.status).toBe(200);
142
+ const agent = (await res.json()) as { id: string; name: string };
143
+ expect(agent.id).toBe(agentId);
144
+ expect(agent.name).toBe("AuthedPageOwner");
145
+ });
146
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Integration tests for the page-session cookie flow:
3
+ * 1. Create a page (bearer-auth) → POST /api/pages
4
+ * 2. Launch it → POST /api/pages/:id/launch → captures Set-Cookie
5
+ * 3. Hit /@swarm/api/me with the cookie → server-side bearer is injected,
6
+ * X-Agent-ID is rewritten to the page owner's id → 200 with /me payload.
7
+ *
8
+ * Spawns the real `src/http.ts` server with API_KEY set so we exercise the
9
+ * full bearer + cookie + proxy chain, not the in-process handler in
10
+ * isolation.
11
+ */
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { randomUUID } from "node:crypto";
14
+ import { unlink } from "node:fs/promises";
15
+ import type { Subprocess } from "bun";
16
+ import { signPageSession } from "../utils/page-session";
17
+
18
+ const TEST_PORT = 19877;
19
+ const TEST_DB_PATH = `/tmp/test-page-proxy-${Date.now()}.sqlite`;
20
+ const BASE = `http://localhost:${TEST_PORT}`;
21
+ const API_KEY = "test-page-proxy-key-12345";
22
+ const PAGE_SECRET = "test-page-proxy-page-secret-67890";
23
+
24
+ let serverProc: Subprocess;
25
+ const agentId = randomUUID();
26
+
27
+ async function waitForServer(url: string, timeoutMs = 15000) {
28
+ const start = Date.now();
29
+ while (Date.now() - start < timeoutMs) {
30
+ try {
31
+ const r = await fetch(url);
32
+ if (r.ok) return;
33
+ } catch {}
34
+ await Bun.sleep(50);
35
+ }
36
+ throw new Error(`Server did not start within ${timeoutMs}ms`);
37
+ }
38
+
39
+ beforeAll(async () => {
40
+ for (const suffix of ["", "-wal", "-shm"]) {
41
+ try {
42
+ await unlink(`${TEST_DB_PATH}${suffix}`);
43
+ } catch {}
44
+ }
45
+
46
+ // Match the spawned server's signing secret so cookies we hand-craft via
47
+ // signPageSession() in-process validate at the proxy.
48
+ process.env.PAGE_SESSION_SECRET = PAGE_SECRET;
49
+
50
+ serverProc = Bun.spawn(["bun", "src/http.ts"], {
51
+ cwd: `${import.meta.dir}/../..`,
52
+ env: {
53
+ ...process.env,
54
+ PORT: String(TEST_PORT),
55
+ DATABASE_PATH: TEST_DB_PATH,
56
+ API_KEY,
57
+ PAGE_SESSION_SECRET: PAGE_SECRET,
58
+ // Pin the upstream URL the proxy forwards to. Even though the proxy now
59
+ // talks to 127.0.0.1:$PORT directly (not deriveApiBaseUrl), strip any
60
+ // ambient ngrok/external MCP_BASE_URL to keep the test env minimal.
61
+ MCP_BASE_URL: `http://127.0.0.1:${TEST_PORT}`,
62
+ CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory",
63
+ SLACK_BOT_TOKEN: "",
64
+ GITHUB_WEBHOOK_SECRET: "",
65
+ AGENTMAIL_API_KEY: "",
66
+ },
67
+ stdout: "ignore",
68
+ stderr: "ignore",
69
+ });
70
+ await waitForServer(`${BASE}/health`);
71
+
72
+ // Register the page-owner agent (so /me succeeds after the proxy rewrites
73
+ // X-Agent-ID to this id).
74
+ const reg = await fetch(`${BASE}/api/agents`, {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ Authorization: `Bearer ${API_KEY}`,
79
+ "X-Agent-ID": agentId,
80
+ },
81
+ body: JSON.stringify({
82
+ name: "PageOwner",
83
+ isLead: false,
84
+ description: "Owner of the test page",
85
+ role: "worker",
86
+ capabilities: ["core"],
87
+ maxTasks: 1,
88
+ }),
89
+ });
90
+ if (reg.status !== 201 && reg.status !== 200) {
91
+ throw new Error(`Failed to register agent: ${reg.status} ${await reg.text()}`);
92
+ }
93
+ }, 20000);
94
+
95
+ afterAll(async () => {
96
+ if (serverProc) {
97
+ serverProc.kill();
98
+ try {
99
+ await serverProc.exited;
100
+ } catch {}
101
+ }
102
+ await Bun.sleep(50);
103
+ for (const suffix of ["", "-wal", "-shm"]) {
104
+ try {
105
+ await unlink(`${TEST_DB_PATH}${suffix}`);
106
+ } catch {}
107
+ }
108
+ });
109
+
110
+ /** Helper: create a page owned by `agentId` and return its id. */
111
+ async function createPage(): Promise<string> {
112
+ const res = await fetch(`${BASE}/api/pages`, {
113
+ method: "POST",
114
+ headers: {
115
+ "Content-Type": "application/json",
116
+ Authorization: `Bearer ${API_KEY}`,
117
+ "X-Agent-ID": agentId,
118
+ },
119
+ body: JSON.stringify({
120
+ slug: `t-${randomUUID().slice(0, 8)}`,
121
+ title: "Proxy Test",
122
+ contentType: "text/html",
123
+ authMode: "public",
124
+ body: "<h1>proxy test</h1>",
125
+ }),
126
+ });
127
+ expect(res.status).toBe(201);
128
+ const json = (await res.json()) as { id: string };
129
+ return json.id;
130
+ }
131
+
132
+ describe("/api/pages/:id/launch", () => {
133
+ test("issues HttpOnly Set-Cookie + 204", async () => {
134
+ const id = await createPage();
135
+ const res = await fetch(`${BASE}/api/pages/${id}/launch`, {
136
+ method: "POST",
137
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
138
+ });
139
+ expect(res.status).toBe(204);
140
+ const cookie = res.headers.get("set-cookie");
141
+ expect(cookie).toBeTruthy();
142
+ expect(cookie!).toContain("page_session=");
143
+ expect(cookie!).toContain("HttpOnly");
144
+ expect(cookie!).toContain("Path=/");
145
+ expect(cookie!).toContain("Max-Age=3600");
146
+ // In dev (NODE_ENV != production) the cookie should be SameSite=Lax sans Secure.
147
+ expect(cookie!).toContain("SameSite=Lax");
148
+ expect(cookie!).not.toMatch(/\bSecure\b/);
149
+ });
150
+
151
+ test("404 for unknown page id", async () => {
152
+ const res = await fetch(`${BASE}/api/pages/${"0".repeat(32)}/launch`, {
153
+ method: "POST",
154
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
155
+ });
156
+ expect(res.status).toBe(404);
157
+ });
158
+
159
+ test("401 without bearer", async () => {
160
+ const id = await createPage();
161
+ const res = await fetch(`${BASE}/api/pages/${id}/launch`, {
162
+ method: "POST",
163
+ });
164
+ expect(res.status).toBe(401);
165
+ });
166
+
167
+ test("OPTIONS preflight returns 204 with CORS headers when Origin set", async () => {
168
+ const id = await createPage();
169
+ const res = await fetch(`${BASE}/api/pages/${id}/launch`, {
170
+ method: "OPTIONS",
171
+ headers: { Origin: "http://localhost:5274" },
172
+ });
173
+ // /core's OPTIONS handler returns 204 first — but our route-specific
174
+ // OPTIONS handler in handlePages sets CORS headers. Either way the
175
+ // browser sees 204; verify the response is 204.
176
+ expect(res.status).toBe(204);
177
+ });
178
+ });
179
+
180
+ describe("/@swarm/api/* proxy", () => {
181
+ // The proxy rewrites `/@swarm/api/<rest>` → `/api/<rest>`. We use
182
+ // `/api/agents/<id>` as the canonical exerciser since it requires both
183
+ // bearer auth AND a valid agent id — proving the proxy injected both.
184
+ test("forwards GET /@swarm/api/agents/:id with cookie → 200 carrying page-owner agent", async () => {
185
+ const id = await createPage();
186
+ const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
187
+ method: "POST",
188
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
189
+ });
190
+ expect(launch.status).toBe(204);
191
+ const setCookie = launch.headers.get("set-cookie");
192
+ expect(setCookie).toBeTruthy();
193
+
194
+ const cookieValue = /page_session=([^;]+)/.exec(setCookie!)?.[1];
195
+ expect(cookieValue).toBeTruthy();
196
+
197
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
198
+ headers: { Cookie: `page_session=${cookieValue}` },
199
+ });
200
+ expect(res.status).toBe(200);
201
+ const agent = (await res.json()) as { id: string; name: string };
202
+ expect(agent.id).toBe(agentId);
203
+ expect(agent.name).toBe("PageOwner");
204
+ });
205
+
206
+ test("rejects request without cookie → 401", async () => {
207
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`);
208
+ expect(res.status).toBe(401);
209
+ const body = (await res.json()) as { error: string };
210
+ expect(body.error).toBe("no page session");
211
+ });
212
+
213
+ test("rejects expired cookie → 401", async () => {
214
+ const expired = await signPageSession({
215
+ pageId: "deadbeef".repeat(4),
216
+ exp: Math.floor(Date.now() / 1000) - 60,
217
+ });
218
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
219
+ headers: { Cookie: `page_session=${expired}` },
220
+ });
221
+ expect(res.status).toBe(401);
222
+ });
223
+
224
+ test("rejects tampered signature → 401", async () => {
225
+ const id = await createPage();
226
+ const exp = Math.floor(Date.now() / 1000) + 3600;
227
+ const good = await signPageSession({ pageId: id, exp });
228
+ const [head, sig] = good.split(".");
229
+ const tamperedSig = `${sig!.slice(0, -1)}${sig!.slice(-1) === "A" ? "B" : "A"}`;
230
+ const bad = `${head}.${tamperedSig}`;
231
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
232
+ headers: { Cookie: `page_session=${bad}` },
233
+ });
234
+ expect(res.status).toBe(401);
235
+ });
236
+
237
+ test("rejects cookie for deleted page → 401", async () => {
238
+ // Sign a cookie referencing a never-existed page id. verifyPageSession
239
+ // returns the payload, getPage returns null → 401 "page session no
240
+ // longer valid". (Step-3 will ship DELETE; this test just exercises the
241
+ // proxy's missing-page branch without depending on it.)
242
+ const ghost = await signPageSession({
243
+ pageId: "fade".repeat(8),
244
+ exp: Math.floor(Date.now() / 1000) + 3600,
245
+ });
246
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
247
+ headers: { Cookie: `page_session=${ghost}` },
248
+ });
249
+ expect(res.status).toBe(401);
250
+ const body = (await res.json()) as { error: string };
251
+ expect(body.error).toBe("page session no longer valid");
252
+ });
253
+
254
+ test("proxy does NOT require a bearer header (cookie is the auth)", async () => {
255
+ const id = await createPage();
256
+ const exp = Math.floor(Date.now() / 1000) + 3600;
257
+ const token = await signPageSession({ pageId: id, exp });
258
+ // Send WITHOUT Authorization header — pure cookie auth.
259
+ const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
260
+ headers: { Cookie: `page_session=${token}` },
261
+ });
262
+ expect(res.status).toBe(200);
263
+ const agent = (await res.json()) as { id: string };
264
+ expect(agent.id).toBe(agentId);
265
+ });
266
+ });