@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,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-facing page routes — `/p/:id` and `/p/:id.json`.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `src/http/pages.ts` (bearer-authed REST) — these are the
|
|
5
|
+
* surfaces an end-user's browser actually hits. Both routes are declared
|
|
6
|
+
* with `auth: { apiKey: false }` so the global bearer gate skips them.
|
|
7
|
+
*
|
|
8
|
+
* Scope of THIS module (step-3):
|
|
9
|
+
* - `auth_mode === 'public'`: ungated. HTML responses inline-inject the
|
|
10
|
+
* `BROWSER_SDK_JS` constant from `src/artifact-sdk/browser-sdk.ts` (reused
|
|
11
|
+
* verbatim — no token-injection hook on the client). JSON responses
|
|
12
|
+
* 302-redirect to the SPA `/pages/:id` route (the JSON renderer lives
|
|
13
|
+
* in the SPA, not the API — step-6/7).
|
|
14
|
+
* - `auth_mode === 'authed'`: returns 401. step-4 narrows this to also
|
|
15
|
+
* accept a valid `page_session` cookie.
|
|
16
|
+
* - `auth_mode === 'password'`: returns 401. step-5 narrows this to also
|
|
17
|
+
* accept `?key=` query param + HTTP Basic.
|
|
18
|
+
*
|
|
19
|
+
* No request/response body is ever scrubbed in the served stream — page
|
|
20
|
+
* bodies are agent-authored content and pass through verbatim. Logging
|
|
21
|
+
* paths (errors only) DO scrub via `scrubSecrets`.
|
|
22
|
+
*/
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { BROWSER_SDK_JS } from "../artifact-sdk/browser-sdk";
|
|
26
|
+
import { getPage } from "../be/db";
|
|
27
|
+
import type { Page } from "../types";
|
|
28
|
+
import { extractAndVerifyCookie, issuePageSessionCookie } from "../utils/page-session";
|
|
29
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
30
|
+
import { route } from "./route-def";
|
|
31
|
+
|
|
32
|
+
// ─── Route definitions (registered with auth: { apiKey: false }) ────────────
|
|
33
|
+
|
|
34
|
+
const publicPageRoute = route({
|
|
35
|
+
method: "get",
|
|
36
|
+
path: "/p/{id}",
|
|
37
|
+
pattern: ["p", null],
|
|
38
|
+
summary: "Render a page (HTML inline; JSON redirects to SPA)",
|
|
39
|
+
tags: ["Pages"],
|
|
40
|
+
params: z.object({ id: z.string() }),
|
|
41
|
+
responses: {
|
|
42
|
+
200: { description: "Rendered HTML page" },
|
|
43
|
+
302: { description: "Redirect to SPA for JSON content" },
|
|
44
|
+
401: { description: "Page requires an authenticated session" },
|
|
45
|
+
403: { description: "Cookie does not match this page id" },
|
|
46
|
+
404: { description: "Page not found" },
|
|
47
|
+
},
|
|
48
|
+
auth: { apiKey: false },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const publicPageJsonRoute = route({
|
|
52
|
+
method: "get",
|
|
53
|
+
path: "/p/{id}.json",
|
|
54
|
+
pattern: ["p", null],
|
|
55
|
+
summary: "Page metadata + body as JSON (used by SPA renderer)",
|
|
56
|
+
tags: ["Pages"],
|
|
57
|
+
params: z.object({ id: z.string() }),
|
|
58
|
+
responses: {
|
|
59
|
+
200: { description: "Page JSON" },
|
|
60
|
+
401: { description: "Page requires an authenticated session" },
|
|
61
|
+
403: { description: "Cookie does not match this page id" },
|
|
62
|
+
404: { description: "Page not found" },
|
|
63
|
+
},
|
|
64
|
+
auth: { apiKey: false },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Inject the BROWSER_SDK script tag into an HTML body. Insert immediately
|
|
71
|
+
* after `<head>` if present; otherwise prepend so partial fragments still get
|
|
72
|
+
* the SDK. The script is wrapped in `<script>...</script>` with no token
|
|
73
|
+
* injection (the SDK relies on server-side header injection at the
|
|
74
|
+
* `/@swarm/api/*` proxy boundary).
|
|
75
|
+
*
|
|
76
|
+
* Also injects `<base target="_blank">` so links inside the iframed page
|
|
77
|
+
* open in the parent window — avoids the user being trapped inside an
|
|
78
|
+
* iframe by a misbehaving page.
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* Default `<head>` injection: `<base>` so links escape the iframe, Tailwind
|
|
82
|
+
* Play CDN so agent pages can use utility classes out of the box, Space
|
|
83
|
+
* Grotesk / Space Mono fonts to match the swarm SPA, a small reset that
|
|
84
|
+
* makes pages theme-aware (dark by default) so an agent who writes zero CSS
|
|
85
|
+
* still gets a presentable page, and finally the Browser SDK so
|
|
86
|
+
* `window.swarmSdk` works.
|
|
87
|
+
*
|
|
88
|
+
* Agent-provided styles ALWAYS win — the reset uses generic selectors with
|
|
89
|
+
* low specificity. Tailwind is loaded as an opt-in tool, not an enforced
|
|
90
|
+
* theme.
|
|
91
|
+
*/
|
|
92
|
+
const PAGE_HEAD_DEFAULTS = `<base target="_blank">
|
|
93
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
94
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
95
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
96
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
97
|
+
<style>
|
|
98
|
+
:root {
|
|
99
|
+
--swarm-bg: #0b0f17;
|
|
100
|
+
--swarm-card: #121826;
|
|
101
|
+
--swarm-border: #22304a;
|
|
102
|
+
--swarm-text: #e6eaf2;
|
|
103
|
+
--swarm-muted: #7c8aa6;
|
|
104
|
+
--swarm-primary: #3b82f6;
|
|
105
|
+
}
|
|
106
|
+
html, body { background: var(--swarm-bg); color: var(--swarm-text); }
|
|
107
|
+
body {
|
|
108
|
+
font-family: "Space Grotesk", system-ui, sans-serif;
|
|
109
|
+
margin: 0;
|
|
110
|
+
padding: 24px;
|
|
111
|
+
line-height: 1.5;
|
|
112
|
+
}
|
|
113
|
+
code, pre, kbd, samp { font-family: "Space Mono", ui-monospace, monospace; }
|
|
114
|
+
a { color: var(--swarm-primary); }
|
|
115
|
+
::selection { background: var(--swarm-primary); color: #fff; }
|
|
116
|
+
</style>`;
|
|
117
|
+
|
|
118
|
+
function injectBrowserSdk(html: string): string {
|
|
119
|
+
const injection = `${PAGE_HEAD_DEFAULTS}<script>${BROWSER_SDK_JS}</script>`;
|
|
120
|
+
// Use the first occurrence of `<head>` (case-insensitive). A page that
|
|
121
|
+
// doesn't have a `<head>` element (raw fragment) still gets the SDK at the
|
|
122
|
+
// front of the document.
|
|
123
|
+
const headOpenMatch = html.match(/<head\b[^>]*>/i);
|
|
124
|
+
if (headOpenMatch) {
|
|
125
|
+
const idx = headOpenMatch.index! + headOpenMatch[0].length;
|
|
126
|
+
return html.slice(0, idx) + injection + html.slice(idx);
|
|
127
|
+
}
|
|
128
|
+
return injection + html;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Trim `.json` off the last path segment, returning the bare id. Returns
|
|
133
|
+
* `null` if the segment doesn't end in `.json` (caller should fall through
|
|
134
|
+
* to the plain `/p/:id` matcher).
|
|
135
|
+
*/
|
|
136
|
+
function stripJsonSuffix(idSegment: string): string | null {
|
|
137
|
+
return idSegment.endsWith(".json") ? idSegment.slice(0, -".json".length) : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Compute the SPA base URL (`APP_URL`). Mirrors `getAppBaseUrl` in pages.ts —
|
|
142
|
+
* duplicated here to keep this module standalone (no cross-import inside the
|
|
143
|
+
* http/ layer).
|
|
144
|
+
*/
|
|
145
|
+
function getAppBaseUrl(): string {
|
|
146
|
+
const env = process.env.APP_URL?.trim();
|
|
147
|
+
if (env) return env.replace(/\/+$/, "");
|
|
148
|
+
return "http://localhost:5274";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build the `Content-Security-Policy` for the served HTML. Allows inline
|
|
153
|
+
* scripts (required for `BROWSER_SDK_JS`) but locks down everything else to
|
|
154
|
+
* `'self'`. The SPA iframes the page in step-6 with `sandbox="allow-scripts
|
|
155
|
+
* allow-forms"`; the CSP is a defence-in-depth layer.
|
|
156
|
+
*/
|
|
157
|
+
function buildCsp(): string {
|
|
158
|
+
// `frame-ancestors` lists every origin allowed to iframe `/p/:id`. We must
|
|
159
|
+
// include the SPA origin(s). `APP_URL` may carry a comma-separated list so
|
|
160
|
+
// portless dev (`https://ui.swarm.localhost`), a Vite port (`http://localhost:5274`),
|
|
161
|
+
// and a tunnel/staging origin can all coexist. Additionally, in non-production
|
|
162
|
+
// we always allow `http://localhost:*` and `https://*.localhost` so swapping
|
|
163
|
+
// between Vite ports / portless dev doesn't require restarting the API.
|
|
164
|
+
const configured = (process.env.APP_URL ?? "")
|
|
165
|
+
.split(",")
|
|
166
|
+
.map((s) => s.trim().replace(/\/+$/, ""))
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
const devFallbacks =
|
|
169
|
+
process.env.NODE_ENV === "production"
|
|
170
|
+
? []
|
|
171
|
+
: [
|
|
172
|
+
"http://localhost:5274",
|
|
173
|
+
"http://localhost:5175",
|
|
174
|
+
"http://127.0.0.1:5274",
|
|
175
|
+
"http://127.0.0.1:5175",
|
|
176
|
+
"https://*.localhost",
|
|
177
|
+
"http://*.localhost",
|
|
178
|
+
];
|
|
179
|
+
const ancestors = Array.from(new Set([...configured, ...devFallbacks]));
|
|
180
|
+
// Allow Tailwind Play CDN (`cdn.tailwindcss.com`) for scripts, Google
|
|
181
|
+
// Fonts (`fonts.googleapis.com` stylesheets + `fonts.gstatic.com` font
|
|
182
|
+
// files) for the swarm default typography, and same-origin /@swarm/api/*
|
|
183
|
+
// for the Browser SDK. Inline scripts/styles remain allowed so
|
|
184
|
+
// agent-emitted styles work.
|
|
185
|
+
return [
|
|
186
|
+
"default-src 'self'",
|
|
187
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com",
|
|
188
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.tailwindcss.com",
|
|
189
|
+
"font-src 'self' https://fonts.gstatic.com data:",
|
|
190
|
+
"img-src 'self' data: https:",
|
|
191
|
+
"connect-src 'self'",
|
|
192
|
+
`frame-ancestors 'self' ${ancestors.join(" ")}`.trim(),
|
|
193
|
+
].join("; ");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Decide whether a page is reachable based on cookie alone. Public pages are
|
|
198
|
+
* always reachable; authed AND password pages can ALSO pass via a valid
|
|
199
|
+
* `page_session` cookie scoped to the same page id (the cookie is the proof
|
|
200
|
+
* once the user has successfully unlocked once). For password pages, when
|
|
201
|
+
* the cookie is absent/invalid the caller falls through to the `?key=` +
|
|
202
|
+
* Basic-auth resolution path; for authed pages the caller surfaces 401.
|
|
203
|
+
*
|
|
204
|
+
* The caller passes the cookie payload (already verified by
|
|
205
|
+
* `extractAndVerifyCookie`) — `null` when no cookie was sent or it failed
|
|
206
|
+
* verification. Cross-page reuse (cookie for page A presented for page B)
|
|
207
|
+
* surfaces as a distinct `403` so misconfigurations are debuggable, NOT a
|
|
208
|
+
* generic 401.
|
|
209
|
+
*
|
|
210
|
+
* For password pages with no/bad cookie, returns `{ ok: false, status: 401,
|
|
211
|
+
* needsPassword: true }` so the handler knows to try the password flow before
|
|
212
|
+
* sending the WWW-Authenticate response.
|
|
213
|
+
*/
|
|
214
|
+
type AccessResult =
|
|
215
|
+
| { ok: true }
|
|
216
|
+
| { ok: false; status: 401 | 403; reason: string; needsPassword?: boolean };
|
|
217
|
+
|
|
218
|
+
function isAccessible(
|
|
219
|
+
page: Page,
|
|
220
|
+
cookiePayload: { pageId: string; exp: number } | null,
|
|
221
|
+
): AccessResult {
|
|
222
|
+
if (page.authMode === "public") return { ok: true };
|
|
223
|
+
|
|
224
|
+
// Cookie-first path. A cookie scoped to a DIFFERENT page id is "stale" —
|
|
225
|
+
// for password mode we silently ignore it and fall through to the password
|
|
226
|
+
// flow so the user can recover via `?key=` / Basic without manual cookie
|
|
227
|
+
// clearing. For authed mode we surface 403 (the SPA's launch-retry path
|
|
228
|
+
// handles recovery; direct browser access to authed pages is rare).
|
|
229
|
+
if (cookiePayload) {
|
|
230
|
+
if (cookiePayload.pageId === page.id) return { ok: true };
|
|
231
|
+
if (page.authMode === "password") {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
status: 401,
|
|
235
|
+
reason: "password required",
|
|
236
|
+
needsPassword: true,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
status: 403,
|
|
242
|
+
reason: "page-session cookie scoped to a different page id",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (page.authMode === "authed") {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
status: 401,
|
|
250
|
+
reason: "authed mode requires page-session cookie; POST /api/pages/:id/launch first",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// password mode, no cookie yet — caller will try ?key= / Basic.
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
status: 401,
|
|
257
|
+
reason: "password required",
|
|
258
|
+
needsPassword: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Extract a password candidate from the request. Order of precedence:
|
|
264
|
+
* 1. `?key=` query param (if present, returns it verbatim — empty string is
|
|
265
|
+
* still "present", caller decides what to do with it).
|
|
266
|
+
* 2. `Authorization: Basic <base64(user:pass)>` header — decodes the base64
|
|
267
|
+
* blob, splits on the FIRST `:`, and returns the part AFTER the colon
|
|
268
|
+
* (the username is ignored — Basic auth has no notion of "username
|
|
269
|
+
* doesn't matter" so we treat anything as the username).
|
|
270
|
+
*
|
|
271
|
+
* Returns `null` when neither input is present, or when the Basic header is
|
|
272
|
+
* malformed (bad base64, no colon, etc.). NEVER throws — malformed Basic
|
|
273
|
+
* collapses to "no candidate" so the caller falls through to the 401 path.
|
|
274
|
+
*/
|
|
275
|
+
function extractPasswordCandidate(
|
|
276
|
+
req: IncomingMessage,
|
|
277
|
+
queryParams: URLSearchParams,
|
|
278
|
+
): string | null {
|
|
279
|
+
const fromQuery = queryParams.get("key");
|
|
280
|
+
if (fromQuery !== null) return fromQuery;
|
|
281
|
+
|
|
282
|
+
const rawAuth = req.headers.authorization;
|
|
283
|
+
const auth = Array.isArray(rawAuth) ? rawAuth[0] : rawAuth;
|
|
284
|
+
if (!auth) return null;
|
|
285
|
+
// Format: `Basic <base64>`. Match case-insensitively per RFC 7617.
|
|
286
|
+
const m = /^Basic\s+(.+)$/i.exec(auth.trim());
|
|
287
|
+
if (!m) return null;
|
|
288
|
+
let decoded: string;
|
|
289
|
+
try {
|
|
290
|
+
decoded = Buffer.from(m[1]!, "base64").toString("utf-8");
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const colonIdx = decoded.indexOf(":");
|
|
295
|
+
if (colonIdx === -1) return null;
|
|
296
|
+
return decoded.slice(colonIdx + 1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Detect whether the originating request is from a "dev" / localhost context,
|
|
301
|
+
* mirroring the same logic used by the bearer-launch endpoint in
|
|
302
|
+
* `src/http/pages.ts`. Used to decide whether to issue cookies with
|
|
303
|
+
* `SameSite=Lax` (dev) vs `SameSite=None; Secure` (prod).
|
|
304
|
+
*/
|
|
305
|
+
function isLocalhostRequest(req: IncomingMessage): boolean {
|
|
306
|
+
if (process.env.NODE_ENV === "production") return false;
|
|
307
|
+
// Only emit `SameSite=Lax` (no Secure) when the request comes from the
|
|
308
|
+
// SAME http://localhost origin as the API — Lax cookies don't travel on
|
|
309
|
+
// cross-site fetches, so portless `*.localhost` setups (SPA on https
|
|
310
|
+
// talking to the API on http) must use `SameSite=None; Secure`. Chrome
|
|
311
|
+
// treats localhost as a secure origin so Secure is honored on HTTP.
|
|
312
|
+
const origin = (req.headers.origin as string | undefined) ?? "";
|
|
313
|
+
if (origin === "") {
|
|
314
|
+
const rawHost = req.headers.host;
|
|
315
|
+
const host = Array.isArray(rawHost) ? rawHost[0] : rawHost;
|
|
316
|
+
if (!host) return true; // best-effort dev default
|
|
317
|
+
return host.startsWith("localhost") || host.startsWith("127.0.0.1");
|
|
318
|
+
}
|
|
319
|
+
return origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── Handler ────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
export async function handlePagesPublic(
|
|
325
|
+
req: IncomingMessage,
|
|
326
|
+
res: ServerResponse,
|
|
327
|
+
pathSegments: string[],
|
|
328
|
+
queryParams: URLSearchParams,
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
// Both routes share the same `["p", null]` pattern; we discriminate by
|
|
331
|
+
// suffix on the second segment. The route() registrations exist mainly so
|
|
332
|
+
// isPublicRoute() lets these through the bearer gate — actual dispatch is
|
|
333
|
+
// handled here.
|
|
334
|
+
if (pathSegments.length !== 2 || pathSegments[0] !== "p") return false;
|
|
335
|
+
if (req.method !== "GET") return false;
|
|
336
|
+
|
|
337
|
+
const second = pathSegments[1]!;
|
|
338
|
+
const jsonStripped = stripJsonSuffix(second);
|
|
339
|
+
const isJsonRoute = jsonStripped !== null;
|
|
340
|
+
const id = jsonStripped ?? second;
|
|
341
|
+
|
|
342
|
+
// Touch parse() to (a) honour Zod validation on the id segment and (b)
|
|
343
|
+
// keep the OpenAPI machinery happy. Mismatched segment counts have
|
|
344
|
+
// already been handled above.
|
|
345
|
+
if (isJsonRoute) {
|
|
346
|
+
// Re-shim pathSegments so the route parser sees `[p, <id>]` not `[p, <id>.json]`.
|
|
347
|
+
const reshim = ["p", id];
|
|
348
|
+
const parsed = await publicPageJsonRoute.parse(req, res, reshim, queryParams);
|
|
349
|
+
if (!parsed) return true;
|
|
350
|
+
} else {
|
|
351
|
+
const parsed = await publicPageRoute.parse(req, res, pathSegments, queryParams);
|
|
352
|
+
if (!parsed) return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const page = getPage(id);
|
|
356
|
+
if (!page) {
|
|
357
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
358
|
+
res.end(JSON.stringify({ error: "Page not found" }));
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Pull + verify the page-session cookie ONCE — `null` covers "no cookie",
|
|
363
|
+
// "tampered signature", "expired", "malformed". The access decision below
|
|
364
|
+
// discriminates per-authMode.
|
|
365
|
+
const cookiePayload = await extractAndVerifyCookie(req);
|
|
366
|
+
|
|
367
|
+
const access = isAccessible(page, cookiePayload);
|
|
368
|
+
|
|
369
|
+
// Set-Cookie that we'll attach to the eventual 200 response when the
|
|
370
|
+
// password flow mints a fresh cookie inline. Empty string = no cookie to
|
|
371
|
+
// attach. We thread this through the handler so the existing 200-response
|
|
372
|
+
// paths below can opt-in without duplicating their branch logic.
|
|
373
|
+
let inlineSetCookie = "";
|
|
374
|
+
|
|
375
|
+
if (!access.ok) {
|
|
376
|
+
// Password flow: cookie missing/invalid, try `?key=` then Basic.
|
|
377
|
+
if (access.needsPassword) {
|
|
378
|
+
const candidate = extractPasswordCandidate(req, queryParams);
|
|
379
|
+
if (candidate !== null && page.passwordHash) {
|
|
380
|
+
// Bun.password.verify is constant-time (bcrypt). NEVER log the
|
|
381
|
+
// candidate or hash — they may carry user-provided secrets.
|
|
382
|
+
let matched = false;
|
|
383
|
+
try {
|
|
384
|
+
matched = await Bun.password.verify(candidate, page.passwordHash);
|
|
385
|
+
} catch {
|
|
386
|
+
matched = false;
|
|
387
|
+
}
|
|
388
|
+
if (matched) {
|
|
389
|
+
inlineSetCookie = await issuePageSessionCookie(page.id, {
|
|
390
|
+
dev: isLocalhostRequest(req),
|
|
391
|
+
});
|
|
392
|
+
// fall through to the regular 200 path below.
|
|
393
|
+
} else {
|
|
394
|
+
// Wrong password → 401 + WWW-Authenticate so the browser re-prompts.
|
|
395
|
+
res.writeHead(401, {
|
|
396
|
+
"Content-Type": "application/json",
|
|
397
|
+
"WWW-Authenticate": `Basic realm="page ${page.id}"`,
|
|
398
|
+
});
|
|
399
|
+
res.end(JSON.stringify({ error: "incorrect password" }));
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
// No candidate at all → 401 + WWW-Authenticate so the browser shows
|
|
404
|
+
// the native Basic auth dialog.
|
|
405
|
+
res.writeHead(401, {
|
|
406
|
+
"Content-Type": "application/json",
|
|
407
|
+
"WWW-Authenticate": `Basic realm="page ${page.id}"`,
|
|
408
|
+
});
|
|
409
|
+
res.end(JSON.stringify({ error: "password required" }));
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
res.writeHead(access.status, { "Content-Type": "application/json" });
|
|
414
|
+
res.end(JSON.stringify({ error: scrubSecrets(access.reason) }));
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (isJsonRoute) {
|
|
420
|
+
// `/p/:id.json` — JSON description of the page used by the SPA renderer.
|
|
421
|
+
// Returns the current head state (no version history). Body included
|
|
422
|
+
// verbatim. NOTE: passwordHash / agentId are NOT exposed here — these
|
|
423
|
+
// are private. step-4 may revisit if needed.
|
|
424
|
+
const headers: Record<string, string> = {
|
|
425
|
+
"Content-Type": "application/json",
|
|
426
|
+
"Cache-Control": "no-store",
|
|
427
|
+
};
|
|
428
|
+
if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
|
|
429
|
+
res.writeHead(200, headers);
|
|
430
|
+
res.end(
|
|
431
|
+
JSON.stringify({
|
|
432
|
+
id: page.id,
|
|
433
|
+
version: 1, // edit-counter is API-internal; SPA reads via /api/pages/:id/versions
|
|
434
|
+
title: page.title,
|
|
435
|
+
description: page.description,
|
|
436
|
+
contentType: page.contentType,
|
|
437
|
+
authMode: page.authMode,
|
|
438
|
+
body: page.body,
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// `/p/:id` — render either HTML directly or 302→SPA for JSON.
|
|
445
|
+
if (page.contentType === "application/json") {
|
|
446
|
+
const headers: Record<string, string> = { Location: `${getAppBaseUrl()}/pages/${page.id}` };
|
|
447
|
+
if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
|
|
448
|
+
res.writeHead(302, headers);
|
|
449
|
+
res.end();
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// text/html — inject SDK + serve.
|
|
454
|
+
const html = injectBrowserSdk(page.body);
|
|
455
|
+
const headers: Record<string, string> = {
|
|
456
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
457
|
+
"Cache-Control": "no-store",
|
|
458
|
+
"Content-Security-Policy": buildCsp(),
|
|
459
|
+
// Defence-in-depth: prevent MIME sniffing and clickjacking outside the SPA.
|
|
460
|
+
"X-Content-Type-Options": "nosniff",
|
|
461
|
+
};
|
|
462
|
+
if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
|
|
463
|
+
res.writeHead(200, headers);
|
|
464
|
+
res.end(html);
|
|
465
|
+
return true;
|
|
466
|
+
}
|