@desplega.ai/agent-swarm 1.78.1 → 1.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/openapi.json +542 -1
  2. package/package.json +1 -1
  3. package/plugin/skills/artifacts/SKILL.md +151 -0
  4. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  5. package/plugin/skills/pages/SKILL.md +274 -0
  6. package/src/artifact-sdk/browser-sdk.ts +105 -20
  7. package/src/be/db.ts +239 -0
  8. package/src/be/migrations/059_pages.sql +34 -0
  9. package/src/be/migrations/060_page_versions.sql +19 -0
  10. package/src/commands/artifact.ts +17 -11
  11. package/src/http/index.ts +7 -1
  12. package/src/http/page-proxy.ts +208 -0
  13. package/src/http/pages-public.ts +466 -0
  14. package/src/http/pages.ts +608 -0
  15. package/src/http/utils.ts +68 -5
  16. package/src/pages/version.ts +44 -0
  17. package/src/prompts/session-templates.ts +51 -0
  18. package/src/server.ts +10 -1
  19. package/src/tests/artifact-commands.test.ts +92 -0
  20. package/src/tests/artifact-sdk.test.ts +80 -74
  21. package/src/tests/create-page-tool.test.ts +197 -0
  22. package/src/tests/fixtures/sample-json-page.json +52 -0
  23. package/src/tests/launch-password-rejection.test.ts +139 -0
  24. package/src/tests/page-proxy-authed.test.ts +146 -0
  25. package/src/tests/page-proxy.test.ts +266 -0
  26. package/src/tests/page-session.test.ts +164 -0
  27. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  28. package/src/tests/pages-authed-mode.test.ts +207 -0
  29. package/src/tests/pages-http.test.ts +193 -0
  30. package/src/tests/pages-list-endpoint.test.ts +149 -0
  31. package/src/tests/pages-password-hash.test.ts +57 -0
  32. package/src/tests/pages-password-mode.test.ts +265 -0
  33. package/src/tests/pages-public-authed-401.test.ts +102 -0
  34. package/src/tests/pages-public-html.test.ts +151 -0
  35. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  36. package/src/tests/pages-storage.test.ts +196 -0
  37. package/src/tests/pages-versioning.test.ts +231 -0
  38. package/src/tests/prompt-template-session.test.ts +3 -2
  39. package/src/tests/skill-update-scope.test.ts +165 -0
  40. package/src/tests/workflow-wait-event.test.ts +4 -7
  41. package/src/tools/create-page.ts +263 -0
  42. package/src/tools/skills/skill-update.ts +26 -0
  43. package/src/tools/tool-config.ts +3 -0
  44. package/src/types.ts +54 -0
  45. package/src/utils/page-session.ts +254 -0
  46. package/plugin/skills/artifacts/skill.md +0 -70
@@ -0,0 +1,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
+ }