@1agh/maude 0.55.0 → 0.56.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 (47) hide show
  1. package/apps/studio/bin/_smart-frames.mjs +187 -20
  2. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  3. package/apps/studio/bin/_transcribe.mjs +40 -3
  4. package/apps/studio/bin/smoke.sh +7 -1
  5. package/apps/studio/client/app.jsx +31 -3
  6. package/apps/studio/client/panels/CloudBar.jsx +44 -5
  7. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  8. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  9. package/apps/studio/client/styles/4-components.css +72 -0
  10. package/apps/studio/cloud/endpoints.ts +56 -3
  11. package/apps/studio/collab/persistence.ts +29 -2
  12. package/apps/studio/config.schema.json +3 -3
  13. package/apps/studio/context.ts +41 -0
  14. package/apps/studio/dist/client.bundle.js +1464 -1464
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/generation/gemma-models.ts +312 -14
  18. package/apps/studio/generation/prefs.ts +7 -2
  19. package/apps/studio/generation/runtime-probe.ts +50 -0
  20. package/apps/studio/generation/whisper-models.ts +124 -0
  21. package/apps/studio/hmr-broadcast.ts +67 -0
  22. package/apps/studio/http.ts +196 -109
  23. package/apps/studio/input-router.tsx +55 -2
  24. package/apps/studio/server.ts +11 -9
  25. package/apps/studio/sync/autocommit.ts +61 -2
  26. package/apps/studio/sync/cell-pairing.ts +174 -0
  27. package/apps/studio/sync/codec.ts +11 -5
  28. package/apps/studio/sync/index.ts +239 -26
  29. package/apps/studio/sync/limits.ts +49 -0
  30. package/apps/studio/sync/loopback.ts +21 -0
  31. package/apps/studio/sync/projection.ts +47 -12
  32. package/apps/studio/sync/supervisor.ts +178 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +95 -0
  34. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  35. package/apps/studio/test/gemma-models.test.ts +245 -0
  36. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  37. package/apps/studio/test/input-router.test.ts +95 -0
  38. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  39. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  40. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  41. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  42. package/apps/studio/test/whisper-setup.test.ts +97 -0
  43. package/apps/studio/whats-new.json +27 -0
  44. package/apps/studio/ws.ts +9 -1
  45. package/cli/commands/kg.mjs +9 -2
  46. package/package.json +8 -8
  47. package/plugins/design/dependencies.json +21 -3
@@ -46,8 +46,15 @@ import { localizeGenAsset } from './generation/download.ts';
46
46
  import {
47
47
  downloadGemmaModel,
48
48
  ffmpegAvailable,
49
+ getOllamaModel,
49
50
  listGemmaModels,
51
+ listScoutModels,
52
+ mlxSetup,
50
53
  mlxVlmAvailable,
54
+ OLLAMA_RECOMMENDED_MODEL,
55
+ ollamaSetupOptions,
56
+ ollamaStatus,
57
+ pullOllamaModel,
51
58
  } from './generation/gemma-models.ts';
52
59
  import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
53
60
  import {
@@ -77,6 +84,8 @@ import {
77
84
  getWhisperModel,
78
85
  listWhisperModels,
79
86
  removeWhisperModel,
87
+ resolveAutoEngine,
88
+ whisperSetup,
80
89
  } from './generation/whisper-models.ts';
81
90
  import { createGitEndpoints } from './git/endpoints.ts';
82
91
  import { gitShowFile } from './git/service.ts';
@@ -289,12 +298,53 @@ export function sameOriginWrite(req: Request): boolean {
289
298
  const origin = req.headers.get('origin');
290
299
  if (!origin) return true; // non-browser / same-origin omitting Origin → allow
291
300
  try {
292
- return origin === new URL(req.url).origin;
301
+ // HOSTS, not full origins — the same reason isOwnShellOrigin (studio-proxy)
302
+ // compares host-only for the collab WS upgrade. In a cell, TLS terminates at
303
+ // the proxy, so the browser's `https://<host>` Origin reaches a studio that
304
+ // serves plaintext and computes `req.url` as `http://<host>`; a full-origin
305
+ // compare then rejects every legitimate shell write on the SCHEME alone
306
+ // (403 "cross-origin write rejected" — the second half of the inspector-edits
307
+ // RCA, found when the loopback fix uncovered it). Host+port still pins the
308
+ // origin: the port rides in `host`, and scheme was never the CSRF
309
+ // discriminator here — locally everything is http, and a real cross-site page
310
+ // has a different host.
311
+ return new URL(origin).host === new URL(req.url).host;
293
312
  } catch {
294
313
  return false;
295
314
  }
296
315
  }
297
316
 
317
+ /**
318
+ * The DNS-rebind guard, mode-aware — Cloud Phase 27, the inspector-edits RCA
319
+ * (`issue-cloud-inspector-edits-refused-by-loopback-guard`).
320
+ *
321
+ * `isLoopbackHost(req.headers.get('host'))` is right on a laptop: a malicious
322
+ * page that resolves `evil.com` → 127.0.0.1 makes the browser send a FOREIGN
323
+ * Host to the locally-bound dev server, and the guard rejects it. In a CELL that
324
+ * same check can NEVER pass — the hub's authenticating proxy rewrites Host to the
325
+ * project's public name (D4, `upstreamHeaders`) on purpose — so every shell edit
326
+ * (Inspector CSS, artboard ops, insert/delete/reorder, edit-attr/text) 403'd
327
+ * with "local request required (DNS-rebinding guard)". Annotations and comments
328
+ * were spared only because their handlers carry no such guard; that split of
329
+ * which routes worked was exactly the fingerprint of this bug.
330
+ *
331
+ * The cell doesn't NEED the Host check: the proxy already terminated the session,
332
+ * authorized it against the one role table (studio-manifest's `decide()`), and
333
+ * injects `x-maude-role` after stripping every inbound `x-maude-*` — so the
334
+ * header's PRESENCE is unforgeable proof the request came through the hub. Same
335
+ * `WORKSPACE ? x-maude-role` vouch the collab-WS upgrade already trusts
336
+ * (server.ts, RCA issue-cloud-live-collaboration-dead). Outside workspace mode
337
+ * this is the loopback check, verbatim.
338
+ *
339
+ * Safe against secret leakage by construction: a cell PRUNES every secret-bearing
340
+ * route family out of its table (`pruneForWorkspace` + the boot-assert), so the
341
+ * vouched path can only ever reach the project-editing surfaces that survive.
342
+ */
343
+ export function isTrustedRequestHost(req: Request): boolean {
344
+ if (isLoopbackHost(req.headers.get('host'))) return true;
345
+ return isWorkspaceMode() && !!req.headers.get('x-maude-role');
346
+ }
347
+
298
348
  /**
299
349
  * CSRF guard for a key-bearing / side-effecting GET (Task 2.5 audio-search —
300
350
  * ethical-hacker F1). A cross-site page can issue a `no-cors` GET to
@@ -1137,7 +1187,7 @@ export function createHttp(
1137
1187
  // but newly worth closing given what this diff makes it able to reveal.
1138
1188
  '/_api/acp/status': async (req: Request) => {
1139
1189
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1140
- if (!isLoopbackHost(req.headers.get('host')))
1190
+ if (!isTrustedRequestHost(req))
1141
1191
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1142
1192
  return Response.json(await probeAcpAvailabilityAuthed(), {
1143
1193
  headers: { 'Cache-Control': 'no-store' },
@@ -1155,7 +1205,7 @@ export function createHttp(
1155
1205
  // ChatPanel not-connected explainer + the Help-menu modal read this.
1156
1206
  '/_api/preflight': async (req: Request) => {
1157
1207
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1158
- if (!isLoopbackHost(req.headers.get('host')))
1208
+ if (!isTrustedRequestHost(req))
1159
1209
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1160
1210
  return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
1161
1211
  },
@@ -1169,7 +1219,7 @@ export function createHttp(
1169
1219
  // (SetupChecklist.jsx) polls this.
1170
1220
  '/_api/setup-readiness': async (req: Request) => {
1171
1221
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1172
- if (!isLoopbackHost(req.headers.get('host')))
1222
+ if (!isTrustedRequestHost(req))
1173
1223
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1174
1224
  return Response.json(await probeSetupReadiness(ctx), {
1175
1225
  headers: { 'Cache-Control': 'no-store' },
@@ -1185,7 +1235,7 @@ export function createHttp(
1185
1235
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1186
1236
  if (!sameOriginWrite(req))
1187
1237
  return new Response('cross-origin write rejected', { status: 403 });
1188
- if (!isLoopbackHost(req.headers.get('host')))
1238
+ if (!isTrustedRequestHost(req))
1189
1239
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1190
1240
  // Fire-and-forget — the install can run past Bun's default 10s idle
1191
1241
  // timeout on a slow network, so this returns as soon as the spawn is
@@ -1206,7 +1256,7 @@ export function createHttp(
1206
1256
  // two routes, which used the wrong guard.
1207
1257
  '/_api/claude/install-status': (req: Request) => {
1208
1258
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1209
- if (!isLoopbackHost(req.headers.get('host')))
1259
+ if (!isTrustedRequestHost(req))
1210
1260
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1211
1261
  return Response.json(getInstallState(), { headers: { 'Cache-Control': 'no-store' } });
1212
1262
  },
@@ -1223,7 +1273,7 @@ export function createHttp(
1223
1273
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1224
1274
  if (!sameOriginWrite(req))
1225
1275
  return new Response('cross-origin write rejected', { status: 403 });
1226
- if (!isLoopbackHost(req.headers.get('host')))
1276
+ if (!isTrustedRequestHost(req))
1227
1277
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1228
1278
  const result = startSignin();
1229
1279
  return Response.json(result, {
@@ -1235,7 +1285,7 @@ export function createHttp(
1235
1285
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1236
1286
  if (!sameOriginWrite(req))
1237
1287
  return new Response('cross-origin write rejected', { status: 403 });
1238
- if (!isLoopbackHost(req.headers.get('host')))
1288
+ if (!isTrustedRequestHost(req))
1239
1289
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1240
1290
  cancelSignin();
1241
1291
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
@@ -1245,7 +1295,7 @@ export function createHttp(
1245
1295
  // Narrowed fields only (never email/orgId/orgName — see login-state.ts).
1246
1296
  '/_api/claude/signin-status': async (req: Request) => {
1247
1297
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1248
- if (!isLoopbackHost(req.headers.get('host')))
1298
+ if (!isTrustedRequestHost(req))
1249
1299
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1250
1300
  const status = await getClaudeAuthStatus();
1251
1301
  return Response.json(
@@ -1263,7 +1313,7 @@ export function createHttp(
1263
1313
  // chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
1264
1314
  if (!sameOriginWrite(req))
1265
1315
  return new Response('cross-origin write rejected', { status: 403 });
1266
- if (!isLoopbackHost(req.headers.get('host')))
1316
+ if (!isTrustedRequestHost(req))
1267
1317
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1268
1318
  ctx.bus.emit('acp-focus', {});
1269
1319
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
@@ -1289,7 +1339,7 @@ export function createHttp(
1289
1339
  if (req.method === 'PATCH') {
1290
1340
  if (!sameOriginWrite(req))
1291
1341
  return new Response('cross-origin write rejected', { status: 403 });
1292
- if (!isLoopbackHost(req.headers.get('host')))
1342
+ if (!isTrustedRequestHost(req))
1293
1343
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1294
1344
  if (!id) return new Response('missing id', { status: 400 });
1295
1345
  let body: unknown;
@@ -1350,7 +1400,7 @@ export function createHttp(
1350
1400
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1351
1401
  if (!sameOriginWrite(req))
1352
1402
  return new Response('cross-origin write rejected', { status: 403 });
1353
- if (!isLoopbackHost(req.headers.get('host')))
1403
+ if (!isTrustedRequestHost(req))
1354
1404
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1355
1405
  const declared = Number(req.headers.get('content-length') || '0');
1356
1406
  if (Number.isFinite(declared) && declared > ASSET_MAX_BYTES) {
@@ -1457,7 +1507,7 @@ export function createHttp(
1457
1507
  // /_api/acp/status (a drive-by page must not read logs via DNS rebinding).
1458
1508
  '/_api/debug-bundle': (req: Request) => {
1459
1509
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1460
- if (!isLoopbackHost(req.headers.get('host')))
1510
+ if (!isTrustedRequestHost(req))
1461
1511
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1462
1512
  return Response.json(
1463
1513
  buildDebugBundle({
@@ -1490,7 +1540,7 @@ export function createHttp(
1490
1540
  '/_api/shell-shot': async (req: Request) => {
1491
1541
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1492
1542
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1493
- if (!isLoopbackHost(req.headers.get('host')))
1543
+ if (!isTrustedRequestHost(req))
1494
1544
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1495
1545
  // Target the very server handling this request — no _server.json read, so
1496
1546
  // a stale/absent state file can't point the capture at another instance.
@@ -1560,7 +1610,7 @@ export function createHttp(
1560
1610
  '/_api/report': async (req: Request) => {
1561
1611
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1562
1612
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1563
- if (!isLoopbackHost(req.headers.get('host')))
1613
+ if (!isTrustedRequestHost(req))
1564
1614
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1565
1615
  const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
1566
1616
  try {
@@ -1593,7 +1643,7 @@ export function createHttp(
1593
1643
  '/_api/report-fallback': async (req: Request) => {
1594
1644
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1595
1645
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1596
- if (!isLoopbackHost(req.headers.get('host')))
1646
+ if (!isTrustedRequestHost(req))
1597
1647
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1598
1648
  const body = await readJson<{ report?: unknown; screenshots?: unknown }>(req);
1599
1649
  if (!body || typeof body.report !== 'object' || body.report === null) {
@@ -1694,7 +1744,7 @@ export function createHttp(
1694
1744
  // the photo-store cap stack (sha8 validate + containment + validatePhotoEdit
1695
1745
  // + size cap) is the load-bearing mitigation. loopback-Host gates DNS-
1696
1746
  // rebinding on every method.
1697
- if (!isLoopbackHost(req.headers.get('host')))
1747
+ if (!isTrustedRequestHost(req))
1698
1748
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1699
1749
  const asset = new URL(req.url).searchParams.get('asset');
1700
1750
  if (req.method === 'GET') {
@@ -1732,7 +1782,7 @@ export function createHttp(
1732
1782
  // DNS-rebinding on every method; the footage-store cap stack (sha8/slug
1733
1783
  // validate + containment + validate{FootageAnalysis,Edl} + size cap) is the
1734
1784
  // load-bearing mitigation for the caller-derived write path.
1735
- if (!isLoopbackHost(req.headers.get('host')))
1785
+ if (!isTrustedRequestHost(req))
1736
1786
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1737
1787
  const params = new URL(req.url).searchParams;
1738
1788
  const asset = params.get('asset');
@@ -1775,7 +1825,7 @@ export function createHttp(
1775
1825
  // a rebound page shares this origin; the dev-server always binds 127.0.0.1,
1776
1826
  // so a real browser (Host: localhost:<port>) passes and only a rebound
1777
1827
  // foreign hostname 403s. (No sameOriginWrite here — GET.)
1778
- if (!isLoopbackHost(req.headers.get('host')))
1828
+ if (!isTrustedRequestHost(req))
1779
1829
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1780
1830
  const file = new URL(req.url).searchParams.get('file');
1781
1831
  const r = await api.loadCanvasSource(file);
@@ -1840,7 +1890,7 @@ export function createHttp(
1840
1890
  // slash-command driver omits Origin (→ allowed); a browser drive-by can't.
1841
1891
  if (!sameOriginWrite(req))
1842
1892
  return new Response('cross-origin write rejected', { status: 403 });
1843
- if (!isLoopbackHost(req.headers.get('host')))
1893
+ if (!isTrustedRequestHost(req))
1844
1894
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1845
1895
  const body = await readJson<{ file?: string; author?: string }>(req);
1846
1896
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
@@ -1860,7 +1910,7 @@ export function createHttp(
1860
1910
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1861
1911
  if (!sameOriginWrite(req))
1862
1912
  return new Response('cross-origin write rejected', { status: 403 });
1863
- if (!isLoopbackHost(req.headers.get('host')))
1913
+ if (!isTrustedRequestHost(req))
1864
1914
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1865
1915
  const body = await readJson<{ file?: string }>(req);
1866
1916
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
@@ -1876,7 +1926,7 @@ export function createHttp(
1876
1926
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1877
1927
  if (!sameOriginWrite(req))
1878
1928
  return new Response('cross-origin write rejected', { status: 403 });
1879
- if (!isLoopbackHost(req.headers.get('host')))
1929
+ if (!isTrustedRequestHost(req))
1880
1930
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1881
1931
  const body = await readJson<{ file?: string }>(req);
1882
1932
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
@@ -1937,7 +1987,7 @@ export function createHttp(
1937
1987
  if (req.method === 'DELETE' || req.method === 'POST') {
1938
1988
  if (!sameOriginWrite(req))
1939
1989
  return new Response('cross-origin write rejected', { status: 403 });
1940
- if (!isLoopbackHost(req.headers.get('host')))
1990
+ if (!isTrustedRequestHost(req))
1941
1991
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1942
1992
  }
1943
1993
  if (req.method === 'DELETE') {
@@ -2000,7 +2050,7 @@ export function createHttp(
2000
2050
  '/_api/fs-move': async (req: Request) => {
2001
2051
  if (!sameOriginWrite(req))
2002
2052
  return new Response('cross-origin write rejected', { status: 403 });
2003
- if (!isLoopbackHost(req.headers.get('host')))
2053
+ if (!isTrustedRequestHost(req))
2004
2054
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2005
2055
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2006
2056
  const body = await readJson<{ file?: unknown; toDir?: unknown }>(req, 4 * 1024);
@@ -2031,7 +2081,7 @@ export function createHttp(
2031
2081
  '/_api/fs-mkdir': async (req: Request) => {
2032
2082
  if (!sameOriginWrite(req))
2033
2083
  return new Response('cross-origin write rejected', { status: 403 });
2034
- if (!isLoopbackHost(req.headers.get('host')))
2084
+ if (!isTrustedRequestHost(req))
2035
2085
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2036
2086
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2037
2087
  const body = await readJson<{ parent?: unknown; name?: unknown }>(req, 4 * 1024);
@@ -2092,7 +2142,7 @@ export function createHttp(
2092
2142
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2093
2143
  if (!sameOriginWrite(req))
2094
2144
  return new Response('cross-origin write rejected', { status: 403 });
2095
- if (!isLoopbackHost(req.headers.get('host')))
2145
+ if (!isTrustedRequestHost(req))
2096
2146
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2097
2147
  const body = await readJson<unknown>(req, 8 * 1024);
2098
2148
  return gitJson(await gitApi.createBranch(body));
@@ -2102,7 +2152,7 @@ export function createHttp(
2102
2152
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2103
2153
  if (!sameOriginWrite(req))
2104
2154
  return new Response('cross-origin write rejected', { status: 403 });
2105
- if (!isLoopbackHost(req.headers.get('host')))
2155
+ if (!isTrustedRequestHost(req))
2106
2156
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2107
2157
  const body = await readJson<unknown>(req, 8 * 1024);
2108
2158
  return gitJson(await gitApi.checkout(body));
@@ -2114,7 +2164,7 @@ export function createHttp(
2114
2164
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2115
2165
  if (!sameOriginWrite(req))
2116
2166
  return new Response('cross-origin write rejected', { status: 403 });
2117
- if (!isLoopbackHost(req.headers.get('host')))
2167
+ if (!isTrustedRequestHost(req))
2118
2168
  return new Response('adding a draft requires a local request', { status: 403 });
2119
2169
  const body = await readJson<unknown>(req, 8 * 1024);
2120
2170
  return gitJson(await gitApi.fold(body));
@@ -2126,7 +2176,7 @@ export function createHttp(
2126
2176
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2127
2177
  if (!sameOriginWrite(req))
2128
2178
  return new Response('cross-origin write rejected', { status: 403 });
2129
- if (!isLoopbackHost(req.headers.get('host')))
2179
+ if (!isTrustedRequestHost(req))
2130
2180
  return new Response('refresh requires a local request', { status: 403 });
2131
2181
  const body = await readJson<unknown>(req, 8 * 1024);
2132
2182
  return gitJson(await gitApi.fetchRemote(body));
@@ -2136,7 +2186,7 @@ export function createHttp(
2136
2186
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2137
2187
  if (!sameOriginWrite(req))
2138
2188
  return new Response('cross-origin write rejected', { status: 403 });
2139
- if (!isLoopbackHost(req.headers.get('host')))
2189
+ if (!isTrustedRequestHost(req))
2140
2190
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2141
2191
  const body = await readJson<unknown>(req, 256 * 1024);
2142
2192
  return gitJson(await gitApi.commit(body));
@@ -2146,7 +2196,7 @@ export function createHttp(
2146
2196
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2147
2197
  if (!sameOriginWrite(req))
2148
2198
  return new Response('cross-origin write rejected', { status: 403 });
2149
- if (!isLoopbackHost(req.headers.get('host')))
2199
+ if (!isTrustedRequestHost(req))
2150
2200
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2151
2201
  const body = await readJson<unknown>(req, 256 * 1024);
2152
2202
  return gitJson(await gitApi.discard(body));
@@ -2156,11 +2206,11 @@ export function createHttp(
2156
2206
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2157
2207
  if (!sameOriginWrite(req))
2158
2208
  return new Response('cross-origin write rejected', { status: 403 });
2159
- if (!isLoopbackHost(req.headers.get('host')))
2209
+ if (!isTrustedRequestHost(req))
2160
2210
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2161
2211
  // Token-bearing: refuse anything not from a loopback Host (the server binds
2162
2212
  // 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
2163
- if (!isLoopbackHost(req.headers.get('host')))
2213
+ if (!isTrustedRequestHost(req))
2164
2214
  return new Response('publish requires a local request', { status: 403 });
2165
2215
  const body = await readJson<unknown>(req, 8 * 1024);
2166
2216
  return gitJson(await gitApi.push(body));
@@ -2170,7 +2220,7 @@ export function createHttp(
2170
2220
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2171
2221
  if (!sameOriginWrite(req))
2172
2222
  return new Response('cross-origin write rejected', { status: 403 });
2173
- if (!isLoopbackHost(req.headers.get('host')))
2223
+ if (!isTrustedRequestHost(req))
2174
2224
  return new Response('get latest requires a local request', { status: 403 });
2175
2225
  const body = await readJson<unknown>(req, 8 * 1024);
2176
2226
  return gitJson(await gitApi.pull(body));
@@ -2184,7 +2234,7 @@ export function createHttp(
2184
2234
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2185
2235
  if (!sameOriginWrite(req))
2186
2236
  return new Response('cross-origin write rejected', { status: 403 });
2187
- if (!isLoopbackHost(req.headers.get('host')))
2237
+ if (!isTrustedRequestHost(req))
2188
2238
  return new Response('resolve requires a local request', { status: 403 });
2189
2239
  const body = await readJson<unknown>(req, 8 * 1024);
2190
2240
  return gitJson(await gitApi.resolve(body));
@@ -2205,7 +2255,7 @@ export function createHttp(
2205
2255
  // whose 401 DELETES the stored credential — a confused deputy worth
2206
2256
  // closing (validate 2026-07-30, defender F6).
2207
2257
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
2208
- if (!isLoopbackHost(req.headers.get('host')))
2258
+ if (!isTrustedRequestHost(req))
2209
2259
  return new Response('local request required', { status: 403 });
2210
2260
  return gitJson(cloudApi.status());
2211
2261
  },
@@ -2213,7 +2263,7 @@ export function createHttp(
2213
2263
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2214
2264
  if (!sameOriginWrite(req))
2215
2265
  return new Response('cross-origin write rejected', { status: 403 });
2216
- if (!isLoopbackHost(req.headers.get('host')))
2266
+ if (!isTrustedRequestHost(req))
2217
2267
  return new Response('local request required', { status: 403 });
2218
2268
  return gitJson(await cloudApi.signinStart());
2219
2269
  },
@@ -2221,7 +2271,7 @@ export function createHttp(
2221
2271
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2222
2272
  if (!sameOriginWrite(req))
2223
2273
  return new Response('cross-origin write rejected', { status: 403 });
2224
- if (!isLoopbackHost(req.headers.get('host')))
2274
+ if (!isTrustedRequestHost(req))
2225
2275
  return new Response('local request required', { status: 403 });
2226
2276
  const body = (await req.json().catch(() => ({}))) as { deviceCode?: string };
2227
2277
  return gitJson(await cloudApi.signinPoll(String(body.deviceCode ?? '')));
@@ -2230,14 +2280,14 @@ export function createHttp(
2230
2280
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2231
2281
  if (!sameOriginWrite(req))
2232
2282
  return new Response('cross-origin write rejected', { status: 403 });
2233
- if (!isLoopbackHost(req.headers.get('host')))
2283
+ if (!isTrustedRequestHost(req))
2234
2284
  return new Response('local request required', { status: 403 });
2235
2285
  return gitJson(cloudApi.signout());
2236
2286
  },
2237
2287
  '/_api/cloud/projects': async (req: Request) => {
2238
2288
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2239
2289
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
2240
- if (!isLoopbackHost(req.headers.get('host')))
2290
+ if (!isTrustedRequestHost(req))
2241
2291
  return new Response('local request required', { status: 403 });
2242
2292
  return gitJson(await cloudApi.projects());
2243
2293
  },
@@ -2245,7 +2295,7 @@ export function createHttp(
2245
2295
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2246
2296
  if (!sameOriginWrite(req))
2247
2297
  return new Response('cross-origin write rejected', { status: 403 });
2248
- if (!isLoopbackHost(req.headers.get('host')))
2298
+ if (!isTrustedRequestHost(req))
2249
2299
  return new Response('local request required', { status: 403 });
2250
2300
  const body = (await req.json().catch(() => ({}))) as { project?: string };
2251
2301
  return gitJson(await cloudApi.attach(String(body.project ?? '')));
@@ -2257,7 +2307,7 @@ export function createHttp(
2257
2307
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2258
2308
  if (!sameOriginWrite(req))
2259
2309
  return new Response('cross-origin write rejected', { status: 403 });
2260
- if (!isLoopbackHost(req.headers.get('host')))
2310
+ if (!isTrustedRequestHost(req))
2261
2311
  return new Response('local request required', { status: 403 });
2262
2312
  const body = (await req.json().catch(() => ({}))) as { code?: string; project?: string };
2263
2313
  return gitJson(
@@ -2270,14 +2320,14 @@ export function createHttp(
2270
2320
 
2271
2321
  '/_api/github/identity': async (req: Request) => {
2272
2322
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2273
- if (!isLoopbackHost(req.headers.get('host')))
2323
+ if (!isTrustedRequestHost(req))
2274
2324
  return new Response('local request required', { status: 403 });
2275
2325
  return gitJson(await githubApi.identity());
2276
2326
  },
2277
2327
 
2278
2328
  '/_api/github/repos': async (req: Request) => {
2279
2329
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2280
- if (!isLoopbackHost(req.headers.get('host')))
2330
+ if (!isTrustedRequestHost(req))
2281
2331
  return new Response('local request required', { status: 403 });
2282
2332
  return gitJson(await githubApi.repos());
2283
2333
  },
@@ -2286,7 +2336,7 @@ export function createHttp(
2286
2336
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2287
2337
  if (!sameOriginWrite(req))
2288
2338
  return new Response('cross-origin write rejected', { status: 403 });
2289
- if (!isLoopbackHost(req.headers.get('host')))
2339
+ if (!isTrustedRequestHost(req))
2290
2340
  return new Response('local request required', { status: 403 });
2291
2341
  const body = await readJson<unknown>(req, 8 * 1024);
2292
2342
  return gitJson(await githubApi.createRepo(body));
@@ -2296,7 +2346,7 @@ export function createHttp(
2296
2346
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2297
2347
  if (!sameOriginWrite(req))
2298
2348
  return new Response('cross-origin write rejected', { status: 403 });
2299
- if (!isLoopbackHost(req.headers.get('host')))
2349
+ if (!isTrustedRequestHost(req))
2300
2350
  return new Response('local request required', { status: 403 });
2301
2351
  const body = await readJson<unknown>(req, 8 * 1024);
2302
2352
  return gitJson(await githubApi.invite(body));
@@ -2306,7 +2356,7 @@ export function createHttp(
2306
2356
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2307
2357
  if (!sameOriginWrite(req))
2308
2358
  return new Response('cross-origin write rejected', { status: 403 });
2309
- if (!isLoopbackHost(req.headers.get('host')))
2359
+ if (!isTrustedRequestHost(req))
2310
2360
  return new Response('local request required', { status: 403 });
2311
2361
  const body = await readJson<unknown>(req, 8 * 1024);
2312
2362
  return gitJson(await githubApi.clone(body));
@@ -2316,7 +2366,7 @@ export function createHttp(
2316
2366
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2317
2367
  if (!sameOriginWrite(req))
2318
2368
  return new Response('cross-origin write rejected', { status: 403 });
2319
- if (!isLoopbackHost(req.headers.get('host')))
2369
+ if (!isTrustedRequestHost(req))
2320
2370
  return new Response('local request required', { status: 403 });
2321
2371
  const body = await readJson<unknown>(req, 8 * 1024);
2322
2372
  return gitJson(await githubApi.createProject(body));
@@ -2331,7 +2381,7 @@ export function createHttp(
2331
2381
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2332
2382
  if (!sameOriginWrite(req))
2333
2383
  return new Response('cross-origin write rejected', { status: 403 });
2334
- if (!isLoopbackHost(req.headers.get('host')))
2384
+ if (!isTrustedRequestHost(req))
2335
2385
  return new Response('local request required', { status: 403 });
2336
2386
  const body = await readJson<unknown>(req, 8 * 1024);
2337
2387
  return gitJson(await githubApi.createLocalProject(body));
@@ -2344,7 +2394,7 @@ export function createHttp(
2344
2394
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2345
2395
  if (!sameOriginWrite(req))
2346
2396
  return new Response('cross-origin write rejected', { status: 403 });
2347
- if (!isLoopbackHost(req.headers.get('host')))
2397
+ if (!isTrustedRequestHost(req))
2348
2398
  return new Response('local request required', { status: 403 });
2349
2399
  const body = await readJson<unknown>(req, 8 * 1024);
2350
2400
  return gitJson(await githubApi.initDesign(body));
@@ -2359,7 +2409,7 @@ export function createHttp(
2359
2409
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2360
2410
  if (!sameOriginWrite(req))
2361
2411
  return new Response('cross-origin write rejected', { status: 403 });
2362
- if (!isLoopbackHost(req.headers.get('host')))
2412
+ if (!isTrustedRequestHost(req))
2363
2413
  return new Response('local request required', { status: 403 });
2364
2414
  const body = await readJson<unknown>(req, 8 * 1024);
2365
2415
  return gitJson(await linkHub(body));
@@ -2374,7 +2424,7 @@ export function createHttp(
2374
2424
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2375
2425
  if (!sameOriginWrite(req))
2376
2426
  return new Response('cross-origin write rejected', { status: 403 });
2377
- if (!isLoopbackHost(req.headers.get('host')))
2427
+ if (!isTrustedRequestHost(req))
2378
2428
  return new Response('local request required', { status: 403 });
2379
2429
  const body = await readJson<unknown>(req, 8 * 1024);
2380
2430
  const result = await signInToWorkspace((body ?? {}) as Record<string, unknown>);
@@ -2390,7 +2440,7 @@ export function createHttp(
2390
2440
  // for, and every route the canvas can reach is one more thing to reason about.
2391
2441
  '/_api/workspace/disclosure': async (req: Request) => {
2392
2442
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2393
- if (!isLoopbackHost(req.headers.get('host')))
2443
+ if (!isTrustedRequestHost(req))
2394
2444
  return new Response('local request required', { status: 403 });
2395
2445
  const url = new URL(req.url);
2396
2446
  const linked = ctx.cfg.linkedHub?.url ?? null;
@@ -2420,7 +2470,7 @@ export function createHttp(
2420
2470
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2421
2471
  if (!sameOriginWrite(req))
2422
2472
  return new Response('cross-origin write rejected', { status: 403 });
2423
- if (!isLoopbackHost(req.headers.get('host')))
2473
+ if (!isTrustedRequestHost(req))
2424
2474
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2425
2475
  const body = await readJson<{
2426
2476
  canvas?: unknown;
@@ -2453,7 +2503,7 @@ export function createHttp(
2453
2503
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2454
2504
  if (!sameOriginWrite(req))
2455
2505
  return new Response('cross-origin write rejected', { status: 403 });
2456
- if (!isLoopbackHost(req.headers.get('host')))
2506
+ if (!isTrustedRequestHost(req))
2457
2507
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2458
2508
  const body = await readJson<{
2459
2509
  canvas?: unknown;
@@ -2486,7 +2536,7 @@ export function createHttp(
2486
2536
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2487
2537
  if (!sameOriginWrite(req))
2488
2538
  return new Response('cross-origin write rejected', { status: 403 });
2489
- if (!isLoopbackHost(req.headers.get('host')))
2539
+ if (!isTrustedRequestHost(req))
2490
2540
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2491
2541
  const body = await readJson<{
2492
2542
  canvas?: unknown;
@@ -2522,7 +2572,7 @@ export function createHttp(
2522
2572
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2523
2573
  if (!sameOriginWrite(req))
2524
2574
  return new Response('cross-origin write rejected', { status: 403 });
2525
- if (!isLoopbackHost(req.headers.get('host')))
2575
+ if (!isTrustedRequestHost(req))
2526
2576
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2527
2577
  const body = await readJson<{
2528
2578
  canvas?: unknown;
@@ -2562,7 +2612,7 @@ export function createHttp(
2562
2612
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2563
2613
  if (!sameOriginWrite(req))
2564
2614
  return new Response('cross-origin write rejected', { status: 403 });
2565
- if (!isLoopbackHost(req.headers.get('host')))
2615
+ if (!isTrustedRequestHost(req))
2566
2616
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2567
2617
  const body = await readJson<{
2568
2618
  canvas?: unknown;
@@ -2598,7 +2648,7 @@ export function createHttp(
2598
2648
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2599
2649
  if (!sameOriginWrite(req))
2600
2650
  return new Response('cross-origin write rejected', { status: 403 });
2601
- if (!isLoopbackHost(req.headers.get('host')))
2651
+ if (!isTrustedRequestHost(req))
2602
2652
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2603
2653
  const body = await readJson<{
2604
2654
  canvas?: unknown;
@@ -2643,7 +2693,7 @@ export function createHttp(
2643
2693
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2644
2694
  if (!sameOriginWrite(req))
2645
2695
  return new Response('cross-origin write rejected', { status: 403 });
2646
- if (!isLoopbackHost(req.headers.get('host')))
2696
+ if (!isTrustedRequestHost(req))
2647
2697
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2648
2698
  const body = await readJson<{
2649
2699
  canvas?: unknown;
@@ -2673,7 +2723,7 @@ export function createHttp(
2673
2723
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2674
2724
  if (!sameOriginWrite(req))
2675
2725
  return new Response('cross-origin write rejected', { status: 403 });
2676
- if (!isLoopbackHost(req.headers.get('host')))
2726
+ if (!isTrustedRequestHost(req))
2677
2727
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2678
2728
  const body = await readJson<{
2679
2729
  canvas?: unknown;
@@ -2710,7 +2760,7 @@ export function createHttp(
2710
2760
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2711
2761
  if (!sameOriginWrite(req))
2712
2762
  return new Response('cross-origin write rejected', { status: 403 });
2713
- if (!isLoopbackHost(req.headers.get('host')))
2763
+ if (!isTrustedRequestHost(req))
2714
2764
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2715
2765
  const body = await readJson<{
2716
2766
  canvas?: unknown;
@@ -2743,7 +2793,7 @@ export function createHttp(
2743
2793
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2744
2794
  if (!sameOriginWrite(req))
2745
2795
  return new Response('cross-origin write rejected', { status: 403 });
2746
- if (!isLoopbackHost(req.headers.get('host')))
2796
+ if (!isTrustedRequestHost(req))
2747
2797
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2748
2798
  const body = await readJson<{
2749
2799
  canvas?: unknown;
@@ -2774,7 +2824,7 @@ export function createHttp(
2774
2824
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2775
2825
  if (!sameOriginWrite(req))
2776
2826
  return new Response('cross-origin write rejected', { status: 403 });
2777
- if (!isLoopbackHost(req.headers.get('host')))
2827
+ if (!isTrustedRequestHost(req))
2778
2828
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2779
2829
  const body = await readJson<{
2780
2830
  canvas?: unknown;
@@ -2808,7 +2858,7 @@ export function createHttp(
2808
2858
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2809
2859
  // DNS-rebinding guard — this GET echoes project source structure; loopback-
2810
2860
  // only Host (real browser passes, rebound hostname 403s). No sameOriginWrite (GET).
2811
- if (!isLoopbackHost(req.headers.get('host')))
2861
+ if (!isTrustedRequestHost(req))
2812
2862
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2813
2863
  const u = new URL(req.url);
2814
2864
  const result = await api.compClips({
@@ -2834,7 +2884,7 @@ export function createHttp(
2834
2884
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2835
2885
  if (!sameOriginWrite(req))
2836
2886
  return new Response('cross-origin write rejected', { status: 403 });
2837
- if (!isLoopbackHost(req.headers.get('host')))
2887
+ if (!isTrustedRequestHost(req))
2838
2888
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2839
2889
  const body = await readJson<{ canvas?: unknown; seq?: unknown; dir?: unknown }>(
2840
2890
  req,
@@ -2865,7 +2915,7 @@ export function createHttp(
2865
2915
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2866
2916
  if (!sameOriginWrite(req))
2867
2917
  return new Response('cross-origin write rejected', { status: 403 });
2868
- if (!isLoopbackHost(req.headers.get('host')))
2918
+ if (!isTrustedRequestHost(req))
2869
2919
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2870
2920
  const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2871
2921
  req,
@@ -2897,7 +2947,7 @@ export function createHttp(
2897
2947
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2898
2948
  if (!sameOriginWrite(req))
2899
2949
  return new Response('cross-origin write rejected', { status: 403 });
2900
- if (!isLoopbackHost(req.headers.get('host')))
2950
+ if (!isTrustedRequestHost(req))
2901
2951
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2902
2952
  const body = await readJson<{
2903
2953
  canvas?: unknown;
@@ -2932,7 +2982,7 @@ export function createHttp(
2932
2982
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2933
2983
  if (!sameOriginWrite(req))
2934
2984
  return new Response('cross-origin write rejected', { status: 403 });
2935
- if (!isLoopbackHost(req.headers.get('host')))
2985
+ if (!isTrustedRequestHost(req))
2936
2986
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2937
2987
  const body = await readJson<{
2938
2988
  canvas?: unknown;
@@ -2966,7 +3016,7 @@ export function createHttp(
2966
3016
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2967
3017
  if (!sameOriginWrite(req))
2968
3018
  return new Response('cross-origin write rejected', { status: 403 });
2969
- if (!isLoopbackHost(req.headers.get('host')))
3019
+ if (!isTrustedRequestHost(req))
2970
3020
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2971
3021
  const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2972
3022
  req,
@@ -2993,7 +3043,7 @@ export function createHttp(
2993
3043
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2994
3044
  if (!sameOriginWrite(req))
2995
3045
  return new Response('cross-origin write rejected', { status: 403 });
2996
- if (!isLoopbackHost(req.headers.get('host')))
3046
+ if (!isTrustedRequestHost(req))
2997
3047
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2998
3048
  const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2999
3049
  req,
@@ -3022,7 +3072,7 @@ export function createHttp(
3022
3072
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3023
3073
  if (!sameOriginWrite(req))
3024
3074
  return new Response('cross-origin write rejected', { status: 403 });
3025
- if (!isLoopbackHost(req.headers.get('host')))
3075
+ if (!isTrustedRequestHost(req))
3026
3076
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3027
3077
  const body = await readJson<{
3028
3078
  canvas?: unknown;
@@ -3056,7 +3106,7 @@ export function createHttp(
3056
3106
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3057
3107
  if (!sameOriginWrite(req))
3058
3108
  return new Response('cross-origin write rejected', { status: 403 });
3059
- if (!isLoopbackHost(req.headers.get('host')))
3109
+ if (!isTrustedRequestHost(req))
3060
3110
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3061
3111
  const body = await readJson<{
3062
3112
  canvas?: unknown;
@@ -3085,7 +3135,7 @@ export function createHttp(
3085
3135
  // + startCanvasServer routes (dual-allowlist, DDR-054). Loopback-Host gated
3086
3136
  // (DNS-rebinding); read-only, so no sameOriginWrite (GET).
3087
3137
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3088
- if (!isLoopbackHost(req.headers.get('host')))
3138
+ if (!isTrustedRequestHost(req))
3089
3139
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3090
3140
  const r = await api.listAssets();
3091
3141
  return Response.json(
@@ -3103,7 +3153,7 @@ export function createHttp(
3103
3153
  // routes (dual-allowlist, DDR-054). Loopback-Host gated (DNS-rebinding);
3104
3154
  // read-only, so no sameOriginWrite (GET).
3105
3155
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3106
- if (!isLoopbackHost(req.headers.get('host')))
3156
+ if (!isTrustedRequestHost(req))
3107
3157
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3108
3158
  const r = await api.listStickers();
3109
3159
  return Response.json(
@@ -3120,7 +3170,7 @@ export function createHttp(
3120
3170
  // from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist, DDR-054);
3121
3171
  // loopback-Host gated (DNS-rebinding); no sameOriginWrite (GET).
3122
3172
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3123
- if (!isLoopbackHost(req.headers.get('host')))
3173
+ if (!isTrustedRequestHost(req))
3124
3174
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3125
3175
  const url = new URL(req.url);
3126
3176
  const result = await api.editScopeOp({
@@ -3144,7 +3194,7 @@ export function createHttp(
3144
3194
  // CANVAS_SAFE_API + startCanvasServer routes (DDR-054); loopback-Host
3145
3195
  // gated; no sameOriginWrite (GET).
3146
3196
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3147
- if (!isLoopbackHost(req.headers.get('host')))
3197
+ if (!isTrustedRequestHost(req))
3148
3198
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3149
3199
  const url = new URL(req.url);
3150
3200
  const result = await api.componentMapOp({
@@ -3168,7 +3218,7 @@ export function createHttp(
3168
3218
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3169
3219
  if (!sameOriginWrite(req))
3170
3220
  return new Response('cross-origin write rejected', { status: 403 });
3171
- if (!isLoopbackHost(req.headers.get('host')))
3221
+ if (!isTrustedRequestHost(req))
3172
3222
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3173
3223
  const body = await readJson<{
3174
3224
  canvas?: unknown;
@@ -3200,7 +3250,7 @@ export function createHttp(
3200
3250
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3201
3251
  if (!sameOriginWrite(req))
3202
3252
  return new Response('cross-origin write rejected', { status: 403 });
3203
- if (!isLoopbackHost(req.headers.get('host')))
3253
+ if (!isTrustedRequestHost(req))
3204
3254
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3205
3255
  const body = await readJson<{
3206
3256
  canvas?: unknown;
@@ -3231,7 +3281,7 @@ export function createHttp(
3231
3281
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3232
3282
  if (!sameOriginWrite(req))
3233
3283
  return new Response('cross-origin write rejected', { status: 403 });
3234
- if (!isLoopbackHost(req.headers.get('host')))
3284
+ if (!isTrustedRequestHost(req))
3235
3285
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3236
3286
  const body = await readJson<{
3237
3287
  canvas?: unknown;
@@ -3265,7 +3315,7 @@ export function createHttp(
3265
3315
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3266
3316
  if (!sameOriginWrite(req))
3267
3317
  return new Response('cross-origin write rejected', { status: 403 });
3268
- if (!isLoopbackHost(req.headers.get('host')))
3318
+ if (!isTrustedRequestHost(req))
3269
3319
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3270
3320
  const body = await readJson<{
3271
3321
  canvas?: unknown;
@@ -3295,7 +3345,7 @@ export function createHttp(
3295
3345
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3296
3346
  if (!sameOriginWrite(req))
3297
3347
  return new Response('cross-origin write rejected', { status: 403 });
3298
- if (!isLoopbackHost(req.headers.get('host')))
3348
+ if (!isTrustedRequestHost(req))
3299
3349
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3300
3350
  const body = await readJson<{
3301
3351
  canvas?: unknown;
@@ -3326,7 +3376,7 @@ export function createHttp(
3326
3376
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3327
3377
  if (!sameOriginWrite(req))
3328
3378
  return new Response('cross-origin write rejected', { status: 403 });
3329
- if (!isLoopbackHost(req.headers.get('host')))
3379
+ if (!isTrustedRequestHost(req))
3330
3380
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3331
3381
  const body = await readJson<{
3332
3382
  canvas?: unknown;
@@ -3355,7 +3405,7 @@ export function createHttp(
3355
3405
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3356
3406
  if (!sameOriginWrite(req))
3357
3407
  return new Response('cross-origin write rejected', { status: 403 });
3358
- if (!isLoopbackHost(req.headers.get('host')))
3408
+ if (!isTrustedRequestHost(req))
3359
3409
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3360
3410
  const body = await readJson<{ canvas?: unknown; artboardId?: unknown }>(req, 8 * 1024);
3361
3411
  if (!body) return new Response('body required', { status: 400 });
@@ -3423,7 +3473,7 @@ export function createHttp(
3423
3473
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3424
3474
  if (!sameOriginWrite(req))
3425
3475
  return new Response('cross-origin write rejected', { status: 403 });
3426
- if (!isLoopbackHost(req.headers.get('host')))
3476
+ if (!isTrustedRequestHost(req))
3427
3477
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3428
3478
  const kind = (req.headers.get('x-import-kind') || 'svg').toLowerCase();
3429
3479
  if (kind !== 'svg') {
@@ -3490,7 +3540,7 @@ export function createHttp(
3490
3540
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3491
3541
  if (!sameOriginWrite(req))
3492
3542
  return new Response('cross-origin write rejected', { status: 403 });
3493
- if (!isLoopbackHost(req.headers.get('host')))
3543
+ if (!isTrustedRequestHost(req))
3494
3544
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3495
3545
  let body: { assetPath?: unknown };
3496
3546
  try {
@@ -3564,7 +3614,7 @@ export function createHttp(
3564
3614
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3565
3615
  if (!sameOriginWrite(req))
3566
3616
  return new Response('cross-origin write rejected', { status: 403 });
3567
- if (!isLoopbackHost(req.headers.get('host')))
3617
+ if (!isTrustedRequestHost(req))
3568
3618
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3569
3619
  const body = await readJson<{
3570
3620
  format?: unknown;
@@ -3608,7 +3658,7 @@ export function createHttp(
3608
3658
  // /_api/export today — DDR-060). loopback-Host gated on every method;
3609
3659
  // the mutating POST is additionally sameOriginWrite CSRF gated (same
3610
3660
  // /flow:done security fan-out fix as /_api/export above).
3611
- if (!isLoopbackHost(req.headers.get('host')))
3661
+ if (!isTrustedRequestHost(req))
3612
3662
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3613
3663
  if (req.method === 'GET') {
3614
3664
  return Response.json(
@@ -3656,7 +3706,7 @@ export function createHttp(
3656
3706
  // unguessable job-scoped UUID plus this guard closes the DNS-rebinding
3657
3707
  // angle the /flow:done security fan-out checked for).
3658
3708
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3659
- if (!isLoopbackHost(req.headers.get('host')))
3709
+ if (!isTrustedRequestHost(req))
3660
3710
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3661
3711
  const id = new URL(req.url).searchParams.get('id');
3662
3712
  if (!id) return new Response('id query param required', { status: 400 });
@@ -3684,7 +3734,7 @@ export function createHttp(
3684
3734
  // loopback-Host gated on every method; the mutating POST is additionally
3685
3735
  // sameOriginWrite CSRF-gated. See DDR-16x + the canvas-origin-gate test.
3686
3736
  '/_api/generate-jobs': async (req: Request) => {
3687
- if (!isLoopbackHost(req.headers.get('host')))
3737
+ if (!isTrustedRequestHost(req))
3688
3738
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3689
3739
  if (req.method === 'GET') {
3690
3740
  return Response.json(
@@ -3803,7 +3853,7 @@ export function createHttp(
3803
3853
  // keyUrl) + presence-only `configured` flags. NO secret ever crosses here.
3804
3854
  // MAIN-ORIGIN ONLY, loopback-gated.
3805
3855
  '/_api/generate/providers': async (req: Request) => {
3806
- if (!isLoopbackHost(req.headers.get('host')))
3856
+ if (!isTrustedRequestHost(req))
3807
3857
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3808
3858
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3809
3859
  const configured = new Set(configuredProviders());
@@ -3820,7 +3870,7 @@ export function createHttp(
3820
3870
  // ({configured:[...]}) — a key value is NEVER echoed back (mirrors
3821
3871
  // github_is_signed_in). MAIN-ORIGIN ONLY, loopback + sameOriginWrite gated.
3822
3872
  '/_api/generate/keys': async (req: Request) => {
3823
- if (!isLoopbackHost(req.headers.get('host')))
3873
+ if (!isTrustedRequestHost(req))
3824
3874
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3825
3875
  if (req.method === 'GET') {
3826
3876
  return Response.json(
@@ -3864,7 +3914,7 @@ export function createHttp(
3864
3914
  // ONLY, loopback + sameOriginWrite gated. NEVER touches a key — that's the
3865
3915
  // separate /_api/generate/keys route.
3866
3916
  '/_api/generate/prefs': async (req: Request) => {
3867
- if (!isLoopbackHost(req.headers.get('host')))
3917
+ if (!isTrustedRequestHost(req))
3868
3918
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3869
3919
  if (req.method === 'GET') {
3870
3920
  return Response.json(
@@ -3897,7 +3947,7 @@ export function createHttp(
3897
3947
  }
3898
3948
  const provider = body?.transcriptionProvider;
3899
3949
  if (!isTranscriptionProvider(provider))
3900
- return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
3950
+ return new Response('transcriptionProvider must be auto|whisper|elevenlabs|groq', {
3901
3951
  status: 400,
3902
3952
  });
3903
3953
  await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
@@ -3919,7 +3969,7 @@ export function createHttp(
3919
3969
  // prefs; POST merge-patches only the provided keys. MAIN-ORIGIN ONLY, loopback
3920
3970
  // + sameOriginWrite gated (never reachable from the untrusted canvas origin).
3921
3971
  '/_api/ui-prefs': async (req: Request) => {
3922
- if (!isLoopbackHost(req.headers.get('host')))
3972
+ if (!isTrustedRequestHost(req))
3923
3973
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3924
3974
  if (req.method === 'GET') {
3925
3975
  return Response.json(readUiPrefs(), { headers: { 'Cache-Control': 'no-store' } });
@@ -3976,7 +4026,7 @@ export function createHttp(
3976
4026
  // already paid), returning ranked reuse candidates. MAIN-ORIGIN ONLY,
3977
4027
  // loopback-gated (it resolves the provider key server-side).
3978
4028
  '/_api/generate/audio-search': async (req: Request) => {
3979
- if (!isLoopbackHost(req.headers.get('host')))
4029
+ if (!isTrustedRequestHost(req))
3980
4030
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3981
4031
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3982
4032
  // F1 (ethical-hacker) — this GET resolves the user's key + fans out to the
@@ -4024,7 +4074,7 @@ export function createHttp(
4024
4074
  // reused track is itself searchable. MAIN-ORIGIN ONLY, loopback +
4025
4075
  // sameOriginWrite gated.
4026
4076
  '/_api/generate/audio-reuse': async (req: Request) => {
4027
- if (!isLoopbackHost(req.headers.get('host')))
4077
+ if (!isTrustedRequestHost(req))
4028
4078
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4029
4079
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4030
4080
  if (!sameOriginWrite(req))
@@ -4064,11 +4114,21 @@ export function createHttp(
4064
4114
  // `maude design transcribe --provider whisper` auto-resolves it. MAIN-ORIGIN
4065
4115
  // ONLY, loopback + sameOriginWrite gated.
4066
4116
  '/_api/generate/whisper-model': async (req: Request) => {
4067
- if (!isLoopbackHost(req.headers.get('host')))
4117
+ if (!isTrustedRequestHost(req))
4068
4118
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4069
4119
  if (req.method === 'GET') {
4120
+ // `setup` tells the card whether the ENGINE is actually installed and
4121
+ // how to get it on THIS machine (no `brew install` without Homebrew);
4122
+ // `auto` reports what the automatic engine choice currently resolves
4123
+ // to, so the card can state it out loud rather than switch silently.
4124
+ const setup = whisperSetup();
4070
4125
  return Response.json(
4071
- { models: listWhisperModels(), downloading: whisperDownload },
4126
+ {
4127
+ models: listWhisperModels(),
4128
+ downloading: whisperDownload,
4129
+ setup,
4130
+ auto: resolveAutoEngine(configuredProviders(), setup.installed),
4131
+ },
4072
4132
  { headers: { 'Cache-Control': 'no-store' } }
4073
4133
  );
4074
4134
  }
@@ -4127,7 +4187,7 @@ export function createHttp(
4127
4187
  // which tier will run; POST {id} downloads one via huggingface_hub (gated on
4128
4188
  // mlx-vlm being installed — the model is useless without it). MAIN-ORIGIN ONLY.
4129
4189
  '/_api/generate/keyframe-model': async (req: Request) => {
4130
- if (!isLoopbackHost(req.headers.get('host')))
4190
+ if (!isTrustedRequestHost(req))
4131
4191
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4132
4192
  if (req.method === 'GET') {
4133
4193
  // The GET builds availability by spawning subprocesses (import mlx_vlm is
@@ -4137,12 +4197,28 @@ export function createHttp(
4137
4197
  // are also TTL-cached in gemma-models.ts as defence-in-depth.
4138
4198
  if (!sameOriginRead(req))
4139
4199
  return new Response('cross-origin read rejected', { status: 403 });
4200
+ const ollama = await ollamaStatus();
4140
4201
  return Response.json(
4141
4202
  {
4142
- models: listGemmaModels(),
4203
+ // Both runtimes' models in one list, each tagged with the runtime
4204
+ // it needs — the card enables the button for whichever runtime this
4205
+ // machine actually has, instead of a dead "Needs mlx-vlm".
4206
+ models: listScoutModels(ollama),
4143
4207
  downloading: keyframeDownload,
4144
4208
  mlxVlmAvailable: mlxVlmAvailable(),
4145
4209
  ffmpegAvailable: ffmpegAvailable(),
4210
+ // Setup routes that actually work on THIS machine, best first — no
4211
+ // `brew install` without Homebrew, no venv one-liner without
4212
+ // python3, and "start it" rather than "install it" when the binary
4213
+ // is already there. Computed from the SAME paths the probes check,
4214
+ // so the shown command and the detection can't drift.
4215
+ mlx: mlxSetup(),
4216
+ ollama: {
4217
+ ...ollama,
4218
+ recommendedModel: OLLAMA_RECOMMENDED_MODEL,
4219
+ pullCommand: `ollama pull ${OLLAMA_RECOMMENDED_MODEL}`,
4220
+ setup: ollamaSetupOptions(ollama),
4221
+ },
4146
4222
  },
4147
4223
  { headers: { 'Cache-Control': 'no-store' } }
4148
4224
  );
@@ -4158,23 +4234,34 @@ export function createHttp(
4158
4234
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4159
4235
  if (!sameOriginWrite(req))
4160
4236
  return new Response('cross-origin write rejected', { status: 403 });
4161
- if (!mlxVlmAvailable())
4237
+ const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
4238
+ const id = typeof body?.id === 'string' ? body.id : '';
4239
+ // One route, two runtimes: an `ollama:` id pulls through the local Ollama
4240
+ // server, anything else is an mlx HF snapshot. Each gates on ITS OWN
4241
+ // runtime — an Ollama user must not be blocked by a missing mlx-vlm.
4242
+ const isOllama = Boolean(getOllamaModel(id));
4243
+ if (!isOllama && !listGemmaModels().some((m) => m.id === id))
4244
+ return new Response('unknown model id', { status: 400 });
4245
+ if (isOllama) {
4246
+ const status = await ollamaStatus();
4247
+ if (!status.available)
4248
+ return new Response('Ollama is not running — start it (`ollama serve`) and try again.', {
4249
+ status: 400,
4250
+ });
4251
+ } else if (!mlxVlmAvailable())
4162
4252
  return new Response(
4163
- 'mlx-vlm not installed — the Gemma scout needs an Apple-Silicon Mac + `pip install mlx-vlm`.',
4253
+ '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
4254
  {
4165
4255
  status: 400,
4166
4256
  }
4167
4257
  );
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
4258
  if (keyframeDownload && !keyframeDownload.error)
4173
4259
  return new Response(`a download is already in progress (${keyframeDownload.id})`, {
4174
4260
  status: 409,
4175
4261
  });
4176
4262
  keyframeDownload = { id, received: 0, total: 0 };
4177
- void downloadGemmaModel(
4263
+ const run = isOllama ? pullOllamaModel : downloadGemmaModel;
4264
+ void run(
4178
4265
  id,
4179
4266
  (received, total) => {
4180
4267
  if (keyframeDownload && keyframeDownload.id === id) {
@@ -4212,7 +4299,7 @@ export function createHttp(
4212
4299
  // dataURLs of local footage → disclosure) or WRITE arbitrary JSON later
4213
4300
  // rendered as <img src> in the trusted shell. The loopback-host check is
4214
4301
  // the same backstop every sibling privileged route carries.
4215
- if (!isLoopbackHost(req.headers.get('host')))
4302
+ if (!isTrustedRequestHost(req))
4216
4303
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4217
4304
  const url = new URL(req.url);
4218
4305
  const key = url.searchParams.get('key') ?? '';