@desplega.ai/agent-swarm 1.89.0 → 1.91.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 (63) hide show
  1. package/README.md +4 -0
  2. package/openapi.json +74 -1
  3. package/package.json +6 -6
  4. package/plugin/skills/composio/SKILL.md +138 -63
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/artifact-sdk/server.ts +2 -1
  9. package/src/be/db.ts +28 -0
  10. package/src/be/memory/providers/sqlite-store.ts +6 -1
  11. package/src/be/memory/types.ts +1 -0
  12. package/src/be/modelsdev-cache.json +752 -81
  13. package/src/be/scripts/typecheck.ts +132 -1
  14. package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
  15. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  16. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  17. package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
  18. package/src/be/seed-scripts/index.ts +36 -0
  19. package/src/commands/artifact.ts +3 -2
  20. package/src/commands/profile-sync.ts +310 -0
  21. package/src/commands/runner.ts +91 -1
  22. package/src/heartbeat/heartbeat.ts +54 -7
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/index.ts +47 -0
  25. package/src/http/integrations.ts +6 -1
  26. package/src/http/mcp-bridge.ts +117 -0
  27. package/src/http/mcp-oauth.ts +97 -39
  28. package/src/http/memory.ts +5 -2
  29. package/src/http/openapi.ts +2 -2
  30. package/src/http/pages-public.ts +10 -11
  31. package/src/http/pages.ts +7 -11
  32. package/src/http/scripts.ts +24 -1
  33. package/src/http/tasks.ts +2 -0
  34. package/src/http/utils.ts +11 -4
  35. package/src/jira/app.ts +2 -3
  36. package/src/jira/webhook-lifecycle.ts +2 -1
  37. package/src/linear/app.ts +2 -3
  38. package/src/providers/claude-adapter.ts +26 -0
  39. package/src/scripts-runtime/executors/native.ts +1 -0
  40. package/src/scripts-runtime/sdk-allowlist.ts +121 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  42. package/src/scripts-runtime/types/stdlib.d.ts +227 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
  44. package/src/tasks/worker-follow-up.ts +19 -1
  45. package/src/tests/claude-adapter-otel.test.ts +85 -1
  46. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  47. package/src/tests/hook-registration-nudge.test.ts +69 -0
  48. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  49. package/src/tests/pages-public-html.test.ts +41 -0
  50. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  51. package/src/tests/profile-sync.test.ts +282 -0
  52. package/src/tests/scripts-runtime.test.ts +33 -0
  53. package/src/tests/seed-scripts.test.ts +2 -2
  54. package/src/tools/create-metric.ts +2 -3
  55. package/src/tools/create-page.ts +3 -6
  56. package/src/tools/memory-rate.ts +2 -1
  57. package/src/tools/memory-search.ts +1 -0
  58. package/src/tools/register-kapso-number.ts +2 -4
  59. package/src/tools/request-human-input.ts +2 -1
  60. package/src/tools/script-common.ts +2 -4
  61. package/src/tools/script-run.ts +7 -0
  62. package/src/utils/constants.ts +58 -8
  63. package/templates/skills/swarm-scripts/content.md +46 -7
@@ -10,16 +10,29 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
10
10
  import { unlink } from "node:fs/promises";
11
11
  import {
12
12
  closeDb,
13
+ completeTask,
13
14
  createAgent,
14
15
  createTaskExtended,
15
16
  getChildTasks,
16
17
  getDb,
18
+ getLogsByTaskId,
17
19
  getTaskById,
18
20
  initDb,
19
21
  insertActiveSession,
20
22
  startTask,
21
23
  } from "../be/db";
22
- import { codeLevelTriage } from "../heartbeat/heartbeat";
24
+ import {
25
+ createTrackerSync,
26
+ getTrackerSync,
27
+ getTrackerSyncByExternalId,
28
+ } from "../be/db-queries/tracker";
29
+ import {
30
+ codeLevelTriage,
31
+ MAX_RESUME_GENERATIONS,
32
+ RESUME_BUDGET_EXHAUSTED_REASON,
33
+ setBeforeHeartbeatSupersedeForTests,
34
+ } from "../heartbeat/heartbeat";
35
+ import { RESUME_GENERATION_TAG_PREFIX } from "../tasks/worker-follow-up";
23
36
 
24
37
  const TEST_DB_PATH = "./test-heartbeat-supersede-resume.sqlite";
25
38
 
@@ -46,6 +59,8 @@ describe("Heartbeat — supersede + resume (DES-523)", () => {
46
59
  });
47
60
 
48
61
  beforeEach(() => {
62
+ setBeforeHeartbeatSupersedeForTests(null);
63
+ getDb().run("DELETE FROM tracker_sync");
49
64
  getDb().run("DELETE FROM agent_tasks");
50
65
  getDb().run("DELETE FROM agents");
51
66
  getDb().run("DELETE FROM active_sessions");
@@ -81,7 +96,82 @@ describe("Heartbeat — supersede + resume (DES-523)", () => {
81
96
  expect(resume.taskType).toBe("resume");
82
97
  expect(resume.tags).toContain("auto-resume");
83
98
  expect(resume.tags).toContain("reason:crash_recovery");
99
+ expect(resume.tags).toContain(`${RESUME_GENERATION_TAG_PREFIX}1`);
84
100
  expect(resume.id).toBe(findings.autoResumedTasks[0]!.resumeTaskId);
101
+
102
+ const supersedeLog = getLogsByTaskId(parent.id).find(
103
+ (log) => log.eventType === "task_superseded",
104
+ );
105
+ expect(supersedeLog).toBeTruthy();
106
+ const metadata = JSON.parse(supersedeLog!.metadata ?? "{}") as { resumeTaskId?: string };
107
+ expect(metadata.resumeTaskId).toBe(resume.id);
108
+ });
109
+
110
+ test("Case A: crash-recovery resume chain stops at the generation cap", async () => {
111
+ const agent = createAgent({ name: "dead-resume-worker", isLead: false, status: "busy" });
112
+ const parent = createTaskExtended("Resume at generation cap", {
113
+ agentId: agent.id,
114
+ taskType: "resume",
115
+ tags: [
116
+ "auto-resume",
117
+ "reason:crash_recovery",
118
+ `${RESUME_GENERATION_TAG_PREFIX}${MAX_RESUME_GENERATIONS}`,
119
+ ],
120
+ });
121
+ startTask(parent.id);
122
+
123
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
124
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
125
+
126
+ const findings = await codeLevelTriage();
127
+
128
+ expect(findings.autoResumedTasks.length).toBe(0);
129
+ expect(findings.autoFailedTasks.length).toBe(1);
130
+ expect(findings.autoFailedTasks[0]!.taskId).toBe(parent.id);
131
+ expect(findings.autoFailedTasks[0]!.reason).toBe(RESUME_BUDGET_EXHAUSTED_REASON);
132
+
133
+ const updatedParent = getTaskById(parent.id);
134
+ expect(updatedParent?.status).toBe("failed");
135
+ expect(updatedParent?.failureReason).toBe(RESUME_BUDGET_EXHAUSTED_REASON);
136
+ expect(getChildTasks(parent.id).length).toBe(0);
137
+ });
138
+
139
+ test("Case A: supersede race does not create a resume child or repoint tracker_sync", async () => {
140
+ const agent = createAgent({ name: "dead-worker-race", isLead: false, status: "busy" });
141
+ const parent = createTaskExtended("Tracked parent that finishes during heartbeat", {
142
+ agentId: agent.id,
143
+ });
144
+ startTask(parent.id);
145
+
146
+ createTrackerSync({
147
+ provider: "linear",
148
+ entityType: "task",
149
+ swarmId: parent.id,
150
+ externalId: "linear-race-issue",
151
+ externalIdentifier: "ENG-637",
152
+ externalUrl: "https://linear.app/test/issue/ENG-637",
153
+ });
154
+
155
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
156
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
157
+
158
+ setBeforeHeartbeatSupersedeForTests((task) => {
159
+ expect(task.id).toBe(parent.id);
160
+ completeTask(parent.id, "finished by racing worker");
161
+ });
162
+
163
+ const findings = await codeLevelTriage();
164
+
165
+ expect(findings.autoResumedTasks.length).toBe(0);
166
+ expect(findings.autoFailedTasks.length).toBe(0);
167
+
168
+ const updatedParent = getTaskById(parent.id);
169
+ expect(updatedParent?.status).toBe("completed");
170
+ expect(getChildTasks(parent.id).length).toBe(0);
171
+
172
+ expect(getTrackerSync("linear", "task", parent.id)).not.toBeNull();
173
+ const byExternal = getTrackerSyncByExternalId("linear", "task", "linear-race-issue");
174
+ expect(byExternal?.swarmId).toBe(parent.id);
85
175
  });
86
176
 
87
177
  // --------------------------------------------------------------------------
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Registration-nudge gate tests.
3
+ *
4
+ * The "use join-swarm" nudge previously fired on EVERY hook event when
5
+ * getAgentInfo() returned null — including transient lookup failures for
6
+ * already-registered agents. This caused 123 redundant join-swarm calls
7
+ * in a 3-day window, all bouncing off "already exists".
8
+ *
9
+ * The fix: only nudge on SessionStart AND only when no X-Agent-ID header
10
+ * is present (genuinely unregistered).
11
+ */
12
+ import { describe, expect, test } from "bun:test";
13
+ import { shouldShowRegistrationNudge } from "../hooks/hook";
14
+
15
+ describe("shouldShowRegistrationNudge", () => {
16
+ test("(a) pre-assigned agent (X-Agent-ID present) + null lookup on SessionStart → NO nudge", () => {
17
+ expect(
18
+ shouldShowRegistrationNudge({
19
+ agentInfoPresent: false,
20
+ eventType: "SessionStart",
21
+ hasAgentIdHeader: true,
22
+ }),
23
+ ).toBe(false);
24
+ });
25
+
26
+ test("(b) non-SessionStart event → NO nudge regardless of other conditions", () => {
27
+ const nonStartEvents = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "Stop"];
28
+
29
+ for (const eventType of nonStartEvents) {
30
+ expect(
31
+ shouldShowRegistrationNudge({
32
+ agentInfoPresent: false,
33
+ eventType,
34
+ hasAgentIdHeader: false,
35
+ }),
36
+ ).toBe(false);
37
+ }
38
+ });
39
+
40
+ test("(c) genuinely unregistered (no X-Agent-ID) on SessionStart → nudge present", () => {
41
+ expect(
42
+ shouldShowRegistrationNudge({
43
+ agentInfoPresent: false,
44
+ eventType: "SessionStart",
45
+ hasAgentIdHeader: false,
46
+ }),
47
+ ).toBe(true);
48
+ });
49
+
50
+ test("registered agent (agentInfo present) never gets nudged", () => {
51
+ expect(
52
+ shouldShowRegistrationNudge({
53
+ agentInfoPresent: true,
54
+ eventType: "SessionStart",
55
+ hasAgentIdHeader: false,
56
+ }),
57
+ ).toBe(false);
58
+ });
59
+
60
+ test("pre-assigned agent on non-SessionStart event → NO nudge", () => {
61
+ expect(
62
+ shouldShowRegistrationNudge({
63
+ agentInfoPresent: false,
64
+ eventType: "UserPromptSubmit",
65
+ hasAgentIdHeader: true,
66
+ }),
67
+ ).toBe(false);
68
+ });
69
+ });
@@ -0,0 +1,213 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { closeDb, createMcpServer, initDb } from "../be/db";
6
+ import { getMcpOAuthToken } from "../be/db-queries/mcp-oauth";
7
+ import { handleCore } from "../http/core";
8
+ import { handleMcpOAuth } from "../http/mcp-oauth";
9
+ import { getPathSegments, parseQueryParams } from "../http/utils";
10
+
11
+ const API_KEY = "test-secret-key";
12
+ const TEST_DB_PATH = "./test-mcp-oauth-manual-client.sqlite";
13
+
14
+ async function removeDbFiles(): Promise<void> {
15
+ for (const suffix of ["", "-wal", "-shm"]) {
16
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
17
+ }
18
+ }
19
+
20
+ type TestResponse = {
21
+ status: number;
22
+ text: string;
23
+ headers: Record<string, string>;
24
+ json: () => Promise<unknown>;
25
+ };
26
+
27
+ async function dispatch(path: string, init: RequestInit = {}): Promise<TestResponse> {
28
+ const headers: Record<string, string> = {
29
+ ...((init.headers as Record<string, string>) ?? {}),
30
+ };
31
+ if (init.body !== undefined && !headers["Content-Type"])
32
+ headers["Content-Type"] = "application/json";
33
+
34
+ const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
35
+ req.method = init.method ?? "GET";
36
+ req.url = path;
37
+ req.headers = Object.fromEntries(
38
+ Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
39
+ );
40
+
41
+ let status = 200;
42
+ let text = "";
43
+ const responseHeaders: Record<string, string> = {};
44
+ const res = {
45
+ headersSent: false,
46
+ writableEnded: false,
47
+ setHeader(name: string, value: number | string | readonly string[]) {
48
+ responseHeaders[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value);
49
+ return this;
50
+ },
51
+ writeHead(code: number, headersArg?: Record<string, number | string | readonly string[]>) {
52
+ status = code;
53
+ if (headersArg) {
54
+ for (const [key, value] of Object.entries(headersArg)) {
55
+ responseHeaders[key.toLowerCase()] = Array.isArray(value)
56
+ ? value.join(", ")
57
+ : String(value);
58
+ }
59
+ }
60
+ this.headersSent = true;
61
+ return this;
62
+ },
63
+ end(chunk?: unknown) {
64
+ if (chunk !== undefined) text += String(chunk);
65
+ this.writableEnded = true;
66
+ return this;
67
+ },
68
+ } as unknown as ServerResponse;
69
+
70
+ const handledCore = await handleCore(req, res, undefined, API_KEY);
71
+ if (!handledCore) {
72
+ const pathSegments = getPathSegments(req.url || "");
73
+ const queryParams = parseQueryParams(req.url || "");
74
+ const handled = await handleMcpOAuth(req, res, pathSegments, queryParams);
75
+ if (!handled) {
76
+ res.writeHead(404, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({ error: "Not found" }));
78
+ }
79
+ }
80
+
81
+ return {
82
+ status,
83
+ text,
84
+ headers: responseHeaders,
85
+ json: async () => JSON.parse(text),
86
+ };
87
+ }
88
+
89
+ describe("MCP OAuth manual client flow", () => {
90
+ let originalFetch: typeof fetch;
91
+ let capturedTokenBody: string | null;
92
+ let originalPublicMcpBaseUrl: string | undefined;
93
+ let originalAppUrl: string | undefined;
94
+ let originalDashboardUrl: string | undefined;
95
+
96
+ beforeEach(async () => {
97
+ originalFetch = globalThis.fetch;
98
+ capturedTokenBody = null;
99
+ originalPublicMcpBaseUrl = process.env.PUBLIC_MCP_BASE_URL;
100
+ originalAppUrl = process.env.APP_URL;
101
+ originalDashboardUrl = process.env.DASHBOARD_URL;
102
+ process.env.SECRETS_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
103
+
104
+ await removeDbFiles();
105
+ initDb(TEST_DB_PATH);
106
+ process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.test";
107
+ process.env.APP_URL = "https://dashboard.example.test";
108
+ delete process.env.DASHBOARD_URL;
109
+
110
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
111
+ const href = input.toString();
112
+ if (href === "https://login.salesforce.com/services/oauth2/token") {
113
+ capturedTokenBody = init?.body?.toString() ?? null;
114
+ return new Response(
115
+ JSON.stringify({
116
+ access_token: "sf-access-token",
117
+ token_type: "Bearer",
118
+ expires_in: 3600,
119
+ refresh_token: "sf-refresh-token",
120
+ scope: "mcp_api refresh_token",
121
+ }),
122
+ { status: 200, headers: { "Content-Type": "application/json" } },
123
+ );
124
+ }
125
+ return new Response("not found", { status: 404 });
126
+ }) as typeof fetch;
127
+ });
128
+
129
+ afterEach(async () => {
130
+ globalThis.fetch = originalFetch;
131
+ closeDb();
132
+ await removeDbFiles();
133
+ if (originalPublicMcpBaseUrl === undefined) delete process.env.PUBLIC_MCP_BASE_URL;
134
+ else process.env.PUBLIC_MCP_BASE_URL = originalPublicMcpBaseUrl;
135
+ if (originalAppUrl === undefined) delete process.env.APP_URL;
136
+ else process.env.APP_URL = originalAppUrl;
137
+ if (originalDashboardUrl === undefined) delete process.env.DASHBOARD_URL;
138
+ else process.env.DASHBOARD_URL = originalDashboardUrl;
139
+ });
140
+
141
+ test("authorize-url uses a stored manual client when DCR is not available", async () => {
142
+ const mcpServer = createMcpServer({
143
+ name: "salesforce-sobjects",
144
+ transport: "http",
145
+ url: "https://api.salesforce.com/platform/mcp/v1/platform/sobject-all",
146
+ scope: "swarm",
147
+ });
148
+
149
+ const manualRes = await dispatch(`/api/mcp-oauth/${mcpServer.id}/manual-client`, {
150
+ method: "POST",
151
+ headers: {
152
+ Authorization: `Bearer ${API_KEY}`,
153
+ },
154
+ body: JSON.stringify({
155
+ clientId: "sf-client-id",
156
+ clientSecret: "sf-client-secret",
157
+ authorizationServerIssuer: "https://login.salesforce.com",
158
+ authorizeUrl: "https://login.salesforce.com/services/oauth2/authorize",
159
+ tokenUrl: "https://login.salesforce.com/services/oauth2/token",
160
+ scopes: ["mcp_api", "refresh_token"],
161
+ }),
162
+ });
163
+ expect(manualRes.status).toBe(200);
164
+
165
+ const provisionalToken = getMcpOAuthToken(mcpServer.id);
166
+ expect(provisionalToken?.clientSource).toBe("manual");
167
+ expect(provisionalToken?.status).toBe("error");
168
+
169
+ const authorizeRes = await dispatch(`/api/mcp-oauth/${mcpServer.id}/authorize-url`, {
170
+ headers: { Authorization: `Bearer ${API_KEY}` },
171
+ });
172
+ expect(authorizeRes.status).toBe(200);
173
+ const { providerUrl } = (await authorizeRes.json()) as { providerUrl: string };
174
+ const provider = new URL(providerUrl);
175
+
176
+ expect(provider.origin + provider.pathname).toBe(
177
+ "https://login.salesforce.com/services/oauth2/authorize",
178
+ );
179
+ expect(provider.searchParams.get("client_id")).toBe("sf-client-id");
180
+ expect(provider.searchParams.get("scope")).toBe("mcp_api refresh_token");
181
+ expect(provider.searchParams.get("resource")).toBe(mcpServer.url);
182
+ expect(provider.searchParams.get("redirect_uri")).toBe(
183
+ "https://swarm.example.test/api/mcp-oauth/callback",
184
+ );
185
+
186
+ const state = provider.searchParams.get("state");
187
+ expect(state).toBeTruthy();
188
+
189
+ const callbackRes = await dispatch(
190
+ `/api/mcp-oauth/callback?state=${encodeURIComponent(state!)}&code=sf-auth-code`,
191
+ );
192
+ expect(callbackRes.status).toBe(302);
193
+ expect(callbackRes.headers.location).toBe(
194
+ `${process.env.APP_URL}/mcp-servers/${mcpServer.id}?oauth=success`,
195
+ );
196
+
197
+ const tokenRequest = new URLSearchParams(capturedTokenBody ?? "");
198
+ expect(tokenRequest.get("client_id")).toBe("sf-client-id");
199
+ expect(tokenRequest.get("client_secret")).toBe("sf-client-secret");
200
+ expect(tokenRequest.get("resource")).toBe(mcpServer.url);
201
+ expect(tokenRequest.get("redirect_uri")).toBe(
202
+ "https://swarm.example.test/api/mcp-oauth/callback",
203
+ );
204
+
205
+ const connectedToken = getMcpOAuthToken(mcpServer.id);
206
+ expect(connectedToken?.clientSource).toBe("manual");
207
+ expect(connectedToken?.status).toBe("connected");
208
+ expect(connectedToken?.accessToken).toBe("sf-access-token");
209
+ expect(connectedToken?.refreshToken).toBe("sf-refresh-token");
210
+ expect(connectedToken?.dcrClientId).toBe("sf-client-id");
211
+ expect(connectedToken?.dcrClientSecret).toBe("sf-client-secret");
212
+ });
213
+ });
@@ -41,6 +41,8 @@ describe("GET /p/:id — HTML public path", () => {
41
41
  let server: Server;
42
42
  const agentId = crypto.randomUUID();
43
43
  const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
44
+ const ORIG_APP = process.env.APP_URL;
45
+ const ORIG_DASHBOARD = process.env.DASHBOARD_URL;
44
46
 
45
47
  beforeAll(async () => {
46
48
  for (const suffix of ["", "-wal", "-shm"]) {
@@ -56,6 +58,10 @@ describe("GET /p/:id — HTML public path", () => {
56
58
  afterAll(async () => {
57
59
  await new Promise<void>((resolve) => server.close(() => resolve()));
58
60
  closeDb();
61
+ if (ORIG_APP === undefined) delete process.env.APP_URL;
62
+ else process.env.APP_URL = ORIG_APP;
63
+ if (ORIG_DASHBOARD === undefined) delete process.env.DASHBOARD_URL;
64
+ else process.env.DASHBOARD_URL = ORIG_DASHBOARD;
59
65
  for (const suffix of ["", "-wal", "-shm"]) {
60
66
  try {
61
67
  await unlink(`${TEST_DB_PATH}${suffix}`);
@@ -98,6 +104,41 @@ describe("GET /p/:id — HTML public path", () => {
98
104
  expect(text).toContain("class SwarmSDK"); // BROWSER_SDK_JS sentinel
99
105
  });
100
106
 
107
+ test("CSP frame ancestors include deprecated DASHBOARD_URL alias", async () => {
108
+ const prevApp = process.env.APP_URL;
109
+ const prevDashboard = process.env.DASHBOARD_URL;
110
+ delete process.env.APP_URL;
111
+ process.env.DASHBOARD_URL = "https://dashboard.example.test/";
112
+ try {
113
+ const html = "<!doctype html><html><head><title>CSP</title></head><body></body></html>";
114
+ const post = await fetch(`${BASE}/api/pages`, {
115
+ method: "POST",
116
+ headers,
117
+ body: JSON.stringify({
118
+ slug: "dashboard-url-csp",
119
+ title: "Dashboard URL CSP",
120
+ contentType: "text/html",
121
+ authMode: "public",
122
+ body: html,
123
+ }),
124
+ });
125
+ expect(post.status).toBe(201);
126
+ const { id } = (await post.json()) as { id: string };
127
+
128
+ const res = await fetch(`${BASE}/p/${id}`);
129
+ expect(res.status).toBe(200);
130
+ const csp = res.headers.get("content-security-policy");
131
+ const frameAncestors =
132
+ csp?.split(";").find((d) => d.trim().startsWith("frame-ancestors ")) ?? "";
133
+ expect(frameAncestors).toContain("https://dashboard.example.test");
134
+ } finally {
135
+ if (prevApp === undefined) delete process.env.APP_URL;
136
+ else process.env.APP_URL = prevApp;
137
+ if (prevDashboard === undefined) delete process.env.DASHBOARD_URL;
138
+ else process.env.DASHBOARD_URL = prevDashboard;
139
+ }
140
+ });
141
+
101
142
  test("public JSON page 302-redirects to SPA artifact route", async () => {
102
143
  const post = await fetch(`${BASE}/api/pages`, {
103
144
  method: "POST",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Public `/p/:id` for JSON content. JSON pages do NOT render at the API —
3
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`.
4
+ * responds with a 302 to the configured app URL's `/pages/:id`.
5
5
  */
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import crypto from "node:crypto";
@@ -38,6 +38,7 @@ describe("GET /p/:id — JSON page redirect", () => {
38
38
  const agentId = crypto.randomUUID();
39
39
  const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
40
40
  const ORIG_APP = process.env.APP_URL;
41
+ const ORIG_DASHBOARD = process.env.DASHBOARD_URL;
41
42
 
42
43
  beforeAll(async () => {
43
44
  for (const suffix of ["", "-wal", "-shm"]) {
@@ -48,6 +49,7 @@ describe("GET /p/:id — JSON page redirect", () => {
48
49
  initDb(TEST_DB_PATH);
49
50
  // Pin APP_URL so the redirect is deterministic across hosts.
50
51
  process.env.APP_URL = "http://localhost:5274";
52
+ delete process.env.DASHBOARD_URL;
51
53
  server = createTestServer();
52
54
  await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
53
55
  });
@@ -57,6 +59,8 @@ describe("GET /p/:id — JSON page redirect", () => {
57
59
  closeDb();
58
60
  if (ORIG_APP === undefined) delete process.env.APP_URL;
59
61
  else process.env.APP_URL = ORIG_APP;
62
+ if (ORIG_DASHBOARD === undefined) delete process.env.DASHBOARD_URL;
63
+ else process.env.DASHBOARD_URL = ORIG_DASHBOARD;
60
64
  for (const suffix of ["", "-wal", "-shm"]) {
61
65
  try {
62
66
  await unlink(`${TEST_DB_PATH}${suffix}`);
@@ -64,7 +68,7 @@ describe("GET /p/:id — JSON page redirect", () => {
64
68
  }
65
69
  });
66
70
 
67
- test("JSON content redirects to ${APP_URL}/pages/:id", async () => {
71
+ test("JSON content redirects to configured app URL pages route", async () => {
68
72
  const post = await fetch(`${BASE}/api/pages`, {
69
73
  method: "POST",
70
74
  headers,
@@ -83,4 +87,35 @@ describe("GET /p/:id — JSON page redirect", () => {
83
87
  expect(res.status).toBe(302);
84
88
  expect(res.headers.get("location")).toBe(`http://localhost:5274/pages/${id}`);
85
89
  });
90
+
91
+ test("JSON content falls back to local SPA when app URL envs are unset", async () => {
92
+ const prevApp = process.env.APP_URL;
93
+ const prevDashboard = process.env.DASHBOARD_URL;
94
+ process.env.APP_URL = "";
95
+ delete process.env.DASHBOARD_URL;
96
+ try {
97
+ const post = await fetch(`${BASE}/api/pages`, {
98
+ method: "POST",
99
+ headers,
100
+ body: JSON.stringify({
101
+ slug: "redir-local-fallback",
102
+ title: "Redirect Locally",
103
+ contentType: "application/json",
104
+ authMode: "public",
105
+ body: JSON.stringify({ kind: "spec", nodes: [] }),
106
+ }),
107
+ });
108
+ expect(post.status).toBe(201);
109
+ const { id } = (await post.json()) as { id: string };
110
+
111
+ const res = await fetch(`${BASE}/p/${id}`, { redirect: "manual" });
112
+ expect(res.status).toBe(302);
113
+ expect(res.headers.get("location")).toBe(`http://localhost:5274/pages/${id}`);
114
+ } finally {
115
+ if (prevApp === undefined) delete process.env.APP_URL;
116
+ else process.env.APP_URL = prevApp;
117
+ if (prevDashboard === undefined) delete process.env.DASHBOARD_URL;
118
+ else process.env.DASHBOARD_URL = prevDashboard;
119
+ }
120
+ });
86
121
  });