@1agh/maude 0.55.0 → 0.57.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 (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -17,7 +17,14 @@ import {
17
17
  startSignin,
18
18
  } from './acp/login-state.ts';
19
19
  import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
20
- import { deleteChat, listChats, readChatMessages, writeChatMeta } from './acp/transcript.ts';
20
+ import { activitySnapshot, runningChats } from './acp/running.ts';
21
+ import {
22
+ chatTranscriptSeq,
23
+ deleteChat,
24
+ listChats,
25
+ readChatMessages,
26
+ writeChatMeta,
27
+ } from './acp/transcript.ts';
21
28
  import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
29
  import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
30
  import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
@@ -46,8 +53,15 @@ import { localizeGenAsset } from './generation/download.ts';
46
53
  import {
47
54
  downloadGemmaModel,
48
55
  ffmpegAvailable,
56
+ getOllamaModel,
49
57
  listGemmaModels,
58
+ listScoutModels,
59
+ mlxSetup,
50
60
  mlxVlmAvailable,
61
+ OLLAMA_RECOMMENDED_MODEL,
62
+ ollamaSetupOptions,
63
+ ollamaStatus,
64
+ pullOllamaModel,
51
65
  } from './generation/gemma-models.ts';
52
66
  import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
53
67
  import {
@@ -77,6 +91,8 @@ import {
77
91
  getWhisperModel,
78
92
  listWhisperModels,
79
93
  removeWhisperModel,
94
+ resolveAutoEngine,
95
+ whisperSetup,
80
96
  } from './generation/whisper-models.ts';
81
97
  import { createGitEndpoints } from './git/endpoints.ts';
82
98
  import { gitShowFile } from './git/service.ts';
@@ -289,12 +305,53 @@ export function sameOriginWrite(req: Request): boolean {
289
305
  const origin = req.headers.get('origin');
290
306
  if (!origin) return true; // non-browser / same-origin omitting Origin → allow
291
307
  try {
292
- return origin === new URL(req.url).origin;
308
+ // HOSTS, not full origins — the same reason isOwnShellOrigin (studio-proxy)
309
+ // compares host-only for the collab WS upgrade. In a cell, TLS terminates at
310
+ // the proxy, so the browser's `https://<host>` Origin reaches a studio that
311
+ // serves plaintext and computes `req.url` as `http://<host>`; a full-origin
312
+ // compare then rejects every legitimate shell write on the SCHEME alone
313
+ // (403 "cross-origin write rejected" — the second half of the inspector-edits
314
+ // RCA, found when the loopback fix uncovered it). Host+port still pins the
315
+ // origin: the port rides in `host`, and scheme was never the CSRF
316
+ // discriminator here — locally everything is http, and a real cross-site page
317
+ // has a different host.
318
+ return new URL(origin).host === new URL(req.url).host;
293
319
  } catch {
294
320
  return false;
295
321
  }
296
322
  }
297
323
 
324
+ /**
325
+ * The DNS-rebind guard, mode-aware — Cloud Phase 27, the inspector-edits RCA
326
+ * (`issue-cloud-inspector-edits-refused-by-loopback-guard`).
327
+ *
328
+ * `isLoopbackHost(req.headers.get('host'))` is right on a laptop: a malicious
329
+ * page that resolves `evil.com` → 127.0.0.1 makes the browser send a FOREIGN
330
+ * Host to the locally-bound dev server, and the guard rejects it. In a CELL that
331
+ * same check can NEVER pass — the hub's authenticating proxy rewrites Host to the
332
+ * project's public name (D4, `upstreamHeaders`) on purpose — so every shell edit
333
+ * (Inspector CSS, artboard ops, insert/delete/reorder, edit-attr/text) 403'd
334
+ * with "local request required (DNS-rebinding guard)". Annotations and comments
335
+ * were spared only because their handlers carry no such guard; that split of
336
+ * which routes worked was exactly the fingerprint of this bug.
337
+ *
338
+ * The cell doesn't NEED the Host check: the proxy already terminated the session,
339
+ * authorized it against the one role table (studio-manifest's `decide()`), and
340
+ * injects `x-maude-role` after stripping every inbound `x-maude-*` — so the
341
+ * header's PRESENCE is unforgeable proof the request came through the hub. Same
342
+ * `WORKSPACE ? x-maude-role` vouch the collab-WS upgrade already trusts
343
+ * (server.ts, RCA issue-cloud-live-collaboration-dead). Outside workspace mode
344
+ * this is the loopback check, verbatim.
345
+ *
346
+ * Safe against secret leakage by construction: a cell PRUNES every secret-bearing
347
+ * route family out of its table (`pruneForWorkspace` + the boot-assert), so the
348
+ * vouched path can only ever reach the project-editing surfaces that survive.
349
+ */
350
+ export function isTrustedRequestHost(req: Request): boolean {
351
+ if (isLoopbackHost(req.headers.get('host'))) return true;
352
+ return isWorkspaceMode() && !!req.headers.get('x-maude-role');
353
+ }
354
+
298
355
  /**
299
356
  * CSRF guard for a key-bearing / side-effecting GET (Task 2.5 audio-search —
300
357
  * ethical-hacker F1). A cross-site page can issue a `no-cors` GET to
@@ -1137,7 +1194,7 @@ export function createHttp(
1137
1194
  // but newly worth closing given what this diff makes it able to reveal.
1138
1195
  '/_api/acp/status': async (req: Request) => {
1139
1196
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1140
- if (!isLoopbackHost(req.headers.get('host')))
1197
+ if (!isTrustedRequestHost(req))
1141
1198
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1142
1199
  return Response.json(await probeAcpAvailabilityAuthed(), {
1143
1200
  headers: { 'Cache-Control': 'no-store' },
@@ -1155,7 +1212,7 @@ export function createHttp(
1155
1212
  // ChatPanel not-connected explainer + the Help-menu modal read this.
1156
1213
  '/_api/preflight': async (req: Request) => {
1157
1214
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1158
- if (!isLoopbackHost(req.headers.get('host')))
1215
+ if (!isTrustedRequestHost(req))
1159
1216
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1160
1217
  return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
1161
1218
  },
@@ -1169,7 +1226,7 @@ export function createHttp(
1169
1226
  // (SetupChecklist.jsx) polls this.
1170
1227
  '/_api/setup-readiness': async (req: Request) => {
1171
1228
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1172
- if (!isLoopbackHost(req.headers.get('host')))
1229
+ if (!isTrustedRequestHost(req))
1173
1230
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1174
1231
  return Response.json(await probeSetupReadiness(ctx), {
1175
1232
  headers: { 'Cache-Control': 'no-store' },
@@ -1185,7 +1242,7 @@ export function createHttp(
1185
1242
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1186
1243
  if (!sameOriginWrite(req))
1187
1244
  return new Response('cross-origin write rejected', { status: 403 });
1188
- if (!isLoopbackHost(req.headers.get('host')))
1245
+ if (!isTrustedRequestHost(req))
1189
1246
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1190
1247
  // Fire-and-forget — the install can run past Bun's default 10s idle
1191
1248
  // timeout on a slow network, so this returns as soon as the spawn is
@@ -1206,7 +1263,7 @@ export function createHttp(
1206
1263
  // two routes, which used the wrong guard.
1207
1264
  '/_api/claude/install-status': (req: Request) => {
1208
1265
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1209
- if (!isLoopbackHost(req.headers.get('host')))
1266
+ if (!isTrustedRequestHost(req))
1210
1267
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1211
1268
  return Response.json(getInstallState(), { headers: { 'Cache-Control': 'no-store' } });
1212
1269
  },
@@ -1223,7 +1280,7 @@ export function createHttp(
1223
1280
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1224
1281
  if (!sameOriginWrite(req))
1225
1282
  return new Response('cross-origin write rejected', { status: 403 });
1226
- if (!isLoopbackHost(req.headers.get('host')))
1283
+ if (!isTrustedRequestHost(req))
1227
1284
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1228
1285
  const result = startSignin();
1229
1286
  return Response.json(result, {
@@ -1235,7 +1292,7 @@ export function createHttp(
1235
1292
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1236
1293
  if (!sameOriginWrite(req))
1237
1294
  return new Response('cross-origin write rejected', { status: 403 });
1238
- if (!isLoopbackHost(req.headers.get('host')))
1295
+ if (!isTrustedRequestHost(req))
1239
1296
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1240
1297
  cancelSignin();
1241
1298
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
@@ -1245,7 +1302,7 @@ export function createHttp(
1245
1302
  // Narrowed fields only (never email/orgId/orgName — see login-state.ts).
1246
1303
  '/_api/claude/signin-status': async (req: Request) => {
1247
1304
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1248
- if (!isLoopbackHost(req.headers.get('host')))
1305
+ if (!isTrustedRequestHost(req))
1249
1306
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1250
1307
  const status = await getClaudeAuthStatus();
1251
1308
  return Response.json(
@@ -1263,12 +1320,46 @@ export function createHttp(
1263
1320
  // chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
1264
1321
  if (!sameOriginWrite(req))
1265
1322
  return new Response('cross-origin write rejected', { status: 403 });
1266
- if (!isLoopbackHost(req.headers.get('host')))
1323
+ if (!isTrustedRequestHost(req))
1267
1324
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1268
1325
  ctx.bus.emit('acp-focus', {});
1269
1326
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
1270
1327
  },
1271
1328
 
1329
+ // Addendum Task 9 — "is a chat mid-turn right now?", asked by
1330
+ // RepoBranchSwitcher.jsx before any of its three `window.location.reload()`
1331
+ // call sites. A `git checkout` moves the worktree under a running agent
1332
+ // (read on `draft-a`, written back after the checkout to `main` — a silent
1333
+ // cross-branch clobber that `_history/`, being per-canvas-slug with no
1334
+ // branch awareness, cannot undo), so this gates a confirm rather than
1335
+ // reloading silently. MAIN-ORIGIN ONLY, read-only, no chat CONTENT — just
1336
+ // how many are busy, so it says nothing the canvas origin could want.
1337
+ '/_api/acp/running': (req: Request) => {
1338
+ if (!isTrustedRequestHost(req))
1339
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1340
+ const running = runningChats();
1341
+ return Response.json(
1342
+ { running: running.length, chats: running },
1343
+ { headers: { 'Cache-Control': 'no-store' } }
1344
+ );
1345
+ },
1346
+
1347
+ // feature-acp-turn-notifications Task 3 — the native shell's poller reads
1348
+ // this to decide whether to fire an OS notification for a NON-visible
1349
+ // project (Decision C). A sibling of `/_api/acp/running` above rather than
1350
+ // an extension of it — that route stays exactly as-is because the reaper's
1351
+ // `has_running_chat` (sidecar.rs) already depends on its `{running,chats}`
1352
+ // shape and has no reason to grow one. MAIN-ORIGIN ONLY, same gate, and —
1353
+ // load-bearing (Decision D) — NEVER a chat title, message text, or any
1354
+ // transcript content: just chat ids (opaque, already exposed by `running`
1355
+ // above) and a three-value state. Anyone adding a field here should ask
1356
+ // what it would look like rendered on a lock screen.
1357
+ '/_api/acp/activity': (req: Request) => {
1358
+ if (!isTrustedRequestHost(req))
1359
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1360
+ return Response.json(activitySnapshot(), { headers: { 'Cache-Control': 'no-store' } });
1361
+ },
1362
+
1272
1363
  // Phase 31 — repo-level chat list + history (for the chat switcher +
1273
1364
  // hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
1274
1365
  '/_api/acp/chats': () =>
@@ -1289,7 +1380,7 @@ export function createHttp(
1289
1380
  if (req.method === 'PATCH') {
1290
1381
  if (!sameOriginWrite(req))
1291
1382
  return new Response('cross-origin write rejected', { status: 403 });
1292
- if (!isLoopbackHost(req.headers.get('host')))
1383
+ if (!isTrustedRequestHost(req))
1293
1384
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1294
1385
  if (!id) return new Response('missing id', { status: 400 });
1295
1386
  let body: unknown;
@@ -1315,8 +1406,16 @@ export function createHttp(
1315
1406
  return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
1316
1407
  }
1317
1408
  if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
1409
+ // Addendum Task 8 — the re-attach seam. Read the sequence marker BEFORE
1410
+ // the messages: a line appended between the two reads then shows up in
1411
+ // neither, and the client's `attach` will replay it. Reading it after
1412
+ // would risk the opposite (a line counted but not returned), which the
1413
+ // client would silently drop as "already hydrated" — a hole in the feed.
1414
+ // Shipped as a header rather than a body field so the response SHAPE
1415
+ // stays the plain message array every existing consumer expects.
1416
+ const seq = chatTranscriptSeq(ctx.paths.designRoot, id);
1318
1417
  return Response.json(readChatMessages(ctx.paths.designRoot, id), {
1319
- headers: { 'Cache-Control': 'no-store' },
1418
+ headers: { 'Cache-Control': 'no-store', 'X-Maude-Chat-Seq': String(seq) },
1320
1419
  });
1321
1420
  },
1322
1421
 
@@ -1350,7 +1449,7 @@ export function createHttp(
1350
1449
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1351
1450
  if (!sameOriginWrite(req))
1352
1451
  return new Response('cross-origin write rejected', { status: 403 });
1353
- if (!isLoopbackHost(req.headers.get('host')))
1452
+ if (!isTrustedRequestHost(req))
1354
1453
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1355
1454
  const declared = Number(req.headers.get('content-length') || '0');
1356
1455
  if (Number.isFinite(declared) && declared > ASSET_MAX_BYTES) {
@@ -1457,7 +1556,7 @@ export function createHttp(
1457
1556
  // /_api/acp/status (a drive-by page must not read logs via DNS rebinding).
1458
1557
  '/_api/debug-bundle': (req: Request) => {
1459
1558
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1460
- if (!isLoopbackHost(req.headers.get('host')))
1559
+ if (!isTrustedRequestHost(req))
1461
1560
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1462
1561
  return Response.json(
1463
1562
  buildDebugBundle({
@@ -1490,7 +1589,7 @@ export function createHttp(
1490
1589
  '/_api/shell-shot': async (req: Request) => {
1491
1590
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1492
1591
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1493
- if (!isLoopbackHost(req.headers.get('host')))
1592
+ if (!isTrustedRequestHost(req))
1494
1593
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1495
1594
  // Target the very server handling this request — no _server.json read, so
1496
1595
  // a stale/absent state file can't point the capture at another instance.
@@ -1560,7 +1659,7 @@ export function createHttp(
1560
1659
  '/_api/report': async (req: Request) => {
1561
1660
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1562
1661
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1563
- if (!isLoopbackHost(req.headers.get('host')))
1662
+ if (!isTrustedRequestHost(req))
1564
1663
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1565
1664
  const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
1566
1665
  try {
@@ -1593,7 +1692,7 @@ export function createHttp(
1593
1692
  '/_api/report-fallback': async (req: Request) => {
1594
1693
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1595
1694
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1596
- if (!isLoopbackHost(req.headers.get('host')))
1695
+ if (!isTrustedRequestHost(req))
1597
1696
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1598
1697
  const body = await readJson<{ report?: unknown; screenshots?: unknown }>(req);
1599
1698
  if (!body || typeof body.report !== 'object' || body.report === null) {
@@ -1694,7 +1793,7 @@ export function createHttp(
1694
1793
  // the photo-store cap stack (sha8 validate + containment + validatePhotoEdit
1695
1794
  // + size cap) is the load-bearing mitigation. loopback-Host gates DNS-
1696
1795
  // rebinding on every method.
1697
- if (!isLoopbackHost(req.headers.get('host')))
1796
+ if (!isTrustedRequestHost(req))
1698
1797
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1699
1798
  const asset = new URL(req.url).searchParams.get('asset');
1700
1799
  if (req.method === 'GET') {
@@ -1732,7 +1831,7 @@ export function createHttp(
1732
1831
  // DNS-rebinding on every method; the footage-store cap stack (sha8/slug
1733
1832
  // validate + containment + validate{FootageAnalysis,Edl} + size cap) is the
1734
1833
  // load-bearing mitigation for the caller-derived write path.
1735
- if (!isLoopbackHost(req.headers.get('host')))
1834
+ if (!isTrustedRequestHost(req))
1736
1835
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1737
1836
  const params = new URL(req.url).searchParams;
1738
1837
  const asset = params.get('asset');
@@ -1775,7 +1874,7 @@ export function createHttp(
1775
1874
  // a rebound page shares this origin; the dev-server always binds 127.0.0.1,
1776
1875
  // so a real browser (Host: localhost:<port>) passes and only a rebound
1777
1876
  // foreign hostname 403s. (No sameOriginWrite here — GET.)
1778
- if (!isLoopbackHost(req.headers.get('host')))
1877
+ if (!isTrustedRequestHost(req))
1779
1878
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1780
1879
  const file = new URL(req.url).searchParams.get('file');
1781
1880
  const r = await api.loadCanvasSource(file);
@@ -1840,7 +1939,7 @@ export function createHttp(
1840
1939
  // slash-command driver omits Origin (→ allowed); a browser drive-by can't.
1841
1940
  if (!sameOriginWrite(req))
1842
1941
  return new Response('cross-origin write rejected', { status: 403 });
1843
- if (!isLoopbackHost(req.headers.get('host')))
1942
+ if (!isTrustedRequestHost(req))
1844
1943
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1845
1944
  const body = await readJson<{ file?: string; author?: string }>(req);
1846
1945
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
@@ -1860,7 +1959,7 @@ export function createHttp(
1860
1959
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1861
1960
  if (!sameOriginWrite(req))
1862
1961
  return new Response('cross-origin write rejected', { status: 403 });
1863
- if (!isLoopbackHost(req.headers.get('host')))
1962
+ if (!isTrustedRequestHost(req))
1864
1963
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1865
1964
  const body = await readJson<{ file?: string }>(req);
1866
1965
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
@@ -1876,7 +1975,7 @@ export function createHttp(
1876
1975
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1877
1976
  if (!sameOriginWrite(req))
1878
1977
  return new Response('cross-origin write rejected', { status: 403 });
1879
- if (!isLoopbackHost(req.headers.get('host')))
1978
+ if (!isTrustedRequestHost(req))
1880
1979
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1881
1980
  const body = await readJson<{ file?: string }>(req);
1882
1981
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
@@ -1937,7 +2036,7 @@ export function createHttp(
1937
2036
  if (req.method === 'DELETE' || req.method === 'POST') {
1938
2037
  if (!sameOriginWrite(req))
1939
2038
  return new Response('cross-origin write rejected', { status: 403 });
1940
- if (!isLoopbackHost(req.headers.get('host')))
2039
+ if (!isTrustedRequestHost(req))
1941
2040
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1942
2041
  }
1943
2042
  if (req.method === 'DELETE') {
@@ -2000,7 +2099,7 @@ export function createHttp(
2000
2099
  '/_api/fs-move': async (req: Request) => {
2001
2100
  if (!sameOriginWrite(req))
2002
2101
  return new Response('cross-origin write rejected', { status: 403 });
2003
- if (!isLoopbackHost(req.headers.get('host')))
2102
+ if (!isTrustedRequestHost(req))
2004
2103
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2005
2104
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2006
2105
  const body = await readJson<{ file?: unknown; toDir?: unknown }>(req, 4 * 1024);
@@ -2031,7 +2130,7 @@ export function createHttp(
2031
2130
  '/_api/fs-mkdir': async (req: Request) => {
2032
2131
  if (!sameOriginWrite(req))
2033
2132
  return new Response('cross-origin write rejected', { status: 403 });
2034
- if (!isLoopbackHost(req.headers.get('host')))
2133
+ if (!isTrustedRequestHost(req))
2035
2134
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2036
2135
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2037
2136
  const body = await readJson<{ parent?: unknown; name?: unknown }>(req, 4 * 1024);
@@ -2092,7 +2191,7 @@ export function createHttp(
2092
2191
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2093
2192
  if (!sameOriginWrite(req))
2094
2193
  return new Response('cross-origin write rejected', { status: 403 });
2095
- if (!isLoopbackHost(req.headers.get('host')))
2194
+ if (!isTrustedRequestHost(req))
2096
2195
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2097
2196
  const body = await readJson<unknown>(req, 8 * 1024);
2098
2197
  return gitJson(await gitApi.createBranch(body));
@@ -2102,7 +2201,7 @@ export function createHttp(
2102
2201
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2103
2202
  if (!sameOriginWrite(req))
2104
2203
  return new Response('cross-origin write rejected', { status: 403 });
2105
- if (!isLoopbackHost(req.headers.get('host')))
2204
+ if (!isTrustedRequestHost(req))
2106
2205
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2107
2206
  const body = await readJson<unknown>(req, 8 * 1024);
2108
2207
  return gitJson(await gitApi.checkout(body));
@@ -2114,7 +2213,7 @@ export function createHttp(
2114
2213
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2115
2214
  if (!sameOriginWrite(req))
2116
2215
  return new Response('cross-origin write rejected', { status: 403 });
2117
- if (!isLoopbackHost(req.headers.get('host')))
2216
+ if (!isTrustedRequestHost(req))
2118
2217
  return new Response('adding a draft requires a local request', { status: 403 });
2119
2218
  const body = await readJson<unknown>(req, 8 * 1024);
2120
2219
  return gitJson(await gitApi.fold(body));
@@ -2126,7 +2225,7 @@ export function createHttp(
2126
2225
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2127
2226
  if (!sameOriginWrite(req))
2128
2227
  return new Response('cross-origin write rejected', { status: 403 });
2129
- if (!isLoopbackHost(req.headers.get('host')))
2228
+ if (!isTrustedRequestHost(req))
2130
2229
  return new Response('refresh requires a local request', { status: 403 });
2131
2230
  const body = await readJson<unknown>(req, 8 * 1024);
2132
2231
  return gitJson(await gitApi.fetchRemote(body));
@@ -2136,7 +2235,7 @@ export function createHttp(
2136
2235
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2137
2236
  if (!sameOriginWrite(req))
2138
2237
  return new Response('cross-origin write rejected', { status: 403 });
2139
- if (!isLoopbackHost(req.headers.get('host')))
2238
+ if (!isTrustedRequestHost(req))
2140
2239
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2141
2240
  const body = await readJson<unknown>(req, 256 * 1024);
2142
2241
  return gitJson(await gitApi.commit(body));
@@ -2146,7 +2245,7 @@ export function createHttp(
2146
2245
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2147
2246
  if (!sameOriginWrite(req))
2148
2247
  return new Response('cross-origin write rejected', { status: 403 });
2149
- if (!isLoopbackHost(req.headers.get('host')))
2248
+ if (!isTrustedRequestHost(req))
2150
2249
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2151
2250
  const body = await readJson<unknown>(req, 256 * 1024);
2152
2251
  return gitJson(await gitApi.discard(body));
@@ -2156,11 +2255,11 @@ export function createHttp(
2156
2255
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2157
2256
  if (!sameOriginWrite(req))
2158
2257
  return new Response('cross-origin write rejected', { status: 403 });
2159
- if (!isLoopbackHost(req.headers.get('host')))
2258
+ if (!isTrustedRequestHost(req))
2160
2259
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2161
2260
  // Token-bearing: refuse anything not from a loopback Host (the server binds
2162
2261
  // 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
2163
- if (!isLoopbackHost(req.headers.get('host')))
2262
+ if (!isTrustedRequestHost(req))
2164
2263
  return new Response('publish requires a local request', { status: 403 });
2165
2264
  const body = await readJson<unknown>(req, 8 * 1024);
2166
2265
  return gitJson(await gitApi.push(body));
@@ -2170,7 +2269,7 @@ export function createHttp(
2170
2269
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2171
2270
  if (!sameOriginWrite(req))
2172
2271
  return new Response('cross-origin write rejected', { status: 403 });
2173
- if (!isLoopbackHost(req.headers.get('host')))
2272
+ if (!isTrustedRequestHost(req))
2174
2273
  return new Response('get latest requires a local request', { status: 403 });
2175
2274
  const body = await readJson<unknown>(req, 8 * 1024);
2176
2275
  return gitJson(await gitApi.pull(body));
@@ -2184,7 +2283,7 @@ export function createHttp(
2184
2283
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2185
2284
  if (!sameOriginWrite(req))
2186
2285
  return new Response('cross-origin write rejected', { status: 403 });
2187
- if (!isLoopbackHost(req.headers.get('host')))
2286
+ if (!isTrustedRequestHost(req))
2188
2287
  return new Response('resolve requires a local request', { status: 403 });
2189
2288
  const body = await readJson<unknown>(req, 8 * 1024);
2190
2289
  return gitJson(await gitApi.resolve(body));
@@ -2205,7 +2304,7 @@ export function createHttp(
2205
2304
  // whose 401 DELETES the stored credential — a confused deputy worth
2206
2305
  // closing (validate 2026-07-30, defender F6).
2207
2306
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
2208
- if (!isLoopbackHost(req.headers.get('host')))
2307
+ if (!isTrustedRequestHost(req))
2209
2308
  return new Response('local request required', { status: 403 });
2210
2309
  return gitJson(cloudApi.status());
2211
2310
  },
@@ -2213,7 +2312,7 @@ export function createHttp(
2213
2312
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2214
2313
  if (!sameOriginWrite(req))
2215
2314
  return new Response('cross-origin write rejected', { status: 403 });
2216
- if (!isLoopbackHost(req.headers.get('host')))
2315
+ if (!isTrustedRequestHost(req))
2217
2316
  return new Response('local request required', { status: 403 });
2218
2317
  return gitJson(await cloudApi.signinStart());
2219
2318
  },
@@ -2221,7 +2320,7 @@ export function createHttp(
2221
2320
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2222
2321
  if (!sameOriginWrite(req))
2223
2322
  return new Response('cross-origin write rejected', { status: 403 });
2224
- if (!isLoopbackHost(req.headers.get('host')))
2323
+ if (!isTrustedRequestHost(req))
2225
2324
  return new Response('local request required', { status: 403 });
2226
2325
  const body = (await req.json().catch(() => ({}))) as { deviceCode?: string };
2227
2326
  return gitJson(await cloudApi.signinPoll(String(body.deviceCode ?? '')));
@@ -2230,14 +2329,14 @@ export function createHttp(
2230
2329
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2231
2330
  if (!sameOriginWrite(req))
2232
2331
  return new Response('cross-origin write rejected', { status: 403 });
2233
- if (!isLoopbackHost(req.headers.get('host')))
2332
+ if (!isTrustedRequestHost(req))
2234
2333
  return new Response('local request required', { status: 403 });
2235
2334
  return gitJson(cloudApi.signout());
2236
2335
  },
2237
2336
  '/_api/cloud/projects': async (req: Request) => {
2238
2337
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2239
2338
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
2240
- if (!isLoopbackHost(req.headers.get('host')))
2339
+ if (!isTrustedRequestHost(req))
2241
2340
  return new Response('local request required', { status: 403 });
2242
2341
  return gitJson(await cloudApi.projects());
2243
2342
  },
@@ -2245,7 +2344,7 @@ export function createHttp(
2245
2344
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2246
2345
  if (!sameOriginWrite(req))
2247
2346
  return new Response('cross-origin write rejected', { status: 403 });
2248
- if (!isLoopbackHost(req.headers.get('host')))
2347
+ if (!isTrustedRequestHost(req))
2249
2348
  return new Response('local request required', { status: 403 });
2250
2349
  const body = (await req.json().catch(() => ({}))) as { project?: string };
2251
2350
  return gitJson(await cloudApi.attach(String(body.project ?? '')));
@@ -2257,7 +2356,7 @@ export function createHttp(
2257
2356
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2258
2357
  if (!sameOriginWrite(req))
2259
2358
  return new Response('cross-origin write rejected', { status: 403 });
2260
- if (!isLoopbackHost(req.headers.get('host')))
2359
+ if (!isTrustedRequestHost(req))
2261
2360
  return new Response('local request required', { status: 403 });
2262
2361
  const body = (await req.json().catch(() => ({}))) as { code?: string; project?: string };
2263
2362
  return gitJson(
@@ -2270,14 +2369,14 @@ export function createHttp(
2270
2369
 
2271
2370
  '/_api/github/identity': async (req: Request) => {
2272
2371
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2273
- if (!isLoopbackHost(req.headers.get('host')))
2372
+ if (!isTrustedRequestHost(req))
2274
2373
  return new Response('local request required', { status: 403 });
2275
2374
  return gitJson(await githubApi.identity());
2276
2375
  },
2277
2376
 
2278
2377
  '/_api/github/repos': async (req: Request) => {
2279
2378
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2280
- if (!isLoopbackHost(req.headers.get('host')))
2379
+ if (!isTrustedRequestHost(req))
2281
2380
  return new Response('local request required', { status: 403 });
2282
2381
  return gitJson(await githubApi.repos());
2283
2382
  },
@@ -2286,7 +2385,7 @@ export function createHttp(
2286
2385
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2287
2386
  if (!sameOriginWrite(req))
2288
2387
  return new Response('cross-origin write rejected', { status: 403 });
2289
- if (!isLoopbackHost(req.headers.get('host')))
2388
+ if (!isTrustedRequestHost(req))
2290
2389
  return new Response('local request required', { status: 403 });
2291
2390
  const body = await readJson<unknown>(req, 8 * 1024);
2292
2391
  return gitJson(await githubApi.createRepo(body));
@@ -2296,7 +2395,7 @@ export function createHttp(
2296
2395
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2297
2396
  if (!sameOriginWrite(req))
2298
2397
  return new Response('cross-origin write rejected', { status: 403 });
2299
- if (!isLoopbackHost(req.headers.get('host')))
2398
+ if (!isTrustedRequestHost(req))
2300
2399
  return new Response('local request required', { status: 403 });
2301
2400
  const body = await readJson<unknown>(req, 8 * 1024);
2302
2401
  return gitJson(await githubApi.invite(body));
@@ -2306,7 +2405,7 @@ export function createHttp(
2306
2405
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2307
2406
  if (!sameOriginWrite(req))
2308
2407
  return new Response('cross-origin write rejected', { status: 403 });
2309
- if (!isLoopbackHost(req.headers.get('host')))
2408
+ if (!isTrustedRequestHost(req))
2310
2409
  return new Response('local request required', { status: 403 });
2311
2410
  const body = await readJson<unknown>(req, 8 * 1024);
2312
2411
  return gitJson(await githubApi.clone(body));
@@ -2316,7 +2415,7 @@ export function createHttp(
2316
2415
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2317
2416
  if (!sameOriginWrite(req))
2318
2417
  return new Response('cross-origin write rejected', { status: 403 });
2319
- if (!isLoopbackHost(req.headers.get('host')))
2418
+ if (!isTrustedRequestHost(req))
2320
2419
  return new Response('local request required', { status: 403 });
2321
2420
  const body = await readJson<unknown>(req, 8 * 1024);
2322
2421
  return gitJson(await githubApi.createProject(body));
@@ -2331,7 +2430,7 @@ export function createHttp(
2331
2430
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2332
2431
  if (!sameOriginWrite(req))
2333
2432
  return new Response('cross-origin write rejected', { status: 403 });
2334
- if (!isLoopbackHost(req.headers.get('host')))
2433
+ if (!isTrustedRequestHost(req))
2335
2434
  return new Response('local request required', { status: 403 });
2336
2435
  const body = await readJson<unknown>(req, 8 * 1024);
2337
2436
  return gitJson(await githubApi.createLocalProject(body));
@@ -2344,7 +2443,7 @@ export function createHttp(
2344
2443
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2345
2444
  if (!sameOriginWrite(req))
2346
2445
  return new Response('cross-origin write rejected', { status: 403 });
2347
- if (!isLoopbackHost(req.headers.get('host')))
2446
+ if (!isTrustedRequestHost(req))
2348
2447
  return new Response('local request required', { status: 403 });
2349
2448
  const body = await readJson<unknown>(req, 8 * 1024);
2350
2449
  return gitJson(await githubApi.initDesign(body));
@@ -2359,7 +2458,7 @@ export function createHttp(
2359
2458
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2360
2459
  if (!sameOriginWrite(req))
2361
2460
  return new Response('cross-origin write rejected', { status: 403 });
2362
- if (!isLoopbackHost(req.headers.get('host')))
2461
+ if (!isTrustedRequestHost(req))
2363
2462
  return new Response('local request required', { status: 403 });
2364
2463
  const body = await readJson<unknown>(req, 8 * 1024);
2365
2464
  return gitJson(await linkHub(body));
@@ -2374,7 +2473,7 @@ export function createHttp(
2374
2473
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2375
2474
  if (!sameOriginWrite(req))
2376
2475
  return new Response('cross-origin write rejected', { status: 403 });
2377
- if (!isLoopbackHost(req.headers.get('host')))
2476
+ if (!isTrustedRequestHost(req))
2378
2477
  return new Response('local request required', { status: 403 });
2379
2478
  const body = await readJson<unknown>(req, 8 * 1024);
2380
2479
  const result = await signInToWorkspace((body ?? {}) as Record<string, unknown>);
@@ -2390,7 +2489,7 @@ export function createHttp(
2390
2489
  // for, and every route the canvas can reach is one more thing to reason about.
2391
2490
  '/_api/workspace/disclosure': async (req: Request) => {
2392
2491
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2393
- if (!isLoopbackHost(req.headers.get('host')))
2492
+ if (!isTrustedRequestHost(req))
2394
2493
  return new Response('local request required', { status: 403 });
2395
2494
  const url = new URL(req.url);
2396
2495
  const linked = ctx.cfg.linkedHub?.url ?? null;
@@ -2420,7 +2519,7 @@ export function createHttp(
2420
2519
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2421
2520
  if (!sameOriginWrite(req))
2422
2521
  return new Response('cross-origin write rejected', { status: 403 });
2423
- if (!isLoopbackHost(req.headers.get('host')))
2522
+ if (!isTrustedRequestHost(req))
2424
2523
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2425
2524
  const body = await readJson<{
2426
2525
  canvas?: unknown;
@@ -2453,7 +2552,7 @@ export function createHttp(
2453
2552
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2454
2553
  if (!sameOriginWrite(req))
2455
2554
  return new Response('cross-origin write rejected', { status: 403 });
2456
- if (!isLoopbackHost(req.headers.get('host')))
2555
+ if (!isTrustedRequestHost(req))
2457
2556
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2458
2557
  const body = await readJson<{
2459
2558
  canvas?: unknown;
@@ -2486,7 +2585,7 @@ export function createHttp(
2486
2585
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2487
2586
  if (!sameOriginWrite(req))
2488
2587
  return new Response('cross-origin write rejected', { status: 403 });
2489
- if (!isLoopbackHost(req.headers.get('host')))
2588
+ if (!isTrustedRequestHost(req))
2490
2589
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2491
2590
  const body = await readJson<{
2492
2591
  canvas?: unknown;
@@ -2522,7 +2621,7 @@ export function createHttp(
2522
2621
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2523
2622
  if (!sameOriginWrite(req))
2524
2623
  return new Response('cross-origin write rejected', { status: 403 });
2525
- if (!isLoopbackHost(req.headers.get('host')))
2624
+ if (!isTrustedRequestHost(req))
2526
2625
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2527
2626
  const body = await readJson<{
2528
2627
  canvas?: unknown;
@@ -2562,7 +2661,7 @@ export function createHttp(
2562
2661
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2563
2662
  if (!sameOriginWrite(req))
2564
2663
  return new Response('cross-origin write rejected', { status: 403 });
2565
- if (!isLoopbackHost(req.headers.get('host')))
2664
+ if (!isTrustedRequestHost(req))
2566
2665
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2567
2666
  const body = await readJson<{
2568
2667
  canvas?: unknown;
@@ -2598,7 +2697,7 @@ export function createHttp(
2598
2697
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2599
2698
  if (!sameOriginWrite(req))
2600
2699
  return new Response('cross-origin write rejected', { status: 403 });
2601
- if (!isLoopbackHost(req.headers.get('host')))
2700
+ if (!isTrustedRequestHost(req))
2602
2701
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2603
2702
  const body = await readJson<{
2604
2703
  canvas?: unknown;
@@ -2643,7 +2742,7 @@ export function createHttp(
2643
2742
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2644
2743
  if (!sameOriginWrite(req))
2645
2744
  return new Response('cross-origin write rejected', { status: 403 });
2646
- if (!isLoopbackHost(req.headers.get('host')))
2745
+ if (!isTrustedRequestHost(req))
2647
2746
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2648
2747
  const body = await readJson<{
2649
2748
  canvas?: unknown;
@@ -2673,7 +2772,7 @@ export function createHttp(
2673
2772
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2674
2773
  if (!sameOriginWrite(req))
2675
2774
  return new Response('cross-origin write rejected', { status: 403 });
2676
- if (!isLoopbackHost(req.headers.get('host')))
2775
+ if (!isTrustedRequestHost(req))
2677
2776
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2678
2777
  const body = await readJson<{
2679
2778
  canvas?: unknown;
@@ -2710,7 +2809,7 @@ export function createHttp(
2710
2809
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2711
2810
  if (!sameOriginWrite(req))
2712
2811
  return new Response('cross-origin write rejected', { status: 403 });
2713
- if (!isLoopbackHost(req.headers.get('host')))
2812
+ if (!isTrustedRequestHost(req))
2714
2813
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2715
2814
  const body = await readJson<{
2716
2815
  canvas?: unknown;
@@ -2743,7 +2842,7 @@ export function createHttp(
2743
2842
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2744
2843
  if (!sameOriginWrite(req))
2745
2844
  return new Response('cross-origin write rejected', { status: 403 });
2746
- if (!isLoopbackHost(req.headers.get('host')))
2845
+ if (!isTrustedRequestHost(req))
2747
2846
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2748
2847
  const body = await readJson<{
2749
2848
  canvas?: unknown;
@@ -2774,7 +2873,7 @@ export function createHttp(
2774
2873
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2775
2874
  if (!sameOriginWrite(req))
2776
2875
  return new Response('cross-origin write rejected', { status: 403 });
2777
- if (!isLoopbackHost(req.headers.get('host')))
2876
+ if (!isTrustedRequestHost(req))
2778
2877
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2779
2878
  const body = await readJson<{
2780
2879
  canvas?: unknown;
@@ -2808,7 +2907,7 @@ export function createHttp(
2808
2907
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2809
2908
  // DNS-rebinding guard — this GET echoes project source structure; loopback-
2810
2909
  // only Host (real browser passes, rebound hostname 403s). No sameOriginWrite (GET).
2811
- if (!isLoopbackHost(req.headers.get('host')))
2910
+ if (!isTrustedRequestHost(req))
2812
2911
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2813
2912
  const u = new URL(req.url);
2814
2913
  const result = await api.compClips({
@@ -2834,7 +2933,7 @@ export function createHttp(
2834
2933
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2835
2934
  if (!sameOriginWrite(req))
2836
2935
  return new Response('cross-origin write rejected', { status: 403 });
2837
- if (!isLoopbackHost(req.headers.get('host')))
2936
+ if (!isTrustedRequestHost(req))
2838
2937
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2839
2938
  const body = await readJson<{ canvas?: unknown; seq?: unknown; dir?: unknown }>(
2840
2939
  req,
@@ -2865,7 +2964,7 @@ export function createHttp(
2865
2964
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2866
2965
  if (!sameOriginWrite(req))
2867
2966
  return new Response('cross-origin write rejected', { status: 403 });
2868
- if (!isLoopbackHost(req.headers.get('host')))
2967
+ if (!isTrustedRequestHost(req))
2869
2968
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2870
2969
  const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2871
2970
  req,
@@ -2897,7 +2996,7 @@ export function createHttp(
2897
2996
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2898
2997
  if (!sameOriginWrite(req))
2899
2998
  return new Response('cross-origin write rejected', { status: 403 });
2900
- if (!isLoopbackHost(req.headers.get('host')))
2999
+ if (!isTrustedRequestHost(req))
2901
3000
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2902
3001
  const body = await readJson<{
2903
3002
  canvas?: unknown;
@@ -2932,7 +3031,7 @@ export function createHttp(
2932
3031
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2933
3032
  if (!sameOriginWrite(req))
2934
3033
  return new Response('cross-origin write rejected', { status: 403 });
2935
- if (!isLoopbackHost(req.headers.get('host')))
3034
+ if (!isTrustedRequestHost(req))
2936
3035
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2937
3036
  const body = await readJson<{
2938
3037
  canvas?: unknown;
@@ -2966,7 +3065,7 @@ export function createHttp(
2966
3065
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2967
3066
  if (!sameOriginWrite(req))
2968
3067
  return new Response('cross-origin write rejected', { status: 403 });
2969
- if (!isLoopbackHost(req.headers.get('host')))
3068
+ if (!isTrustedRequestHost(req))
2970
3069
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2971
3070
  const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2972
3071
  req,
@@ -2993,7 +3092,7 @@ export function createHttp(
2993
3092
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2994
3093
  if (!sameOriginWrite(req))
2995
3094
  return new Response('cross-origin write rejected', { status: 403 });
2996
- if (!isLoopbackHost(req.headers.get('host')))
3095
+ if (!isTrustedRequestHost(req))
2997
3096
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2998
3097
  const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2999
3098
  req,
@@ -3022,7 +3121,7 @@ export function createHttp(
3022
3121
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3023
3122
  if (!sameOriginWrite(req))
3024
3123
  return new Response('cross-origin write rejected', { status: 403 });
3025
- if (!isLoopbackHost(req.headers.get('host')))
3124
+ if (!isTrustedRequestHost(req))
3026
3125
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3027
3126
  const body = await readJson<{
3028
3127
  canvas?: unknown;
@@ -3056,7 +3155,7 @@ export function createHttp(
3056
3155
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3057
3156
  if (!sameOriginWrite(req))
3058
3157
  return new Response('cross-origin write rejected', { status: 403 });
3059
- if (!isLoopbackHost(req.headers.get('host')))
3158
+ if (!isTrustedRequestHost(req))
3060
3159
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3061
3160
  const body = await readJson<{
3062
3161
  canvas?: unknown;
@@ -3085,7 +3184,7 @@ export function createHttp(
3085
3184
  // + startCanvasServer routes (dual-allowlist, DDR-054). Loopback-Host gated
3086
3185
  // (DNS-rebinding); read-only, so no sameOriginWrite (GET).
3087
3186
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3088
- if (!isLoopbackHost(req.headers.get('host')))
3187
+ if (!isTrustedRequestHost(req))
3089
3188
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3090
3189
  const r = await api.listAssets();
3091
3190
  return Response.json(
@@ -3103,7 +3202,7 @@ export function createHttp(
3103
3202
  // routes (dual-allowlist, DDR-054). Loopback-Host gated (DNS-rebinding);
3104
3203
  // read-only, so no sameOriginWrite (GET).
3105
3204
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3106
- if (!isLoopbackHost(req.headers.get('host')))
3205
+ if (!isTrustedRequestHost(req))
3107
3206
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3108
3207
  const r = await api.listStickers();
3109
3208
  return Response.json(
@@ -3120,7 +3219,7 @@ export function createHttp(
3120
3219
  // from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist, DDR-054);
3121
3220
  // loopback-Host gated (DNS-rebinding); no sameOriginWrite (GET).
3122
3221
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3123
- if (!isLoopbackHost(req.headers.get('host')))
3222
+ if (!isTrustedRequestHost(req))
3124
3223
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3125
3224
  const url = new URL(req.url);
3126
3225
  const result = await api.editScopeOp({
@@ -3144,7 +3243,7 @@ export function createHttp(
3144
3243
  // CANVAS_SAFE_API + startCanvasServer routes (DDR-054); loopback-Host
3145
3244
  // gated; no sameOriginWrite (GET).
3146
3245
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3147
- if (!isLoopbackHost(req.headers.get('host')))
3246
+ if (!isTrustedRequestHost(req))
3148
3247
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3149
3248
  const url = new URL(req.url);
3150
3249
  const result = await api.componentMapOp({
@@ -3168,7 +3267,7 @@ export function createHttp(
3168
3267
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3169
3268
  if (!sameOriginWrite(req))
3170
3269
  return new Response('cross-origin write rejected', { status: 403 });
3171
- if (!isLoopbackHost(req.headers.get('host')))
3270
+ if (!isTrustedRequestHost(req))
3172
3271
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3173
3272
  const body = await readJson<{
3174
3273
  canvas?: unknown;
@@ -3200,7 +3299,7 @@ export function createHttp(
3200
3299
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3201
3300
  if (!sameOriginWrite(req))
3202
3301
  return new Response('cross-origin write rejected', { status: 403 });
3203
- if (!isLoopbackHost(req.headers.get('host')))
3302
+ if (!isTrustedRequestHost(req))
3204
3303
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3205
3304
  const body = await readJson<{
3206
3305
  canvas?: unknown;
@@ -3231,7 +3330,7 @@ export function createHttp(
3231
3330
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3232
3331
  if (!sameOriginWrite(req))
3233
3332
  return new Response('cross-origin write rejected', { status: 403 });
3234
- if (!isLoopbackHost(req.headers.get('host')))
3333
+ if (!isTrustedRequestHost(req))
3235
3334
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3236
3335
  const body = await readJson<{
3237
3336
  canvas?: unknown;
@@ -3265,7 +3364,7 @@ export function createHttp(
3265
3364
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3266
3365
  if (!sameOriginWrite(req))
3267
3366
  return new Response('cross-origin write rejected', { status: 403 });
3268
- if (!isLoopbackHost(req.headers.get('host')))
3367
+ if (!isTrustedRequestHost(req))
3269
3368
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3270
3369
  const body = await readJson<{
3271
3370
  canvas?: unknown;
@@ -3295,7 +3394,7 @@ export function createHttp(
3295
3394
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3296
3395
  if (!sameOriginWrite(req))
3297
3396
  return new Response('cross-origin write rejected', { status: 403 });
3298
- if (!isLoopbackHost(req.headers.get('host')))
3397
+ if (!isTrustedRequestHost(req))
3299
3398
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3300
3399
  const body = await readJson<{
3301
3400
  canvas?: unknown;
@@ -3326,7 +3425,7 @@ export function createHttp(
3326
3425
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3327
3426
  if (!sameOriginWrite(req))
3328
3427
  return new Response('cross-origin write rejected', { status: 403 });
3329
- if (!isLoopbackHost(req.headers.get('host')))
3428
+ if (!isTrustedRequestHost(req))
3330
3429
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3331
3430
  const body = await readJson<{
3332
3431
  canvas?: unknown;
@@ -3355,7 +3454,7 @@ export function createHttp(
3355
3454
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3356
3455
  if (!sameOriginWrite(req))
3357
3456
  return new Response('cross-origin write rejected', { status: 403 });
3358
- if (!isLoopbackHost(req.headers.get('host')))
3457
+ if (!isTrustedRequestHost(req))
3359
3458
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3360
3459
  const body = await readJson<{ canvas?: unknown; artboardId?: unknown }>(req, 8 * 1024);
3361
3460
  if (!body) return new Response('body required', { status: 400 });
@@ -3423,7 +3522,7 @@ export function createHttp(
3423
3522
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3424
3523
  if (!sameOriginWrite(req))
3425
3524
  return new Response('cross-origin write rejected', { status: 403 });
3426
- if (!isLoopbackHost(req.headers.get('host')))
3525
+ if (!isTrustedRequestHost(req))
3427
3526
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3428
3527
  const kind = (req.headers.get('x-import-kind') || 'svg').toLowerCase();
3429
3528
  if (kind !== 'svg') {
@@ -3490,7 +3589,7 @@ export function createHttp(
3490
3589
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3491
3590
  if (!sameOriginWrite(req))
3492
3591
  return new Response('cross-origin write rejected', { status: 403 });
3493
- if (!isLoopbackHost(req.headers.get('host')))
3592
+ if (!isTrustedRequestHost(req))
3494
3593
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3495
3594
  let body: { assetPath?: unknown };
3496
3595
  try {
@@ -3564,7 +3663,7 @@ export function createHttp(
3564
3663
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3565
3664
  if (!sameOriginWrite(req))
3566
3665
  return new Response('cross-origin write rejected', { status: 403 });
3567
- if (!isLoopbackHost(req.headers.get('host')))
3666
+ if (!isTrustedRequestHost(req))
3568
3667
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3569
3668
  const body = await readJson<{
3570
3669
  format?: unknown;
@@ -3608,7 +3707,7 @@ export function createHttp(
3608
3707
  // /_api/export today — DDR-060). loopback-Host gated on every method;
3609
3708
  // the mutating POST is additionally sameOriginWrite CSRF gated (same
3610
3709
  // /flow:done security fan-out fix as /_api/export above).
3611
- if (!isLoopbackHost(req.headers.get('host')))
3710
+ if (!isTrustedRequestHost(req))
3612
3711
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3613
3712
  if (req.method === 'GET') {
3614
3713
  return Response.json(
@@ -3656,7 +3755,7 @@ export function createHttp(
3656
3755
  // unguessable job-scoped UUID plus this guard closes the DNS-rebinding
3657
3756
  // angle the /flow:done security fan-out checked for).
3658
3757
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3659
- if (!isLoopbackHost(req.headers.get('host')))
3758
+ if (!isTrustedRequestHost(req))
3660
3759
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3661
3760
  const id = new URL(req.url).searchParams.get('id');
3662
3761
  if (!id) return new Response('id query param required', { status: 400 });
@@ -3684,7 +3783,7 @@ export function createHttp(
3684
3783
  // loopback-Host gated on every method; the mutating POST is additionally
3685
3784
  // sameOriginWrite CSRF-gated. See DDR-16x + the canvas-origin-gate test.
3686
3785
  '/_api/generate-jobs': async (req: Request) => {
3687
- if (!isLoopbackHost(req.headers.get('host')))
3786
+ if (!isTrustedRequestHost(req))
3688
3787
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3689
3788
  if (req.method === 'GET') {
3690
3789
  return Response.json(
@@ -3803,7 +3902,7 @@ export function createHttp(
3803
3902
  // keyUrl) + presence-only `configured` flags. NO secret ever crosses here.
3804
3903
  // MAIN-ORIGIN ONLY, loopback-gated.
3805
3904
  '/_api/generate/providers': async (req: Request) => {
3806
- if (!isLoopbackHost(req.headers.get('host')))
3905
+ if (!isTrustedRequestHost(req))
3807
3906
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3808
3907
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3809
3908
  const configured = new Set(configuredProviders());
@@ -3820,7 +3919,7 @@ export function createHttp(
3820
3919
  // ({configured:[...]}) — a key value is NEVER echoed back (mirrors
3821
3920
  // github_is_signed_in). MAIN-ORIGIN ONLY, loopback + sameOriginWrite gated.
3822
3921
  '/_api/generate/keys': async (req: Request) => {
3823
- if (!isLoopbackHost(req.headers.get('host')))
3922
+ if (!isTrustedRequestHost(req))
3824
3923
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3825
3924
  if (req.method === 'GET') {
3826
3925
  return Response.json(
@@ -3864,7 +3963,7 @@ export function createHttp(
3864
3963
  // ONLY, loopback + sameOriginWrite gated. NEVER touches a key — that's the
3865
3964
  // separate /_api/generate/keys route.
3866
3965
  '/_api/generate/prefs': async (req: Request) => {
3867
- if (!isLoopbackHost(req.headers.get('host')))
3966
+ if (!isTrustedRequestHost(req))
3868
3967
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3869
3968
  if (req.method === 'GET') {
3870
3969
  return Response.json(
@@ -3897,7 +3996,7 @@ export function createHttp(
3897
3996
  }
3898
3997
  const provider = body?.transcriptionProvider;
3899
3998
  if (!isTranscriptionProvider(provider))
3900
- return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
3999
+ return new Response('transcriptionProvider must be auto|whisper|elevenlabs|groq', {
3901
4000
  status: 400,
3902
4001
  });
3903
4002
  await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
@@ -3919,7 +4018,7 @@ export function createHttp(
3919
4018
  // prefs; POST merge-patches only the provided keys. MAIN-ORIGIN ONLY, loopback
3920
4019
  // + sameOriginWrite gated (never reachable from the untrusted canvas origin).
3921
4020
  '/_api/ui-prefs': async (req: Request) => {
3922
- if (!isLoopbackHost(req.headers.get('host')))
4021
+ if (!isTrustedRequestHost(req))
3923
4022
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3924
4023
  if (req.method === 'GET') {
3925
4024
  return Response.json(readUiPrefs(), { headers: { 'Cache-Control': 'no-store' } });
@@ -3976,7 +4075,7 @@ export function createHttp(
3976
4075
  // already paid), returning ranked reuse candidates. MAIN-ORIGIN ONLY,
3977
4076
  // loopback-gated (it resolves the provider key server-side).
3978
4077
  '/_api/generate/audio-search': async (req: Request) => {
3979
- if (!isLoopbackHost(req.headers.get('host')))
4078
+ if (!isTrustedRequestHost(req))
3980
4079
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3981
4080
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3982
4081
  // F1 (ethical-hacker) — this GET resolves the user's key + fans out to the
@@ -4024,7 +4123,7 @@ export function createHttp(
4024
4123
  // reused track is itself searchable. MAIN-ORIGIN ONLY, loopback +
4025
4124
  // sameOriginWrite gated.
4026
4125
  '/_api/generate/audio-reuse': async (req: Request) => {
4027
- if (!isLoopbackHost(req.headers.get('host')))
4126
+ if (!isTrustedRequestHost(req))
4028
4127
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4029
4128
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4030
4129
  if (!sameOriginWrite(req))
@@ -4064,11 +4163,21 @@ export function createHttp(
4064
4163
  // `maude design transcribe --provider whisper` auto-resolves it. MAIN-ORIGIN
4065
4164
  // ONLY, loopback + sameOriginWrite gated.
4066
4165
  '/_api/generate/whisper-model': async (req: Request) => {
4067
- if (!isLoopbackHost(req.headers.get('host')))
4166
+ if (!isTrustedRequestHost(req))
4068
4167
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4069
4168
  if (req.method === 'GET') {
4169
+ // `setup` tells the card whether the ENGINE is actually installed and
4170
+ // how to get it on THIS machine (no `brew install` without Homebrew);
4171
+ // `auto` reports what the automatic engine choice currently resolves
4172
+ // to, so the card can state it out loud rather than switch silently.
4173
+ const setup = whisperSetup();
4070
4174
  return Response.json(
4071
- { models: listWhisperModels(), downloading: whisperDownload },
4175
+ {
4176
+ models: listWhisperModels(),
4177
+ downloading: whisperDownload,
4178
+ setup,
4179
+ auto: resolveAutoEngine(configuredProviders(), setup.installed),
4180
+ },
4072
4181
  { headers: { 'Cache-Control': 'no-store' } }
4073
4182
  );
4074
4183
  }
@@ -4127,7 +4236,7 @@ export function createHttp(
4127
4236
  // which tier will run; POST {id} downloads one via huggingface_hub (gated on
4128
4237
  // mlx-vlm being installed — the model is useless without it). MAIN-ORIGIN ONLY.
4129
4238
  '/_api/generate/keyframe-model': async (req: Request) => {
4130
- if (!isLoopbackHost(req.headers.get('host')))
4239
+ if (!isTrustedRequestHost(req))
4131
4240
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4132
4241
  if (req.method === 'GET') {
4133
4242
  // The GET builds availability by spawning subprocesses (import mlx_vlm is
@@ -4137,12 +4246,28 @@ export function createHttp(
4137
4246
  // are also TTL-cached in gemma-models.ts as defence-in-depth.
4138
4247
  if (!sameOriginRead(req))
4139
4248
  return new Response('cross-origin read rejected', { status: 403 });
4249
+ const ollama = await ollamaStatus();
4140
4250
  return Response.json(
4141
4251
  {
4142
- models: listGemmaModels(),
4252
+ // Both runtimes' models in one list, each tagged with the runtime
4253
+ // it needs — the card enables the button for whichever runtime this
4254
+ // machine actually has, instead of a dead "Needs mlx-vlm".
4255
+ models: listScoutModels(ollama),
4143
4256
  downloading: keyframeDownload,
4144
4257
  mlxVlmAvailable: mlxVlmAvailable(),
4145
4258
  ffmpegAvailable: ffmpegAvailable(),
4259
+ // Setup routes that actually work on THIS machine, best first — no
4260
+ // `brew install` without Homebrew, no venv one-liner without
4261
+ // python3, and "start it" rather than "install it" when the binary
4262
+ // is already there. Computed from the SAME paths the probes check,
4263
+ // so the shown command and the detection can't drift.
4264
+ mlx: mlxSetup(),
4265
+ ollama: {
4266
+ ...ollama,
4267
+ recommendedModel: OLLAMA_RECOMMENDED_MODEL,
4268
+ pullCommand: `ollama pull ${OLLAMA_RECOMMENDED_MODEL}`,
4269
+ setup: ollamaSetupOptions(ollama),
4270
+ },
4146
4271
  },
4147
4272
  { headers: { 'Cache-Control': 'no-store' } }
4148
4273
  );
@@ -4158,23 +4283,34 @@ export function createHttp(
4158
4283
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4159
4284
  if (!sameOriginWrite(req))
4160
4285
  return new Response('cross-origin write rejected', { status: 403 });
4161
- if (!mlxVlmAvailable())
4286
+ const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
4287
+ const id = typeof body?.id === 'string' ? body.id : '';
4288
+ // One route, two runtimes: an `ollama:` id pulls through the local Ollama
4289
+ // server, anything else is an mlx HF snapshot. Each gates on ITS OWN
4290
+ // runtime — an Ollama user must not be blocked by a missing mlx-vlm.
4291
+ const isOllama = Boolean(getOllamaModel(id));
4292
+ if (!isOllama && !listGemmaModels().some((m) => m.id === id))
4293
+ return new Response('unknown model id', { status: 400 });
4294
+ if (isOllama) {
4295
+ const status = await ollamaStatus();
4296
+ if (!status.available)
4297
+ return new Response('Ollama is not running — start it (`ollama serve`) and try again.', {
4298
+ status: 400,
4299
+ });
4300
+ } else if (!mlxVlmAvailable())
4162
4301
  return new Response(
4163
- 'mlx-vlm not installed — the Gemma scout needs an Apple-Silicon Mac + `pip install mlx-vlm`.',
4302
+ 'mlx-vlm not installed — run the install command shown in Settings → Video. (Ollama users don’t need this download: `ollama pull` fetches its own models.)',
4164
4303
  {
4165
4304
  status: 400,
4166
4305
  }
4167
4306
  );
4168
- const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
4169
- const id = typeof body?.id === 'string' ? body.id : '';
4170
- if (!listGemmaModels().some((m) => m.id === id))
4171
- return new Response('unknown model id', { status: 400 });
4172
4307
  if (keyframeDownload && !keyframeDownload.error)
4173
4308
  return new Response(`a download is already in progress (${keyframeDownload.id})`, {
4174
4309
  status: 409,
4175
4310
  });
4176
4311
  keyframeDownload = { id, received: 0, total: 0 };
4177
- void downloadGemmaModel(
4312
+ const run = isOllama ? pullOllamaModel : downloadGemmaModel;
4313
+ void run(
4178
4314
  id,
4179
4315
  (received, total) => {
4180
4316
  if (keyframeDownload && keyframeDownload.id === id) {
@@ -4212,7 +4348,7 @@ export function createHttp(
4212
4348
  // dataURLs of local footage → disclosure) or WRITE arbitrary JSON later
4213
4349
  // rendered as <img src> in the trusted shell. The loopback-host check is
4214
4350
  // the same backstop every sibling privileged route carries.
4215
- if (!isLoopbackHost(req.headers.get('host')))
4351
+ if (!isTrustedRequestHost(req))
4216
4352
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4217
4353
  const url = new URL(req.url);
4218
4354
  const key = url.searchParams.get('key') ?? '';