@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  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/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -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,16 @@ 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';
22
- import { getRuntimeBundle, packageForSlug, RUNTIME_PACKAGES, slugFor } from './runtime-bundle.ts';
27
+ import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
28
+ import { linkHub } from './sync/hub-link.ts';
23
29
  import { loadWhatsNew } from './whats-new.ts';
30
+ import { isLoopbackHost } from './ws.ts';
24
31
 
25
32
  // Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
26
33
  // See paths.ts for the resolution logic + Phase 19.1 / v0.18.1 rationale.
@@ -81,10 +88,11 @@ function ext(p: string): string {
81
88
  *
82
89
  * `frame-ancestors` (A6) — restricts who may embed the canvas document. The
83
90
  * legit embedder is the main dev-server origin, so we allowlist exactly that
84
- * (`mainOrigin`) plus `'self'`; an arbitrary external page can no longer reframe
85
- * the canvas. When `mainOrigin` is unknown (tests / pre-boot) the directive is
86
- * OMITTED rather than set to `'self'` `'self'` alone would forbid the legit
87
- * cross-origin embed and blank the canvas.
91
+ * (`mainOrigin` a space-separated source list; server.ts advertises both
92
+ * loopback spellings, `localhost` + `127.0.0.1`) plus `'self'`; an arbitrary
93
+ * external page can no longer reframe the canvas. When `mainOrigin` is unknown
94
+ * (tests / pre-boot) the directive is OMITTED rather than set to `'self'` —
95
+ * `'self'` alone would forbid the legit cross-origin embed and blank the canvas.
88
96
  */
89
97
  export function cspForCanvasShell(html: string, mainOrigin?: string): string {
90
98
  const hashes: string[] = [];
@@ -115,6 +123,34 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
115
123
  return directives.join('; ');
116
124
  }
117
125
 
126
+ /**
127
+ * CSRF guard for the main-origin source-write routes (edit-css / edit-text /
128
+ * edit-attr). Those routes are reachable only from the shell, which is
129
+ * same-origin — but `readJson` enforces no `Content-Type`, so a cross-site page
130
+ * could otherwise forge a `text/plain` CORS *simple-request* POST to
131
+ * `http://localhost:<port>/_api/edit-*` (no preflight) and drive a write into
132
+ * the user's source `.tsx`. The browser stamps every cross-origin POST with an
133
+ * unspoofable `Origin` header, so we reject any request whose Origin is PRESENT
134
+ * and ≠ the server's own origin. A request with NO Origin (bun:test, curl,
135
+ * non-browser programmatic clients — none of which are the CSRF threat, which
136
+ * requires a browser executing attacker markup that always sends Origin on a
137
+ * cross-origin POST) is allowed through. This is layered on top of the DDR-054
138
+ * origin-split (which blocks the untrusted canvas *iframe*); it closes the
139
+ * *other* untrusted origin — a malicious top-level page in another tab.
140
+ * Deliberately NOT applied to `/_api/asset` (that route is canvas-origin
141
+ * reachable by design — drag-drop/paste upload runs inside the iframe). See
142
+ * DDR-105. Exported for unit testing (the decision is a pure function of `req`).
143
+ */
144
+ export function sameOriginWrite(req: Request): boolean {
145
+ const origin = req.headers.get('origin');
146
+ if (!origin) return true; // non-browser / same-origin omitting Origin → allow
147
+ try {
148
+ return origin === new URL(req.url).origin;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
118
154
  function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
119
155
  let pathname: string;
120
156
  try {
@@ -217,6 +253,15 @@ async function serveCanvasTsx(
217
253
  ctx: Context,
218
254
  locatorAbsPath: string
219
255
  ): Promise<Response> {
256
+ // Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
257
+ // its source AT a past version (git show) instead of the working-tree file, so
258
+ // the rendered before/after is real. Isolated additive branch — the normal
259
+ // (no-sha) serve below is byte-identical to before. The historical build still
260
+ // resolves sibling imports against the CURRENT on-disk files (an accepted
261
+ // approximation: the canvas code at the sha, with today's DS/lib).
262
+ const shaParam = new URL(req.url).searchParams.get('sha');
263
+ if (shaParam) return serveHistoricalCanvas(absPath, shaParam, req, ctx);
264
+
220
265
  const file = Bun.file(absPath);
221
266
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
222
267
 
@@ -284,6 +329,101 @@ async function serveCanvasTsx(
284
329
  });
285
330
  }
286
331
 
332
+ // Phase 27 (E2) — build + serve a canvas at a past git ref. Immutable per
333
+ // (path, sha) — historical content never changes, so the cache lives for the
334
+ // process (folding the boot id + chrome epoch into the etag so a chrome change
335
+ // still busts the browser copy, since the build inlines today's chrome).
336
+ // LRU-capped so a flood of distinct ?sha values (each minting a permanent entry)
337
+ // can't grow the heap unbounded — the route is reachable from the UNTRUSTED
338
+ // canvas origin (it's on the canvas-serve path), so a hub-pushed canvas could
339
+ // otherwise OOM the sidecar (security review — ethical-hacker, Finding 1).
340
+ const historicalCanvasCache = new Map<string, { js: string; etag: string }>();
341
+ const HIST_MAX_CACHE = 96;
342
+ // Rate-limit the EXPENSIVE miss path (git show + Bun.build, or a non-resolving
343
+ // git lookup) so a distinct-sha spray from the canvas origin can't starve the
344
+ // event loop. Cache HITS are free; only a NEW (path,sha) build consumes budget.
345
+ // Legit use (DiffView opens ~1–2 historical iframes per compare) is far under.
346
+ //
347
+ // TWO LOAD-BEARING INVARIANTS (security re-review — do not "optimize" away):
348
+ // 1. The limiter is DELIBERATELY GLOBAL, not per-origin/per-key — the attacker
349
+ // controls the canvas (hence any origin/key), so a keyed limiter is
350
+ // trivially defeated. The cost (a legit user 429s during an active flood) is
351
+ // accepted; it only bites under attack.
352
+ // 2. `historicalBuildAllowed()` MUST stay ABOVE `gitShowFile` so the
353
+ // non-resolving-sha spray (cheap git lookup, no build) is capped too —
354
+ // moving it below would re-open the CPU vector. Guarded by the
355
+ // "rate-limited — DoS guard" test in test/git-api.test.ts.
356
+ const HIST_WINDOW_MS = 10_000;
357
+ const HIST_MAX_BUILDS = 24;
358
+ let histWindowStart = Date.now();
359
+ let histBuilds = 0;
360
+ function historicalBuildAllowed(): boolean {
361
+ const now = Date.now();
362
+ if (now - histWindowStart >= HIST_WINDOW_MS) {
363
+ histWindowStart = now;
364
+ histBuilds = 0;
365
+ }
366
+ if (histBuilds >= HIST_MAX_BUILDS) return false;
367
+ histBuilds++;
368
+ return true;
369
+ }
370
+ async function serveHistoricalCanvas(
371
+ absPath: string,
372
+ sha: string,
373
+ req: Request,
374
+ ctx: Context
375
+ ): Promise<Response> {
376
+ const repoRel = relative(ctx.paths.repoRoot, absPath).replace(/\\/g, '/');
377
+ const key = `${absPath}\0${sha}\0${RUNTIME_BOOT_ID}\0${CHROME_EPOCH}`;
378
+ let cached = historicalCanvasCache.get(key);
379
+ if (cached) {
380
+ // LRU touch — re-insert so the hot entry isn't the next eviction victim.
381
+ historicalCanvasCache.delete(key);
382
+ historicalCanvasCache.set(key, cached);
383
+ } else {
384
+ if (!historicalBuildAllowed()) {
385
+ return new Response('Too many version previews — try again in a moment.', {
386
+ status: 429,
387
+ headers: { 'Retry-After': '10', 'Cache-Control': 'no-store' },
388
+ });
389
+ }
390
+ const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
391
+ if (source == null) return new Response('No saved version of this canvas', { status: 404 });
392
+ try {
393
+ const result = await buildCanvasModule(absPath, source, { designRoot: ctx.paths.designRoot });
394
+ cached = {
395
+ js: result.js,
396
+ etag: `${result.etag}-${sha}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
397
+ };
398
+ historicalCanvasCache.set(key, cached);
399
+ if (historicalCanvasCache.size > HIST_MAX_CACHE) {
400
+ const oldest = historicalCanvasCache.keys().next().value;
401
+ if (oldest !== undefined) historicalCanvasCache.delete(oldest);
402
+ }
403
+ } catch (err) {
404
+ const msg = err instanceof Error ? err.message : String(err);
405
+ return new Response(`Canvas build error: ${msg}`, {
406
+ status: 500,
407
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
408
+ });
409
+ }
410
+ }
411
+ if (req.headers.get('if-none-match') === cached.etag) {
412
+ return new Response(null, {
413
+ status: 304,
414
+ headers: { ETag: cached.etag, 'Cache-Control': 'no-cache' },
415
+ });
416
+ }
417
+ return new Response(cached.js, {
418
+ status: 200,
419
+ headers: {
420
+ 'Content-Type': 'application/javascript; charset=utf-8',
421
+ ETag: cached.etag,
422
+ 'Cache-Control': 'no-cache',
423
+ },
424
+ });
425
+ }
426
+
287
427
  async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
288
428
  const file = Bun.file(absPath);
289
429
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
@@ -398,6 +538,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
398
538
  }
399
539
  }
400
540
 
541
+ // Phase 27 (E2) — `/_api/git/*` orchestration. MAIN-ORIGIN ONLY: every git
542
+ // route is intentionally absent from CANVAS_SAFE_API + startCanvasServer's
543
+ // `routes` map (the dual-allowlist rule), so the untrusted canvas iframe origin
544
+ // can never reach status/commit/publish/get-latest. http.ts owns the gating;
545
+ // git/endpoints.ts owns the orchestration.
546
+ const gitApi = createGitEndpoints(ctx);
547
+ // Phase 28 (E3) — `/_api/github/*`. Same dual-allowlist rule: main-origin only,
548
+ // and every route is token-bearing (server-held keychain token via the loopback
549
+ // bridge), so all four also carry a loopback-Host check.
550
+ const githubApi = createGitHubEndpoints(ctx);
551
+ const gitJson = (r: { status: number; json: unknown }) =>
552
+ Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
553
+
401
554
  const routes = {
402
555
  '/_health': () =>
403
556
  Response.json({
@@ -409,6 +562,47 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
409
562
 
410
563
  '/_active': () => Response.json(inspect.state),
411
564
 
565
+ // Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
566
+ // (is the adapter present + is `claude` on PATH); no subprocess spawned.
567
+ // MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
568
+ // so the untrusted canvas iframe is 403'd. The native shell reads this to
569
+ // decide between the enabled panel and the not-connected explainer.
570
+ '/_api/acp/status': () =>
571
+ Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
572
+
573
+ // Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
574
+ // POSTs here; we emit a bus event the shell turns into "open the Assistant
575
+ // panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
576
+ '/_api/acp/focus': (req: Request) => {
577
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
578
+ // CSRF parity with the other POST routes — the loopback `maude design
579
+ // chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
580
+ if (!sameOriginWrite(req))
581
+ return new Response('cross-origin write rejected', { status: 403 });
582
+ ctx.bus.emit('acp-focus', {});
583
+ return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
584
+ },
585
+
586
+ // Phase 31 — repo-level chat list + history (for the chat switcher +
587
+ // hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
588
+ '/_api/acp/chats': () =>
589
+ Response.json(listChats(ctx.paths.designRoot), {
590
+ headers: { 'Cache-Control': 'no-store' },
591
+ }),
592
+ '/_api/acp/chat': (req: Request) => {
593
+ const id = (new URL(req.url).searchParams.get('id') ?? '')
594
+ .replace(/[^a-z0-9_-]/gi, '')
595
+ .slice(0, 64);
596
+ if (req.method === 'DELETE') {
597
+ const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
598
+ return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
599
+ }
600
+ if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
601
+ return Response.json(readChatMessages(ctx.paths.designRoot, id), {
602
+ headers: { 'Cache-Control': 'no-store' },
603
+ });
604
+ },
605
+
412
606
  // Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
413
607
  // runtime writes `_sync.json`; browser tabs also get live pushes over the
414
608
  // WS ('sync:status'). Returns `{ linked: false }` in solo mode.
@@ -521,6 +715,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
521
715
  // Claude work) POSTs here when work begins. body = { file, author }.
522
716
  // Replaces any prior entry for the file.
523
717
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
718
+ // CSRF guard: phase-30 bridges ai-activity onto room awareness, which
719
+ // crosses the hub — a forged cross-origin POST would inject a fake
720
+ // "<x> is editing <file>" presence to every connected peer. The loopback
721
+ // slash-command driver omits Origin (→ allowed); a browser drive-by can't.
722
+ if (!sameOriginWrite(req))
723
+ return new Response('cross-origin write rejected', { status: 403 });
524
724
  const body = await readJson<{ file?: string; author?: string }>(req);
525
725
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
526
726
  return new Response('body.file required', { status: 400 });
@@ -537,6 +737,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
537
737
  // Refresh the lastHeartbeat. Returns 404 if no entry — slash command
538
738
  // can treat that as "the server bounced; re-issue /start".
539
739
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
740
+ if (!sameOriginWrite(req))
741
+ return new Response('cross-origin write rejected', { status: 403 });
540
742
  const body = await readJson<{ file?: string }>(req);
541
743
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
542
744
  return new Response('body.file required', { status: 400 });
@@ -549,6 +751,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
549
751
  '/_api/ai/end': async (req: Request) => {
550
752
  // Explicit completion (normal or error). Banner clears immediately.
551
753
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
754
+ if (!sameOriginWrite(req))
755
+ return new Response('cross-origin write rejected', { status: 403 });
552
756
  const body = await readJson<{ file?: string }>(req);
553
757
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
554
758
  return new Response('body.file required', { status: 400 });
@@ -639,6 +843,304 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
639
843
  );
640
844
  },
641
845
 
846
+ // ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
847
+ // History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
848
+ // POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
849
+ // the token-bearing publish/get-latest routes add a loopback Host check so a
850
+ // request carrying a GitHub token can only originate on this machine.
851
+ '/_api/git/status': async (req: Request) => {
852
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
853
+ // Local status only — fast, no network, no credentials. The remote
854
+ // ahead/behind probe (the "Get latest" nudge) needs a token, which a GET
855
+ // query string must NOT carry (it leaks via logs / Referer / history —
856
+ // security review A3). That probe lands in phase-28 over the server-held
857
+ // keychain token (server-side, never client-supplied), not here.
858
+ const checkRemote = new URL(req.url).searchParams.get('remote') === '1';
859
+ return gitJson(await gitApi.status({ checkRemote }));
860
+ },
861
+
862
+ '/_api/git/log': async (req: Request) => {
863
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
864
+ // Optional `?path=` scopes History to one canvas (phase-27.1). MAIN-ORIGIN
865
+ // ONLY (this route is absent from CANVAS_SAFE_API) — the path is
866
+ // containment-validated in the endpoint before it reaches git.
867
+ const u = new URL(req.url).searchParams;
868
+ return gitJson(await gitApi.log(u.get('limit'), u.get('path')));
869
+ },
870
+
871
+ '/_api/git/diff': async (req: Request) => {
872
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
873
+ return gitJson(await gitApi.diff(new URL(req.url).searchParams.get('sha')));
874
+ },
875
+
876
+ // ── Phase 29 (E4) — drafts (branches). MAIN-ORIGIN ONLY (absent from
877
+ // CANVAS_SAFE_API + startCanvasServer routes); branch + checkout are POST/CSRF-
878
+ // gated source mutations. Switching a draft moves HEAD, which the git-lifecycle
879
+ // watcher turns into a Yjs flush + reload (DDR-051) — no logic duplicated here.
880
+ '/_api/git/branches': async (req: Request) => {
881
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
882
+ return gitJson(await gitApi.branches());
883
+ },
884
+
885
+ '/_api/git/branch': async (req: Request) => {
886
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
887
+ if (!sameOriginWrite(req))
888
+ return new Response('cross-origin write rejected', { status: 403 });
889
+ const body = await readJson<unknown>(req, 8 * 1024);
890
+ return gitJson(await gitApi.createBranch(body));
891
+ },
892
+
893
+ '/_api/git/checkout': async (req: Request) => {
894
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
895
+ if (!sameOriginWrite(req))
896
+ return new Response('cross-origin write rejected', { status: 403 });
897
+ const body = await readJson<unknown>(req, 8 * 1024);
898
+ return gitJson(await gitApi.checkout(body));
899
+ },
900
+
901
+ // "Add this draft to the Shared version" — merges + PUBLISHES, so it is token-
902
+ // bearing: same main-origin + loopback gate as /_api/git/push.
903
+ '/_api/git/fold': async (req: Request) => {
904
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
905
+ if (!sameOriginWrite(req))
906
+ return new Response('cross-origin write rejected', { status: 403 });
907
+ if (!isLoopbackHost(req.headers.get('host')))
908
+ return new Response('adding a draft requires a local request', { status: 403 });
909
+ const body = await readJson<unknown>(req, 8 * 1024);
910
+ return gitJson(await gitApi.fold(body));
911
+ },
912
+
913
+ '/_api/git/commit': async (req: Request) => {
914
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
915
+ if (!sameOriginWrite(req))
916
+ return new Response('cross-origin write rejected', { status: 403 });
917
+ const body = await readJson<unknown>(req, 256 * 1024);
918
+ return gitJson(await gitApi.commit(body));
919
+ },
920
+
921
+ '/_api/git/discard': async (req: Request) => {
922
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
923
+ if (!sameOriginWrite(req))
924
+ return new Response('cross-origin write rejected', { status: 403 });
925
+ const body = await readJson<unknown>(req, 256 * 1024);
926
+ return gitJson(await gitApi.discard(body));
927
+ },
928
+
929
+ '/_api/git/push': async (req: Request) => {
930
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
931
+ if (!sameOriginWrite(req))
932
+ return new Response('cross-origin write rejected', { status: 403 });
933
+ // Token-bearing: refuse anything not from a loopback Host (the server binds
934
+ // 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
935
+ if (!isLoopbackHost(req.headers.get('host')))
936
+ return new Response('publish requires a local request', { status: 403 });
937
+ const body = await readJson<unknown>(req, 8 * 1024);
938
+ return gitJson(await gitApi.push(body));
939
+ },
940
+
941
+ '/_api/git/pull': async (req: Request) => {
942
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
943
+ if (!sameOriginWrite(req))
944
+ return new Response('cross-origin write rejected', { status: 403 });
945
+ if (!isLoopbackHost(req.headers.get('host')))
946
+ return new Response('get latest requires a local request', { status: 403 });
947
+ const body = await readJson<unknown>(req, 8 * 1024);
948
+ return gitJson(await gitApi.pull(body));
949
+ },
950
+
951
+ // Finish a Get-latest merge that hit a conflict (DiffView "Keep mine/theirs/
952
+ // both"). Token-bearing (server-held; resolve completes the two-parent merge
953
+ // commit) → same main-origin + loopback gate as pull. MAIN-ORIGIN ONLY:
954
+ // absent from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist).
955
+ '/_api/git/resolve': async (req: Request) => {
956
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
957
+ if (!sameOriginWrite(req))
958
+ return new Response('cross-origin write rejected', { status: 403 });
959
+ if (!isLoopbackHost(req.headers.get('host')))
960
+ return new Response('resolve requires a local request', { status: 403 });
961
+ const body = await readJson<unknown>(req, 8 * 1024);
962
+ return gitJson(await gitApi.resolve(body));
963
+ },
964
+
965
+ // ── Phase 28 (E3) — GitHub identity & remote. Sign-in/out + keychain live in
966
+ // the Tauri shell (oauth.rs/keychain.rs commands); these endpoints use the
967
+ // server-held token (loopback bridge → token.ts) for the REST calls. MAIN-ORIGIN
968
+ // ONLY (absent from CANVAS_SAFE_API + startCanvasServer) and loopback-Host gated
969
+ // since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
970
+ // command — the dev-server can't touch the OS keychain — so there is no
971
+ // DELETE /_api/github/identity here; see DDR-114.)
972
+ '/_api/github/identity': async (req: Request) => {
973
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
974
+ if (!isLoopbackHost(req.headers.get('host')))
975
+ return new Response('local request required', { status: 403 });
976
+ return gitJson(await githubApi.identity());
977
+ },
978
+
979
+ '/_api/github/repos': async (req: Request) => {
980
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
981
+ if (!isLoopbackHost(req.headers.get('host')))
982
+ return new Response('local request required', { status: 403 });
983
+ return gitJson(await githubApi.repos());
984
+ },
985
+
986
+ '/_api/github/create-repo': async (req: Request) => {
987
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
988
+ if (!sameOriginWrite(req))
989
+ return new Response('cross-origin write rejected', { status: 403 });
990
+ if (!isLoopbackHost(req.headers.get('host')))
991
+ return new Response('local request required', { status: 403 });
992
+ const body = await readJson<unknown>(req, 8 * 1024);
993
+ return gitJson(await githubApi.createRepo(body));
994
+ },
995
+
996
+ '/_api/github/invite': async (req: Request) => {
997
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
998
+ if (!sameOriginWrite(req))
999
+ return new Response('cross-origin write rejected', { status: 403 });
1000
+ if (!isLoopbackHost(req.headers.get('host')))
1001
+ return new Response('local request required', { status: 403 });
1002
+ const body = await readJson<unknown>(req, 8 * 1024);
1003
+ return gitJson(await githubApi.invite(body));
1004
+ },
1005
+
1006
+ '/_api/github/clone': async (req: Request) => {
1007
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1008
+ if (!sameOriginWrite(req))
1009
+ return new Response('cross-origin write rejected', { status: 403 });
1010
+ if (!isLoopbackHost(req.headers.get('host')))
1011
+ return new Response('local request required', { status: 403 });
1012
+ const body = await readJson<unknown>(req, 8 * 1024);
1013
+ return gitJson(await githubApi.clone(body));
1014
+ },
1015
+
1016
+ '/_api/github/create-project': async (req: Request) => {
1017
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1018
+ if (!sameOriginWrite(req))
1019
+ return new Response('cross-origin write rejected', { status: 403 });
1020
+ if (!isLoopbackHost(req.headers.get('host')))
1021
+ return new Response('local request required', { status: 403 });
1022
+ const body = await readJson<unknown>(req, 8 * 1024);
1023
+ return gitJson(await githubApi.createProject(body));
1024
+ },
1025
+
1026
+ // Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
1027
+ // repo → set it up?" fallback). No token / no GitHub — local FS only, but still
1028
+ // main-origin + loopback gated (it writes to disk).
1029
+ '/_api/design/init': async (req: Request) => {
1030
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1031
+ if (!sameOriginWrite(req))
1032
+ return new Response('cross-origin write rejected', { status: 403 });
1033
+ if (!isLoopbackHost(req.headers.get('host')))
1034
+ return new Response('local request required', { status: 403 });
1035
+ const body = await readJson<unknown>(req, 8 * 1024);
1036
+ return gitJson(await githubApi.initDesign(body));
1037
+ },
1038
+
1039
+ // Phase 29 (E4) Door C — connect to a team hub: validate + probe + save the hub
1040
+ // credential to the global ~/.config/maude/hubs.json (sync/hub-link.ts). MAIN
1041
+ // ORIGIN ONLY (omitted from CANVAS_SAFE_API + startCanvasServer routes) + loopback
1042
+ // + POST CSRF, mirroring /_api/github/*. The lean in-app counterpart to the CLI
1043
+ // `maude design link`; the in-UI Connect is the explicit trust grant (DDR-054 F2).
1044
+ '/_api/hub/link': 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 linkHub(body));
1052
+ },
1053
+
1054
+ '/_api/edit-css': async (req: Request) => {
1055
+ // Phase 12 (DDR-103) — single-property inline CSS edit. POST body
1056
+ // { canvas, id, property, value } → writes one key into the element's
1057
+ // inline `style={{}}` object via api.editCss → editAttribute. MAIN ORIGIN
1058
+ // ONLY: intentionally absent from CANVAS_SAFE_API + startCanvasServer's
1059
+ // route allowlist (DDR-054) — the untrusted canvas iframe origin must never
1060
+ // reach a source-write endpoint. The CSS-knob UI lives in the shell (main
1061
+ // origin) and calls it directly.
1062
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1063
+ if (!sameOriginWrite(req))
1064
+ return new Response('cross-origin write rejected', { status: 403 });
1065
+ const body = await readJson<{
1066
+ canvas?: unknown;
1067
+ id?: unknown;
1068
+ property?: unknown;
1069
+ value?: unknown;
1070
+ reset?: unknown;
1071
+ }>(req, 8 * 1024);
1072
+ if (!body) return new Response('body required', { status: 400 });
1073
+ const result = await api.editCss(body);
1074
+ if (!result.ok) {
1075
+ return Response.json(
1076
+ { ok: false, error: result.error },
1077
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1078
+ );
1079
+ }
1080
+ return Response.json(
1081
+ { ok: true, delta: result.delta },
1082
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1083
+ );
1084
+ },
1085
+
1086
+ '/_api/edit-text': async (req: Request) => {
1087
+ // Phase 12 (DDR-103) — inline element text-content edit. POST body
1088
+ // { canvas, id, text } → overwrites the element's single JSXText child
1089
+ // (JSX-escaped) via api.editText → editText. Same MAIN-ORIGIN-ONLY trust
1090
+ // boundary as /_api/edit-css + /_api/canvas. The inline contenteditable
1091
+ // editor reaches it via the dgn:* bus → shell relay (never the iframe).
1092
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1093
+ if (!sameOriginWrite(req))
1094
+ return new Response('cross-origin write rejected', { status: 403 });
1095
+ const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
1096
+ req,
1097
+ 16 * 1024
1098
+ );
1099
+ if (!body) return new Response('body required', { status: 400 });
1100
+ const result = await api.editText(body);
1101
+ if (!result.ok) {
1102
+ return Response.json(
1103
+ { ok: false, error: result.error },
1104
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1105
+ );
1106
+ }
1107
+ return Response.json(
1108
+ { ok: true, delta: result.delta },
1109
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1110
+ );
1111
+ },
1112
+
1113
+ '/_api/edit-attr': async (req: Request) => {
1114
+ // Phase 12.2 (DDR-104) — the CSS panel's "custom HTML attribute" escape
1115
+ // hatch. POST body { canvas, id, attr, value } → writes a plain JSX
1116
+ // attribute (data-*, aria-*, role, …) via api.editAttr → editAttribute's
1117
+ // non-`style.` path. Same MAIN-ORIGIN-ONLY trust boundary as
1118
+ // /_api/edit-css + /_api/edit-text (absent from CANVAS_SAFE_API +
1119
+ // startCanvasServer's route map; the untrusted iframe can never reach it).
1120
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1121
+ if (!sameOriginWrite(req))
1122
+ return new Response('cross-origin write rejected', { status: 403 });
1123
+ const body = await readJson<{
1124
+ canvas?: unknown;
1125
+ id?: unknown;
1126
+ attr?: unknown;
1127
+ value?: unknown;
1128
+ reset?: unknown;
1129
+ }>(req, 8 * 1024);
1130
+ if (!body) return new Response('body required', { status: 400 });
1131
+ const result = await api.editAttr(body);
1132
+ if (!result.ok) {
1133
+ return Response.json(
1134
+ { ok: false, error: result.error },
1135
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1136
+ );
1137
+ }
1138
+ return Response.json(
1139
+ { ok: true, delta: result.delta },
1140
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1141
+ );
1142
+ },
1143
+
642
1144
  '/_api/asset': async (req: Request) => {
643
1145
  // Phase 23 — binary image upload from the canvas (drag-drop / paste / the
644
1146
  // a11y file picker). POST raw image bytes → content-addressed write under
@@ -82,6 +82,8 @@ export type Tool =
82
82
  | 'arrow'
83
83
  | 'sticky'
84
84
  | 'text'
85
+ // FigJam v3 — labelled organizing container (Shift+S).
86
+ | 'section'
85
87
  | 'eraser';
86
88
 
87
89
  const ANNOTATION_TOOLS = new Set<Tool>([
@@ -93,6 +95,7 @@ const ANNOTATION_TOOLS = new Set<Tool>([
93
95
  'arrow',
94
96
  'sticky',
95
97
  'text',
98
+ 'section',
96
99
  'eraser',
97
100
  ]);
98
101
 
@@ -183,6 +186,10 @@ export function classify(input: ClassifyInput): RouterAction {
183
186
  // yields when focus is inside the canvas iframe (app.jsx onKey bail).
184
187
  if (k === 'n') return { kind: 'tool', tool: 'sticky' };
185
188
  if (k === 't') return { kind: 'tool', tool: 'text' };
189
+ // FigJam v3 — Shift+S arms the Section tool (FigJam's own binding; bare S
190
+ // stays with the shell's Design-system view). Checked here because the
191
+ // modifier guard above only filters Cmd/Ctrl/Alt.
192
+ if (k === 's' && input.shiftKey) return { kind: 'tool', tool: 'section' };
186
193
  if (k === 'e') return { kind: 'tool', tool: 'eraser' };
187
194
  if (input.key === 'Escape') return { kind: 'escape' };
188
195
  return { kind: 'no-op' };