@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,53 @@
1
+ // github/token.ts — fetch the GitHub access token from the Tauri shell's
2
+ // loopback bridge at request time (Phase 28 / DDR-108).
3
+ //
4
+ // The token lives ONLY in the OS keychain, owned by the Tauri shell. The dev-server
5
+ // is a separate process and can't read the keychain, so it fetches the token over
6
+ // loopback (keychain.rs → GET /_tauri/github-token, 127.0.0.1-only, per-launch key).
7
+ // The endpoint + key arrive as env from the shell at spawn (sidecar.rs):
8
+ // MAUDE_TOKEN_ENDPOINT=http://127.0.0.1:<port>/_tauri/github-token
9
+ // MAUDE_TOKEN_KEY=<per-launch hex>
10
+ //
11
+ // In non-Tauri mode (`maude design serve` in a browser) those envs are absent →
12
+ // this returns null → the /_api/github/* endpoints degrade to "sign in via the
13
+ // desktop app". The token is never logged, persisted, or returned to the canvas.
14
+
15
+ let warnedNoBridge = false;
16
+
17
+ /**
18
+ * Resolve the current GitHub token from the loopback bridge, or null when not
19
+ * running under the Tauri shell / not signed in. NEVER cache the token (it can be
20
+ * revoked / rotated; the keychain is the single source of truth).
21
+ */
22
+ export async function getGithubToken(): Promise<string | null> {
23
+ const endpoint = process.env.MAUDE_TOKEN_ENDPOINT;
24
+ const key = process.env.MAUDE_TOKEN_KEY;
25
+ if (!endpoint || !key) {
26
+ if (!warnedNoBridge) {
27
+ warnedNoBridge = true;
28
+ // One-time, non-secret: explains why github endpoints 401 outside the app.
29
+ console.error(
30
+ '[maude:github] no token bridge (not the desktop app) — GitHub features disabled'
31
+ );
32
+ }
33
+ return null;
34
+ }
35
+ try {
36
+ const res = await fetch(endpoint, {
37
+ headers: { 'X-Maude-Token-Key': key },
38
+ // The bridge is local + instant; don't hang a request on it.
39
+ signal: AbortSignal.timeout(3000),
40
+ });
41
+ if (res.status !== 200) return null; // 404 = not signed in; 403 = bad key
42
+ const token = (await res.text()).trim();
43
+ return token.length > 0 ? token : null;
44
+ } catch {
45
+ // Bridge unreachable (shell quit mid-request, etc.) — treat as signed out.
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Whether the token bridge is configured at all (i.e. running under the shell). */
51
+ export function tokenBridgeAvailable(): boolean {
52
+ return Boolean(process.env.MAUDE_TOKEN_ENDPOINT && process.env.MAUDE_TOKEN_KEY);
53
+ }
@@ -6,8 +6,15 @@
6
6
  //
7
7
  // - `mode: "css"` → swap <link> href with a cache-bust query (no module
8
8
  // reload, no React state loss). Sub-150ms latency.
9
- // - `mode: "module"` → location.reload() of the canvas iframe. State is lost
10
- // but the change always picks up. <250ms latency.
9
+ // - `mode: "module"` → in-place hot-swap of the canvas module when the canvas
10
+ // runtime is mounted (re-import + remount only the canvas
11
+ // content; the iframe — and the live CollabProvider +
12
+ // presence — stay mounted, so a cross-peer synced edit
13
+ // updates seamlessly without a presence blink — Phase 30
14
+ // / F4). Falls back to location.reload() with no runtime
15
+ // (gallery thumbnails) or on a re-import with no usable
16
+ // default export. State (scroll / tool / undo) survives
17
+ // the hot-swap path.
11
18
  // - `mode: "hard"` → location.reload() (used when canvas-lib.tsx or any
12
19
  // `_lib/**` file changes — every open canvas needs to
13
20
  // re-bundle).
@@ -6,8 +6,10 @@
6
6
 
7
7
  import { createHash } from 'node:crypto';
8
8
  import { existsSync, readFileSync, watch } from 'node:fs';
9
- import { dirname, join, posix, resolve, sep } from 'node:path';
9
+ import { dirname, join, posix, relative, resolve, sep } from 'node:path';
10
10
 
11
+ import { probeAcpAvailability } from './acp/probe.ts';
12
+ import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
11
13
  import type { Api } from './api.ts';
12
14
  import { buildCanvasModule } from './canvas-build.ts';
13
15
  import { canvasLibPath } from './canvas-lib-resolver.ts';
@@ -16,11 +18,17 @@ import type { AiActivity } from './collab/ai-activity.ts';
16
18
  import type { Context } from './context.ts';
17
19
  import { isFormat, isScope, runExport } from './exporters/index.ts';
18
20
  import type { ActiveJsonShape } from './exporters/scope.ts';
21
+ import { createGitEndpoints } from './git/endpoints.ts';
22
+ import { gitShowFile } from './git/service.ts';
23
+ import { createGitHubEndpoints } from './github/endpoints.ts';
19
24
  import type { Inspect } from './inspect.ts';
20
25
  import { canvasSlug, writeLocator } from './locator.ts';
21
26
  import { DEV_SERVER_ROOT } from './paths.ts';
27
+ import { probeReadiness } from './readiness.ts';
22
28
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
29
+ import { linkHub } from './sync/hub-link.ts';
23
30
  import { loadWhatsNew } from './whats-new.ts';
31
+ import { isLoopbackHost } from './ws.ts';
24
32
 
25
33
  // Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
26
34
  // See paths.ts for the resolution logic + Phase 19.1 / v0.18.1 rationale.
@@ -246,6 +254,15 @@ async function serveCanvasTsx(
246
254
  ctx: Context,
247
255
  locatorAbsPath: string
248
256
  ): Promise<Response> {
257
+ // Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
258
+ // its source AT a past version (git show) instead of the working-tree file, so
259
+ // the rendered before/after is real. Isolated additive branch — the normal
260
+ // (no-sha) serve below is byte-identical to before. The historical build still
261
+ // resolves sibling imports against the CURRENT on-disk files (an accepted
262
+ // approximation: the canvas code at the sha, with today's DS/lib).
263
+ const shaParam = new URL(req.url).searchParams.get('sha');
264
+ if (shaParam) return serveHistoricalCanvas(absPath, shaParam, req, ctx);
265
+
249
266
  const file = Bun.file(absPath);
250
267
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
251
268
 
@@ -313,6 +330,101 @@ async function serveCanvasTsx(
313
330
  });
314
331
  }
315
332
 
333
+ // Phase 27 (E2) — build + serve a canvas at a past git ref. Immutable per
334
+ // (path, sha) — historical content never changes, so the cache lives for the
335
+ // process (folding the boot id + chrome epoch into the etag so a chrome change
336
+ // still busts the browser copy, since the build inlines today's chrome).
337
+ // LRU-capped so a flood of distinct ?sha values (each minting a permanent entry)
338
+ // can't grow the heap unbounded — the route is reachable from the UNTRUSTED
339
+ // canvas origin (it's on the canvas-serve path), so a hub-pushed canvas could
340
+ // otherwise OOM the sidecar (security review — ethical-hacker, Finding 1).
341
+ const historicalCanvasCache = new Map<string, { js: string; etag: string }>();
342
+ const HIST_MAX_CACHE = 96;
343
+ // Rate-limit the EXPENSIVE miss path (git show + Bun.build, or a non-resolving
344
+ // git lookup) so a distinct-sha spray from the canvas origin can't starve the
345
+ // event loop. Cache HITS are free; only a NEW (path,sha) build consumes budget.
346
+ // Legit use (DiffView opens ~1–2 historical iframes per compare) is far under.
347
+ //
348
+ // TWO LOAD-BEARING INVARIANTS (security re-review — do not "optimize" away):
349
+ // 1. The limiter is DELIBERATELY GLOBAL, not per-origin/per-key — the attacker
350
+ // controls the canvas (hence any origin/key), so a keyed limiter is
351
+ // trivially defeated. The cost (a legit user 429s during an active flood) is
352
+ // accepted; it only bites under attack.
353
+ // 2. `historicalBuildAllowed()` MUST stay ABOVE `gitShowFile` so the
354
+ // non-resolving-sha spray (cheap git lookup, no build) is capped too —
355
+ // moving it below would re-open the CPU vector. Guarded by the
356
+ // "rate-limited — DoS guard" test in test/git-api.test.ts.
357
+ const HIST_WINDOW_MS = 10_000;
358
+ const HIST_MAX_BUILDS = 24;
359
+ let histWindowStart = Date.now();
360
+ let histBuilds = 0;
361
+ function historicalBuildAllowed(): boolean {
362
+ const now = Date.now();
363
+ if (now - histWindowStart >= HIST_WINDOW_MS) {
364
+ histWindowStart = now;
365
+ histBuilds = 0;
366
+ }
367
+ if (histBuilds >= HIST_MAX_BUILDS) return false;
368
+ histBuilds++;
369
+ return true;
370
+ }
371
+ async function serveHistoricalCanvas(
372
+ absPath: string,
373
+ sha: string,
374
+ req: Request,
375
+ ctx: Context
376
+ ): Promise<Response> {
377
+ const repoRel = relative(ctx.paths.repoRoot, absPath).replace(/\\/g, '/');
378
+ const key = `${absPath}\0${sha}\0${RUNTIME_BOOT_ID}\0${CHROME_EPOCH}`;
379
+ let cached = historicalCanvasCache.get(key);
380
+ if (cached) {
381
+ // LRU touch — re-insert so the hot entry isn't the next eviction victim.
382
+ historicalCanvasCache.delete(key);
383
+ historicalCanvasCache.set(key, cached);
384
+ } else {
385
+ if (!historicalBuildAllowed()) {
386
+ return new Response('Too many version previews — try again in a moment.', {
387
+ status: 429,
388
+ headers: { 'Retry-After': '10', 'Cache-Control': 'no-store' },
389
+ });
390
+ }
391
+ const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
392
+ if (source == null) return new Response('No saved version of this canvas', { status: 404 });
393
+ try {
394
+ const result = await buildCanvasModule(absPath, source, { designRoot: ctx.paths.designRoot });
395
+ cached = {
396
+ js: result.js,
397
+ etag: `${result.etag}-${sha}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
398
+ };
399
+ historicalCanvasCache.set(key, cached);
400
+ if (historicalCanvasCache.size > HIST_MAX_CACHE) {
401
+ const oldest = historicalCanvasCache.keys().next().value;
402
+ if (oldest !== undefined) historicalCanvasCache.delete(oldest);
403
+ }
404
+ } catch (err) {
405
+ const msg = err instanceof Error ? err.message : String(err);
406
+ return new Response(`Canvas build error: ${msg}`, {
407
+ status: 500,
408
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
409
+ });
410
+ }
411
+ }
412
+ if (req.headers.get('if-none-match') === cached.etag) {
413
+ return new Response(null, {
414
+ status: 304,
415
+ headers: { ETag: cached.etag, 'Cache-Control': 'no-cache' },
416
+ });
417
+ }
418
+ return new Response(cached.js, {
419
+ status: 200,
420
+ headers: {
421
+ 'Content-Type': 'application/javascript; charset=utf-8',
422
+ ETag: cached.etag,
423
+ 'Cache-Control': 'no-cache',
424
+ },
425
+ });
426
+ }
427
+
316
428
  async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
317
429
  const file = Bun.file(absPath);
318
430
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
@@ -427,6 +539,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
427
539
  }
428
540
  }
429
541
 
542
+ // Phase 27 (E2) — `/_api/git/*` orchestration. MAIN-ORIGIN ONLY: every git
543
+ // route is intentionally absent from CANVAS_SAFE_API + startCanvasServer's
544
+ // `routes` map (the dual-allowlist rule), so the untrusted canvas iframe origin
545
+ // can never reach status/commit/publish/get-latest. http.ts owns the gating;
546
+ // git/endpoints.ts owns the orchestration.
547
+ const gitApi = createGitEndpoints(ctx);
548
+ // Phase 28 (E3) — `/_api/github/*`. Same dual-allowlist rule: main-origin only,
549
+ // and every route is token-bearing (server-held keychain token via the loopback
550
+ // bridge), so all four also carry a loopback-Host check.
551
+ const githubApi = createGitHubEndpoints(ctx);
552
+ const gitJson = (r: { status: number; json: unknown }) =>
553
+ Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
554
+
430
555
  const routes = {
431
556
  '/_health': () =>
432
557
  Response.json({
@@ -438,6 +563,61 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
438
563
 
439
564
  '/_active': () => Response.json(inspect.state),
440
565
 
566
+ // Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
567
+ // (is the adapter present + is `claude` on PATH); no subprocess spawned.
568
+ // MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
569
+ // so the untrusted canvas iframe is 403'd. The native shell reads this to
570
+ // decide between the enabled panel and the not-connected explainer.
571
+ '/_api/acp/status': () =>
572
+ Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
573
+
574
+ // DDR-128 — first-open AI-editing readiness. Read-only probe of the AI-editing
575
+ // dependency chain (claude CLI · maude CLI · maude marketplace + plugins in the
576
+ // paired Claude Code · optional agent-browser) with per-item remediation; it
577
+ // installs/mutates nothing. MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API +
578
+ // startCanvasServer routes, so the untrusted canvas iframe is 403'd. The probe
579
+ // can shell out (login-shell fallback), so a cross-origin Origin-reject also
580
+ // fronts it — a drive-by page can't fire the loopback endpoint to spawn-storm
581
+ // the dev-server (DDR-128 hardening). The onboarding readiness card + the
582
+ // ChatPanel not-connected explainer + the Help-menu modal read this.
583
+ '/_api/preflight': async (req: Request) => {
584
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
585
+ return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
586
+ },
587
+
588
+ // Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
589
+ // POSTs here; we emit a bus event the shell turns into "open the Assistant
590
+ // panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
591
+ '/_api/acp/focus': (req: Request) => {
592
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
593
+ // CSRF parity with the other POST routes — the loopback `maude design
594
+ // chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
595
+ if (!sameOriginWrite(req))
596
+ return new Response('cross-origin write rejected', { status: 403 });
597
+ ctx.bus.emit('acp-focus', {});
598
+ return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
599
+ },
600
+
601
+ // Phase 31 — repo-level chat list + history (for the chat switcher +
602
+ // hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
603
+ '/_api/acp/chats': () =>
604
+ Response.json(listChats(ctx.paths.designRoot), {
605
+ headers: { 'Cache-Control': 'no-store' },
606
+ }),
607
+ '/_api/acp/chat': (req: Request) => {
608
+ const id = (new URL(req.url).searchParams.get('id') ?? '')
609
+ .replace(/[^a-z0-9_-]/gi, '')
610
+ .slice(0, 64);
611
+ if (req.method === 'DELETE') {
612
+ const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
613
+ return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
614
+ }
615
+ if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
616
+ return Response.json(readChatMessages(ctx.paths.designRoot, id), {
617
+ headers: { 'Cache-Control': 'no-store' },
618
+ });
619
+ },
620
+
441
621
  // Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
442
622
  // runtime writes `_sync.json`; browser tabs also get live pushes over the
443
623
  // WS ('sync:status'). Returns `{ linked: false }` in solo mode.
@@ -550,6 +730,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
550
730
  // Claude work) POSTs here when work begins. body = { file, author }.
551
731
  // Replaces any prior entry for the file.
552
732
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
733
+ // CSRF guard: phase-30 bridges ai-activity onto room awareness, which
734
+ // crosses the hub — a forged cross-origin POST would inject a fake
735
+ // "<x> is editing <file>" presence to every connected peer. The loopback
736
+ // slash-command driver omits Origin (→ allowed); a browser drive-by can't.
737
+ if (!sameOriginWrite(req))
738
+ return new Response('cross-origin write rejected', { status: 403 });
553
739
  const body = await readJson<{ file?: string; author?: string }>(req);
554
740
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
555
741
  return new Response('body.file required', { status: 400 });
@@ -566,6 +752,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
566
752
  // Refresh the lastHeartbeat. Returns 404 if no entry — slash command
567
753
  // can treat that as "the server bounced; re-issue /start".
568
754
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
755
+ if (!sameOriginWrite(req))
756
+ return new Response('cross-origin write rejected', { status: 403 });
569
757
  const body = await readJson<{ file?: string }>(req);
570
758
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
571
759
  return new Response('body.file required', { status: 400 });
@@ -578,6 +766,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
578
766
  '/_api/ai/end': async (req: Request) => {
579
767
  // Explicit completion (normal or error). Banner clears immediately.
580
768
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
769
+ if (!sameOriginWrite(req))
770
+ return new Response('cross-origin write rejected', { status: 403 });
581
771
  const body = await readJson<{ file?: string }>(req);
582
772
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
583
773
  return new Response('body.file required', { status: 400 });
@@ -668,6 +858,214 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
668
858
  );
669
859
  },
670
860
 
861
+ // ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
862
+ // History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
863
+ // POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
864
+ // the token-bearing publish/get-latest routes add a loopback Host check so a
865
+ // request carrying a GitHub token can only originate on this machine.
866
+ '/_api/git/status': async (req: Request) => {
867
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
868
+ // Local status only — fast, no network, no credentials. The remote
869
+ // ahead/behind probe (the "Get latest" nudge) needs a token, which a GET
870
+ // query string must NOT carry (it leaks via logs / Referer / history —
871
+ // security review A3). That probe lands in phase-28 over the server-held
872
+ // keychain token (server-side, never client-supplied), not here.
873
+ const checkRemote = new URL(req.url).searchParams.get('remote') === '1';
874
+ return gitJson(await gitApi.status({ checkRemote }));
875
+ },
876
+
877
+ '/_api/git/log': async (req: Request) => {
878
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
879
+ // Optional `?path=` scopes History to one canvas (phase-27.1). MAIN-ORIGIN
880
+ // ONLY (this route is absent from CANVAS_SAFE_API) — the path is
881
+ // containment-validated in the endpoint before it reaches git.
882
+ const u = new URL(req.url).searchParams;
883
+ return gitJson(await gitApi.log(u.get('limit'), u.get('path')));
884
+ },
885
+
886
+ '/_api/git/diff': async (req: Request) => {
887
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
888
+ return gitJson(await gitApi.diff(new URL(req.url).searchParams.get('sha')));
889
+ },
890
+
891
+ // ── Phase 29 (E4) — drafts (branches). MAIN-ORIGIN ONLY (absent from
892
+ // CANVAS_SAFE_API + startCanvasServer routes); branch + checkout are POST/CSRF-
893
+ // gated source mutations. Switching a draft moves HEAD, which the git-lifecycle
894
+ // watcher turns into a Yjs flush + reload (DDR-051) — no logic duplicated here.
895
+ '/_api/git/branches': async (req: Request) => {
896
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
897
+ return gitJson(await gitApi.branches());
898
+ },
899
+
900
+ '/_api/git/branch': async (req: Request) => {
901
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
902
+ if (!sameOriginWrite(req))
903
+ return new Response('cross-origin write rejected', { status: 403 });
904
+ const body = await readJson<unknown>(req, 8 * 1024);
905
+ return gitJson(await gitApi.createBranch(body));
906
+ },
907
+
908
+ '/_api/git/checkout': async (req: Request) => {
909
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
910
+ if (!sameOriginWrite(req))
911
+ return new Response('cross-origin write rejected', { status: 403 });
912
+ const body = await readJson<unknown>(req, 8 * 1024);
913
+ return gitJson(await gitApi.checkout(body));
914
+ },
915
+
916
+ // "Add this draft to the Shared version" — merges + PUBLISHES, so it is token-
917
+ // bearing: same main-origin + loopback gate as /_api/git/push.
918
+ '/_api/git/fold': async (req: Request) => {
919
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
920
+ if (!sameOriginWrite(req))
921
+ return new Response('cross-origin write rejected', { status: 403 });
922
+ if (!isLoopbackHost(req.headers.get('host')))
923
+ return new Response('adding a draft requires a local request', { status: 403 });
924
+ const body = await readJson<unknown>(req, 8 * 1024);
925
+ return gitJson(await gitApi.fold(body));
926
+ },
927
+
928
+ '/_api/git/commit': async (req: Request) => {
929
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
930
+ if (!sameOriginWrite(req))
931
+ return new Response('cross-origin write rejected', { status: 403 });
932
+ const body = await readJson<unknown>(req, 256 * 1024);
933
+ return gitJson(await gitApi.commit(body));
934
+ },
935
+
936
+ '/_api/git/discard': async (req: Request) => {
937
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
938
+ if (!sameOriginWrite(req))
939
+ return new Response('cross-origin write rejected', { status: 403 });
940
+ const body = await readJson<unknown>(req, 256 * 1024);
941
+ return gitJson(await gitApi.discard(body));
942
+ },
943
+
944
+ '/_api/git/push': async (req: Request) => {
945
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
946
+ if (!sameOriginWrite(req))
947
+ return new Response('cross-origin write rejected', { status: 403 });
948
+ // Token-bearing: refuse anything not from a loopback Host (the server binds
949
+ // 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
950
+ if (!isLoopbackHost(req.headers.get('host')))
951
+ return new Response('publish requires a local request', { status: 403 });
952
+ const body = await readJson<unknown>(req, 8 * 1024);
953
+ return gitJson(await gitApi.push(body));
954
+ },
955
+
956
+ '/_api/git/pull': async (req: Request) => {
957
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
958
+ if (!sameOriginWrite(req))
959
+ return new Response('cross-origin write rejected', { status: 403 });
960
+ if (!isLoopbackHost(req.headers.get('host')))
961
+ return new Response('get latest requires a local request', { status: 403 });
962
+ const body = await readJson<unknown>(req, 8 * 1024);
963
+ return gitJson(await gitApi.pull(body));
964
+ },
965
+
966
+ // Finish a Get-latest merge that hit a conflict (DiffView "Keep mine/theirs/
967
+ // both"). Token-bearing (server-held; resolve completes the two-parent merge
968
+ // commit) → same main-origin + loopback gate as pull. MAIN-ORIGIN ONLY:
969
+ // absent from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist).
970
+ '/_api/git/resolve': async (req: Request) => {
971
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
972
+ if (!sameOriginWrite(req))
973
+ return new Response('cross-origin write rejected', { status: 403 });
974
+ if (!isLoopbackHost(req.headers.get('host')))
975
+ return new Response('resolve requires a local request', { status: 403 });
976
+ const body = await readJson<unknown>(req, 8 * 1024);
977
+ return gitJson(await gitApi.resolve(body));
978
+ },
979
+
980
+ // ── Phase 28 (E3) — GitHub identity & remote. Sign-in/out + keychain live in
981
+ // the Tauri shell (oauth.rs/keychain.rs commands); these endpoints use the
982
+ // server-held token (loopback bridge → token.ts) for the REST calls. MAIN-ORIGIN
983
+ // ONLY (absent from CANVAS_SAFE_API + startCanvasServer) and loopback-Host gated
984
+ // since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
985
+ // command — the dev-server can't touch the OS keychain — so there is no
986
+ // DELETE /_api/github/identity here; see DDR-114.)
987
+ '/_api/github/identity': async (req: Request) => {
988
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
989
+ if (!isLoopbackHost(req.headers.get('host')))
990
+ return new Response('local request required', { status: 403 });
991
+ return gitJson(await githubApi.identity());
992
+ },
993
+
994
+ '/_api/github/repos': async (req: Request) => {
995
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
996
+ if (!isLoopbackHost(req.headers.get('host')))
997
+ return new Response('local request required', { status: 403 });
998
+ return gitJson(await githubApi.repos());
999
+ },
1000
+
1001
+ '/_api/github/create-repo': async (req: Request) => {
1002
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1003
+ if (!sameOriginWrite(req))
1004
+ return new Response('cross-origin write rejected', { status: 403 });
1005
+ if (!isLoopbackHost(req.headers.get('host')))
1006
+ return new Response('local request required', { status: 403 });
1007
+ const body = await readJson<unknown>(req, 8 * 1024);
1008
+ return gitJson(await githubApi.createRepo(body));
1009
+ },
1010
+
1011
+ '/_api/github/invite': async (req: Request) => {
1012
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1013
+ if (!sameOriginWrite(req))
1014
+ return new Response('cross-origin write rejected', { status: 403 });
1015
+ if (!isLoopbackHost(req.headers.get('host')))
1016
+ return new Response('local request required', { status: 403 });
1017
+ const body = await readJson<unknown>(req, 8 * 1024);
1018
+ return gitJson(await githubApi.invite(body));
1019
+ },
1020
+
1021
+ '/_api/github/clone': async (req: Request) => {
1022
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1023
+ if (!sameOriginWrite(req))
1024
+ return new Response('cross-origin write rejected', { status: 403 });
1025
+ if (!isLoopbackHost(req.headers.get('host')))
1026
+ return new Response('local request required', { status: 403 });
1027
+ const body = await readJson<unknown>(req, 8 * 1024);
1028
+ return gitJson(await githubApi.clone(body));
1029
+ },
1030
+
1031
+ '/_api/github/create-project': async (req: Request) => {
1032
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1033
+ if (!sameOriginWrite(req))
1034
+ return new Response('cross-origin write rejected', { status: 403 });
1035
+ if (!isLoopbackHost(req.headers.get('host')))
1036
+ return new Response('local request required', { status: 403 });
1037
+ const body = await readJson<unknown>(req, 8 * 1024);
1038
+ return gitJson(await githubApi.createProject(body));
1039
+ },
1040
+
1041
+ // Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
1042
+ // repo → set it up?" fallback). No token / no GitHub — local FS only, but still
1043
+ // main-origin + loopback gated (it writes to disk).
1044
+ '/_api/design/init': async (req: Request) => {
1045
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1046
+ if (!sameOriginWrite(req))
1047
+ return new Response('cross-origin write rejected', { status: 403 });
1048
+ if (!isLoopbackHost(req.headers.get('host')))
1049
+ return new Response('local request required', { status: 403 });
1050
+ const body = await readJson<unknown>(req, 8 * 1024);
1051
+ return gitJson(await githubApi.initDesign(body));
1052
+ },
1053
+
1054
+ // Phase 29 (E4) Door C — connect to a team hub: validate + probe + save the hub
1055
+ // credential to the global ~/.config/maude/hubs.json (sync/hub-link.ts). MAIN
1056
+ // ORIGIN ONLY (omitted from CANVAS_SAFE_API + startCanvasServer routes) + loopback
1057
+ // + POST CSRF, mirroring /_api/github/*. The lean in-app counterpart to the CLI
1058
+ // `maude design link`; the in-UI Connect is the explicit trust grant (DDR-054 F2).
1059
+ '/_api/hub/link': async (req: Request) => {
1060
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1061
+ if (!sameOriginWrite(req))
1062
+ return new Response('cross-origin write rejected', { status: 403 });
1063
+ if (!isLoopbackHost(req.headers.get('host')))
1064
+ return new Response('local request required', { status: 403 });
1065
+ const body = await readJson<unknown>(req, 8 * 1024);
1066
+ return gitJson(await linkHub(body));
1067
+ },
1068
+
671
1069
  '/_api/edit-css': async (req: Request) => {
672
1070
  // Phase 12 (DDR-103) — single-property inline CSS edit. POST body
673
1071
  // { canvas, id, property, value } → writes one key into the element's