@desplega.ai/agent-swarm 1.78.1 → 1.79.1

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 (75) hide show
  1. package/README.md +1 -0
  2. package/openapi.json +1335 -236
  3. package/package.json +4 -4
  4. package/plugin/skills/artifacts/SKILL.md +151 -0
  5. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  6. package/plugin/skills/kv-storage/SKILL.md +168 -0
  7. package/plugin/skills/pages/SKILL.md +423 -0
  8. package/src/artifact-sdk/browser-sdk.ts +396 -19
  9. package/src/be/db.ts +548 -0
  10. package/src/be/migrations/059_pages.sql +34 -0
  11. package/src/be/migrations/060_page_versions.sql +19 -0
  12. package/src/be/migrations/061_kv_store.sql +34 -0
  13. package/src/be/migrations/062_pages_view_count.sql +9 -0
  14. package/src/commands/artifact.ts +17 -11
  15. package/src/commands/provider-credentials.ts +1 -1
  16. package/src/http/index.ts +9 -1
  17. package/src/http/kv.ts +658 -0
  18. package/src/http/page-proxy.ts +213 -0
  19. package/src/http/pages-public.ts +507 -0
  20. package/src/http/pages.ts +608 -0
  21. package/src/http/status.ts +1 -1
  22. package/src/http/utils.ts +68 -5
  23. package/src/pages/version.ts +44 -0
  24. package/src/prompts/session-templates.ts +51 -0
  25. package/src/providers/pi-mono-adapter.ts +3 -3
  26. package/src/providers/pi-mono-extension.ts +1 -1
  27. package/src/server.ts +29 -1
  28. package/src/tasks/context-key.ts +28 -0
  29. package/src/telemetry.ts +65 -1
  30. package/src/tests/artifact-commands.test.ts +92 -0
  31. package/src/tests/artifact-sdk.test.ts +80 -74
  32. package/src/tests/context-key.test.ts +17 -0
  33. package/src/tests/create-page-tool.test.ts +197 -0
  34. package/src/tests/fixtures/sample-json-page.json +52 -0
  35. package/src/tests/kv-http.test.ts +331 -0
  36. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  37. package/src/tests/kv-page-proxy.test.ts +212 -0
  38. package/src/tests/kv-storage.test.ts +227 -0
  39. package/src/tests/kv-tool.test.ts +217 -0
  40. package/src/tests/launch-password-rejection.test.ts +139 -0
  41. package/src/tests/page-proxy-authed.test.ts +146 -0
  42. package/src/tests/page-proxy.test.ts +270 -0
  43. package/src/tests/page-session.test.ts +169 -0
  44. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  45. package/src/tests/pages-authed-mode.test.ts +211 -0
  46. package/src/tests/pages-http.test.ts +193 -0
  47. package/src/tests/pages-list-endpoint.test.ts +149 -0
  48. package/src/tests/pages-password-hash.test.ts +57 -0
  49. package/src/tests/pages-password-mode.test.ts +265 -0
  50. package/src/tests/pages-public-authed-401.test.ts +102 -0
  51. package/src/tests/pages-public-html.test.ts +151 -0
  52. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  53. package/src/tests/pages-storage.test.ts +196 -0
  54. package/src/tests/pages-versioning.test.ts +231 -0
  55. package/src/tests/pages-view-count.test.ts +220 -0
  56. package/src/tests/prompt-template-session.test.ts +3 -2
  57. package/src/tests/skill-update-scope.test.ts +165 -0
  58. package/src/tests/swarm-diff.test.ts +303 -0
  59. package/src/tests/telemetry-init.test.ts +149 -0
  60. package/src/tests/workflow-wait-event.test.ts +4 -7
  61. package/src/tools/create-page.ts +263 -0
  62. package/src/tools/kv/index.ts +5 -0
  63. package/src/tools/kv/kv-delete.ts +89 -0
  64. package/src/tools/kv/kv-get.ts +64 -0
  65. package/src/tools/kv/kv-incr.ts +116 -0
  66. package/src/tools/kv/kv-list.ts +81 -0
  67. package/src/tools/kv/kv-set.ts +194 -0
  68. package/src/tools/kv/resolve-namespace.ts +58 -0
  69. package/src/tools/skills/skill-update.ts +26 -0
  70. package/src/tools/tool-config.ts +10 -0
  71. package/src/types.ts +107 -0
  72. package/src/utils/internal-ai/complete-structured.ts +2 -2
  73. package/src/utils/internal-ai/credentials.ts +3 -3
  74. package/src/utils/page-session.ts +254 -0
  75. package/plugin/skills/artifacts/skill.md +0 -70
@@ -0,0 +1,507 @@
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, SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
26
+ import { getPage, incrementPageViewCount } 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
+ @media print {
117
+ /* Override theme variables so built-in primitives (swarm-diff, swarm-card)
118
+ that read var(--swarm-card) / var(--swarm-border) etc. inline-styled
119
+ backgrounds also flip to light. Without this, diff cards stay dark navy
120
+ on print. */
121
+ :root {
122
+ --swarm-bg: #ffffff;
123
+ --swarm-card: #ffffff;
124
+ --swarm-border: #cccccc;
125
+ --swarm-text: #000000;
126
+ --swarm-muted: #555555;
127
+ }
128
+ html, body { background: white !important; color: black !important; }
129
+ a { color: black !important; text-decoration: underline; }
130
+ /* Hide any swarm chrome the agent (or built-in primitives) tagged with
131
+ .no-print. Use this class on annotation badges, jump-list nav, anything
132
+ that shouldn't appear in the PDF export. */
133
+ .no-print { display: none !important; }
134
+ /* Avoid page-break inside cards / diff blocks. */
135
+ .swarm-card, swarm-diff { break-inside: avoid; }
136
+ }
137
+ </style>`;
138
+
139
+ function injectBrowserSdk(html: string): string {
140
+ const injection = `${PAGE_HEAD_DEFAULTS}<script>${BROWSER_SDK_JS}</script><script>${SWARM_UI_JS}</script>`;
141
+ // Use the first occurrence of `<head>` (case-insensitive). A page that
142
+ // doesn't have a `<head>` element (raw fragment) still gets the SDK at the
143
+ // front of the document.
144
+ const headOpenMatch = html.match(/<head\b[^>]*>/i);
145
+ if (headOpenMatch) {
146
+ const idx = headOpenMatch.index! + headOpenMatch[0].length;
147
+ return html.slice(0, idx) + injection + html.slice(idx);
148
+ }
149
+ return injection + html;
150
+ }
151
+
152
+ /**
153
+ * Trim `.json` off the last path segment, returning the bare id. Returns
154
+ * `null` if the segment doesn't end in `.json` (caller should fall through
155
+ * to the plain `/p/:id` matcher).
156
+ */
157
+ function stripJsonSuffix(idSegment: string): string | null {
158
+ return idSegment.endsWith(".json") ? idSegment.slice(0, -".json".length) : null;
159
+ }
160
+
161
+ /**
162
+ * Compute the SPA base URL (`APP_URL`). Mirrors `getAppBaseUrl` in pages.ts —
163
+ * duplicated here to keep this module standalone (no cross-import inside the
164
+ * http/ layer).
165
+ */
166
+ function getAppBaseUrl(): string {
167
+ const env = process.env.APP_URL?.trim();
168
+ if (env) return env.replace(/\/+$/, "");
169
+ return "http://localhost:5274";
170
+ }
171
+
172
+ /**
173
+ * Build the `Content-Security-Policy` for the served HTML. Allows inline
174
+ * scripts (required for `BROWSER_SDK_JS`) but locks down everything else to
175
+ * `'self'`. The SPA iframes the page in step-6 with `sandbox="allow-scripts
176
+ * allow-forms"`; the CSP is a defence-in-depth layer.
177
+ */
178
+ function buildCsp(): string {
179
+ // `frame-ancestors` lists every origin allowed to iframe `/p/:id`. We must
180
+ // include the SPA origin(s). `APP_URL` may carry a comma-separated list so
181
+ // portless dev (`https://ui.swarm.localhost`), a Vite port (`http://localhost:5274`),
182
+ // and a tunnel/staging origin can all coexist. Additionally, in non-production
183
+ // we always allow `http://localhost:*` and `https://*.localhost` so swapping
184
+ // between Vite ports / portless dev doesn't require restarting the API.
185
+ const configured = (process.env.APP_URL ?? "")
186
+ .split(",")
187
+ .map((s) => s.trim().replace(/\/+$/, ""))
188
+ .filter(Boolean);
189
+ const devFallbacks =
190
+ process.env.NODE_ENV === "production"
191
+ ? []
192
+ : [
193
+ "http://localhost:5274",
194
+ "http://localhost:5175",
195
+ "http://127.0.0.1:5274",
196
+ "http://127.0.0.1:5175",
197
+ "https://*.localhost",
198
+ "http://*.localhost",
199
+ ];
200
+ const ancestors = Array.from(new Set([...configured, ...devFallbacks]));
201
+ // Allow Tailwind Play CDN (`cdn.tailwindcss.com`) for scripts, Google
202
+ // Fonts (`fonts.googleapis.com` stylesheets + `fonts.gstatic.com` font
203
+ // files) for the swarm default typography, and same-origin /@swarm/api/*
204
+ // for the Browser SDK. Inline scripts/styles remain allowed so
205
+ // agent-emitted styles work.
206
+ return [
207
+ "default-src 'self'",
208
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com",
209
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.tailwindcss.com",
210
+ "font-src 'self' https://fonts.gstatic.com data:",
211
+ "img-src 'self' data: https:",
212
+ "connect-src 'self'",
213
+ `frame-ancestors 'self' ${ancestors.join(" ")}`.trim(),
214
+ ].join("; ");
215
+ }
216
+
217
+ /**
218
+ * Decide whether a page is reachable based on cookie alone. Public pages are
219
+ * always reachable; authed AND password pages can ALSO pass via a valid
220
+ * `page_session` cookie scoped to the same page id (the cookie is the proof
221
+ * once the user has successfully unlocked once). For password pages, when
222
+ * the cookie is absent/invalid the caller falls through to the `?key=` +
223
+ * Basic-auth resolution path; for authed pages the caller surfaces 401.
224
+ *
225
+ * The caller passes the cookie payload (already verified by
226
+ * `extractAndVerifyCookie`) — `null` when no cookie was sent or it failed
227
+ * verification. Cross-page reuse (cookie for page A presented for page B)
228
+ * surfaces as a distinct `403` so misconfigurations are debuggable, NOT a
229
+ * generic 401.
230
+ *
231
+ * For password pages with no/bad cookie, returns `{ ok: false, status: 401,
232
+ * needsPassword: true }` so the handler knows to try the password flow before
233
+ * sending the WWW-Authenticate response.
234
+ */
235
+ type AccessResult =
236
+ | { ok: true }
237
+ | { ok: false; status: 401 | 403; reason: string; needsPassword?: boolean };
238
+
239
+ function isAccessible(
240
+ page: Page,
241
+ cookiePayload: { pageId: string; exp: number } | null,
242
+ ): AccessResult {
243
+ if (page.authMode === "public") return { ok: true };
244
+
245
+ // Cookie-first path. A cookie scoped to a DIFFERENT page id is "stale" —
246
+ // for password mode we silently ignore it and fall through to the password
247
+ // flow so the user can recover via `?key=` / Basic without manual cookie
248
+ // clearing. For authed mode we surface 403 (the SPA's launch-retry path
249
+ // handles recovery; direct browser access to authed pages is rare).
250
+ if (cookiePayload) {
251
+ if (cookiePayload.pageId === page.id) return { ok: true };
252
+ if (page.authMode === "password") {
253
+ return {
254
+ ok: false,
255
+ status: 401,
256
+ reason: "password required",
257
+ needsPassword: true,
258
+ };
259
+ }
260
+ return {
261
+ ok: false,
262
+ status: 403,
263
+ reason: "page-session cookie scoped to a different page id",
264
+ };
265
+ }
266
+
267
+ if (page.authMode === "authed") {
268
+ return {
269
+ ok: false,
270
+ status: 401,
271
+ reason: "authed mode requires page-session cookie; POST /api/pages/:id/launch first",
272
+ };
273
+ }
274
+ // password mode, no cookie yet — caller will try ?key= / Basic.
275
+ return {
276
+ ok: false,
277
+ status: 401,
278
+ reason: "password required",
279
+ needsPassword: true,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Extract a password candidate from the request. Order of precedence:
285
+ * 1. `?key=` query param (if present, returns it verbatim — empty string is
286
+ * still "present", caller decides what to do with it).
287
+ * 2. `Authorization: Basic <base64(user:pass)>` header — decodes the base64
288
+ * blob, splits on the FIRST `:`, and returns the part AFTER the colon
289
+ * (the username is ignored — Basic auth has no notion of "username
290
+ * doesn't matter" so we treat anything as the username).
291
+ *
292
+ * Returns `null` when neither input is present, or when the Basic header is
293
+ * malformed (bad base64, no colon, etc.). NEVER throws — malformed Basic
294
+ * collapses to "no candidate" so the caller falls through to the 401 path.
295
+ */
296
+ function extractPasswordCandidate(
297
+ req: IncomingMessage,
298
+ queryParams: URLSearchParams,
299
+ ): string | null {
300
+ const fromQuery = queryParams.get("key");
301
+ if (fromQuery !== null) return fromQuery;
302
+
303
+ const rawAuth = req.headers.authorization;
304
+ const auth = Array.isArray(rawAuth) ? rawAuth[0] : rawAuth;
305
+ if (!auth) return null;
306
+ // Format: `Basic <base64>`. Match case-insensitively per RFC 7617.
307
+ const m = /^Basic\s+(.+)$/i.exec(auth.trim());
308
+ if (!m) return null;
309
+ let decoded: string;
310
+ try {
311
+ decoded = Buffer.from(m[1]!, "base64").toString("utf-8");
312
+ } catch {
313
+ return null;
314
+ }
315
+ const colonIdx = decoded.indexOf(":");
316
+ if (colonIdx === -1) return null;
317
+ return decoded.slice(colonIdx + 1);
318
+ }
319
+
320
+ /**
321
+ * Detect whether the originating request is from a "dev" / localhost context,
322
+ * mirroring the same logic used by the bearer-launch endpoint in
323
+ * `src/http/pages.ts`. Used to decide whether to issue cookies with
324
+ * `SameSite=Lax` (dev) vs `SameSite=None; Secure` (prod).
325
+ */
326
+ function isLocalhostRequest(req: IncomingMessage): boolean {
327
+ if (process.env.NODE_ENV === "production") return false;
328
+ // Only emit `SameSite=Lax` (no Secure) when the request comes from the
329
+ // SAME http://localhost origin as the API — Lax cookies don't travel on
330
+ // cross-site fetches, so portless `*.localhost` setups (SPA on https
331
+ // talking to the API on http) must use `SameSite=None; Secure`. Chrome
332
+ // treats localhost as a secure origin so Secure is honored on HTTP.
333
+ const origin = (req.headers.origin as string | undefined) ?? "";
334
+ if (origin === "") {
335
+ const rawHost = req.headers.host;
336
+ const host = Array.isArray(rawHost) ? rawHost[0] : rawHost;
337
+ if (!host) return true; // best-effort dev default
338
+ return host.startsWith("localhost") || host.startsWith("127.0.0.1");
339
+ }
340
+ return origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1");
341
+ }
342
+
343
+ // ─── Handler ────────────────────────────────────────────────────────────────
344
+
345
+ export async function handlePagesPublic(
346
+ req: IncomingMessage,
347
+ res: ServerResponse,
348
+ pathSegments: string[],
349
+ queryParams: URLSearchParams,
350
+ ): Promise<boolean> {
351
+ // Both routes share the same `["p", null]` pattern; we discriminate by
352
+ // suffix on the second segment. The route() registrations exist mainly so
353
+ // isPublicRoute() lets these through the bearer gate — actual dispatch is
354
+ // handled here.
355
+ if (pathSegments.length !== 2 || pathSegments[0] !== "p") return false;
356
+ if (req.method !== "GET") return false;
357
+
358
+ const second = pathSegments[1]!;
359
+ const jsonStripped = stripJsonSuffix(second);
360
+ const isJsonRoute = jsonStripped !== null;
361
+ const id = jsonStripped ?? second;
362
+
363
+ // Touch parse() to (a) honour Zod validation on the id segment and (b)
364
+ // keep the OpenAPI machinery happy. Mismatched segment counts have
365
+ // already been handled above.
366
+ if (isJsonRoute) {
367
+ // Re-shim pathSegments so the route parser sees `[p, <id>]` not `[p, <id>.json]`.
368
+ const reshim = ["p", id];
369
+ const parsed = await publicPageJsonRoute.parse(req, res, reshim, queryParams);
370
+ if (!parsed) return true;
371
+ } else {
372
+ const parsed = await publicPageRoute.parse(req, res, pathSegments, queryParams);
373
+ if (!parsed) return true;
374
+ }
375
+
376
+ const page = getPage(id);
377
+ if (!page) {
378
+ res.writeHead(404, { "Content-Type": "application/json" });
379
+ res.end(JSON.stringify({ error: "Page not found" }));
380
+ return true;
381
+ }
382
+
383
+ // Pull + verify the page-session cookie ONCE — `null` covers "no cookie",
384
+ // "tampered signature", "expired", "malformed". The access decision below
385
+ // discriminates per-authMode.
386
+ const cookiePayload = await extractAndVerifyCookie(req);
387
+
388
+ const access = isAccessible(page, cookiePayload);
389
+
390
+ // Set-Cookie that we'll attach to the eventual 200 response when the
391
+ // password flow mints a fresh cookie inline. Empty string = no cookie to
392
+ // attach. We thread this through the handler so the existing 200-response
393
+ // paths below can opt-in without duplicating their branch logic.
394
+ let inlineSetCookie = "";
395
+
396
+ if (!access.ok) {
397
+ // Password flow: cookie missing/invalid, try `?key=` then Basic.
398
+ if (access.needsPassword) {
399
+ const candidate = extractPasswordCandidate(req, queryParams);
400
+ if (candidate !== null && page.passwordHash) {
401
+ // Bun.password.verify is constant-time (bcrypt). NEVER log the
402
+ // candidate or hash — they may carry user-provided secrets.
403
+ let matched = false;
404
+ try {
405
+ matched = await Bun.password.verify(candidate, page.passwordHash);
406
+ } catch {
407
+ matched = false;
408
+ }
409
+ if (matched) {
410
+ inlineSetCookie = await issuePageSessionCookie(page.id, {
411
+ dev: isLocalhostRequest(req),
412
+ });
413
+ // fall through to the regular 200 path below.
414
+ } else {
415
+ // Wrong password → 401 + WWW-Authenticate so the browser re-prompts.
416
+ res.writeHead(401, {
417
+ "Content-Type": "application/json",
418
+ "WWW-Authenticate": `Basic realm="page ${page.id}"`,
419
+ });
420
+ res.end(JSON.stringify({ error: "incorrect password" }));
421
+ return true;
422
+ }
423
+ } else {
424
+ // No candidate at all → 401 + WWW-Authenticate so the browser shows
425
+ // the native Basic auth dialog.
426
+ res.writeHead(401, {
427
+ "Content-Type": "application/json",
428
+ "WWW-Authenticate": `Basic realm="page ${page.id}"`,
429
+ });
430
+ res.end(JSON.stringify({ error: "password required" }));
431
+ return true;
432
+ }
433
+ } else {
434
+ res.writeHead(access.status, { "Content-Type": "application/json" });
435
+ res.end(JSON.stringify({ error: scrubSecrets(access.reason) }));
436
+ return true;
437
+ }
438
+ }
439
+
440
+ if (isJsonRoute) {
441
+ // `/p/:id.json` — JSON description of the page used by the SPA renderer.
442
+ // Returns the current head state (no version history). Body included
443
+ // verbatim. NOTE: passwordHash / agentId are NOT exposed here — these
444
+ // are private. step-4 may revisit if needed.
445
+ const headers: Record<string, string> = {
446
+ "Content-Type": "application/json",
447
+ "Cache-Control": "no-store",
448
+ };
449
+ if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
450
+ res.writeHead(200, headers);
451
+ res.end(
452
+ JSON.stringify({
453
+ id: page.id,
454
+ version: 1, // edit-counter is API-internal; SPA reads via /api/pages/:id/versions
455
+ title: page.title,
456
+ description: page.description,
457
+ contentType: page.contentType,
458
+ authMode: page.authMode,
459
+ body: page.body,
460
+ }),
461
+ );
462
+ bumpViewCount(page.id);
463
+ return true;
464
+ }
465
+
466
+ // `/p/:id` — render either HTML directly or 302→SPA for JSON.
467
+ if (page.contentType === "application/json") {
468
+ const headers: Record<string, string> = { Location: `${getAppBaseUrl()}/pages/${page.id}` };
469
+ if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
470
+ res.writeHead(302, headers);
471
+ res.end();
472
+ // 302 redirects are intentionally NOT counted — they're a stop-over for
473
+ // JSON pages, and the SPA's subsequent `/p/:id.json` fetch bumps the
474
+ // counter via the JSON path above. Counting both would double-count.
475
+ return true;
476
+ }
477
+
478
+ // text/html — inject SDK + serve.
479
+ const html = injectBrowserSdk(page.body);
480
+ const headers: Record<string, string> = {
481
+ "Content-Type": "text/html; charset=utf-8",
482
+ "Cache-Control": "no-store",
483
+ "Content-Security-Policy": buildCsp(),
484
+ // Defence-in-depth: prevent MIME sniffing and clickjacking outside the SPA.
485
+ "X-Content-Type-Options": "nosniff",
486
+ };
487
+ if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
488
+ res.writeHead(200, headers);
489
+ res.end(html);
490
+ bumpViewCount(page.id);
491
+ return true;
492
+ }
493
+
494
+ /**
495
+ * Best-effort view-count bump. Wrapped in try/catch so a counter write never
496
+ * fails the response — pages are served before the bump runs, and any DB
497
+ * error is swallowed silently. No dedup by viewer; one bump per successful
498
+ * 200 (HTML inline or JSON metadata fetch). 302/401/403/404 responses do
499
+ * NOT bump.
500
+ */
501
+ function bumpViewCount(pageId: string): void {
502
+ try {
503
+ incrementPageViewCount(pageId);
504
+ } catch {
505
+ // intentional empty — analytics must never break page serving.
506
+ }
507
+ }