@1agh/maude 0.30.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 (90) 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 +781 -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 +770 -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 +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. 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,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
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.
@@ -246,6 +253,15 @@ async function serveCanvasTsx(
246
253
  ctx: Context,
247
254
  locatorAbsPath: string
248
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
+
249
265
  const file = Bun.file(absPath);
250
266
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
251
267
 
@@ -313,6 +329,101 @@ async function serveCanvasTsx(
313
329
  });
314
330
  }
315
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
+
316
427
  async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
317
428
  const file = Bun.file(absPath);
318
429
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
@@ -427,6 +538,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
427
538
  }
428
539
  }
429
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
+
430
554
  const routes = {
431
555
  '/_health': () =>
432
556
  Response.json({
@@ -438,6 +562,47 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
438
562
 
439
563
  '/_active': () => Response.json(inspect.state),
440
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
+
441
606
  // Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
442
607
  // runtime writes `_sync.json`; browser tabs also get live pushes over the
443
608
  // WS ('sync:status'). Returns `{ linked: false }` in solo mode.
@@ -550,6 +715,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
550
715
  // Claude work) POSTs here when work begins. body = { file, author }.
551
716
  // Replaces any prior entry for the file.
552
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 });
553
724
  const body = await readJson<{ file?: string; author?: string }>(req);
554
725
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
555
726
  return new Response('body.file required', { status: 400 });
@@ -566,6 +737,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
566
737
  // Refresh the lastHeartbeat. Returns 404 if no entry — slash command
567
738
  // can treat that as "the server bounced; re-issue /start".
568
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 });
569
742
  const body = await readJson<{ file?: string }>(req);
570
743
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
571
744
  return new Response('body.file required', { status: 400 });
@@ -578,6 +751,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
578
751
  '/_api/ai/end': async (req: Request) => {
579
752
  // Explicit completion (normal or error). Banner clears immediately.
580
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 });
581
756
  const body = await readJson<{ file?: string }>(req);
582
757
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
583
758
  return new Response('body.file required', { status: 400 });
@@ -668,6 +843,214 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
668
843
  );
669
844
  },
670
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
+
671
1054
  '/_api/edit-css': async (req: Request) => {
672
1055
  // Phase 12 (DDR-103) — single-property inline CSS edit. POST body
673
1056
  // { canvas, id, property, value } → writes one key into the element's
@@ -139,6 +139,35 @@ const CHROME_CSS = `
139
139
  white-space: normal;
140
140
  max-width: 200px;
141
141
  }
142
+ /* Phase 30 / DDR-120 — soft editing-presence. A peer (or a bridged agent)
143
+ actively editing this canvas gets a gently-pulsing ring + a ✎ marker in the
144
+ peer's OWN identity hue (--dc-edit-ring, set inline from peer.color) so the
145
+ agent pulses violet, not the indigo accent. A heads-up, never a lock. */
146
+ .dc-participant--editing { animation: dc-participant-edit-pulse 1.8s ease-in-out infinite; }
147
+ @keyframes dc-participant-edit-pulse {
148
+ 0%, 100% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
149
+ 50% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 4px color-mix(in oklab, var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))) 45%, transparent); }
150
+ }
151
+ .dc-participant-edit-marker {
152
+ position: absolute;
153
+ right: -3px;
154
+ top: -3px;
155
+ width: 13px;
156
+ height: 13px;
157
+ border-radius: 50%;
158
+ background: var(--maude-chrome-bg-0, #fff);
159
+ color: var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50)));
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ font-size: 8px;
164
+ line-height: 1;
165
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.14);
166
+ pointer-events: none;
167
+ }
168
+ @media (prefers-reduced-motion: reduce) {
169
+ .dc-participant--editing { animation: none; box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
170
+ }
142
171
  `.trim();
143
172
 
144
173
  function ensureChromeStyles(): void {
@@ -150,6 +179,17 @@ function ensureChromeStyles(): void {
150
179
  document.head.appendChild(s);
151
180
  }
152
181
 
182
+ /**
183
+ * Phase 30 / DDR-120 — true when a foreign peer is THIS machine's own
184
+ * server-side editing echo: a peer carrying my own name with editing set but
185
+ * no cursor/selection. That's the room's projection of my own agent/inspector
186
+ * edit (which I already see via the local ai-activity avatar), so I skip it. A
187
+ * remote peer never matches (different name), so they still get the heads-up.
188
+ */
189
+ function isOwnEditingEcho(peer: ForeignAwareness, myName: string | undefined): boolean {
190
+ return peer.name === myName && !!peer.editing && !peer.cursor && !peer.selection;
191
+ }
192
+
153
193
  function initialsFor(name: string): string {
154
194
  const cleaned = name.trim();
155
195
  if (!cleaned) return '?';
@@ -169,6 +209,7 @@ interface AvatarProps {
169
209
  function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element {
170
210
  const [open, setOpen] = useState(false);
171
211
  const rootRef = useRef<HTMLDivElement | null>(null);
212
+ const editing = !!peer.editing;
172
213
 
173
214
  useEffect(() => {
174
215
  if (!open) return;
@@ -180,11 +221,16 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
180
221
  return () => document.removeEventListener('mousedown', onDocClick);
181
222
  }, [open]);
182
223
 
224
+ const cls = `dc-participant${isFollowing ? ' dc-participant--following' : ''}${editing ? ' dc-participant--editing' : ''}`;
225
+ // `--dc-edit-ring` drives the editing pulse/marker off the peer's own hue.
226
+ const style = (
227
+ editing ? { background: peer.color, '--dc-edit-ring': peer.color } : { background: peer.color }
228
+ ) as React.CSSProperties;
183
229
  return (
184
230
  <div
185
231
  ref={rootRef}
186
- className={`dc-participant${isFollowing ? ' dc-participant--following' : ''}`}
187
- style={{ background: peer.color }}
232
+ className={cls}
233
+ style={style}
188
234
  onClick={() => setOpen((v) => !v)}
189
235
  onKeyDown={(e) => {
190
236
  if (e.key === 'Enter' || e.key === ' ') {
@@ -194,14 +240,20 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
194
240
  }}
195
241
  role="button"
196
242
  tabIndex={0}
197
- title={peer.name}
198
- aria-label={peer.name}
243
+ title={editing ? `${peer.name} — editing this canvas` : peer.name}
244
+ aria-label={editing ? `${peer.name}, editing this canvas` : peer.name}
199
245
  aria-expanded={open}
200
246
  >
201
247
  {initialsFor(peer.name)}
248
+ {editing && (
249
+ <span className="dc-participant-edit-marker" aria-hidden="true">
250
+
251
+ </span>
252
+ )}
202
253
  {open && (
203
254
  <div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
204
255
  <div className="dc-participant-popover__name">{peer.name}</div>
256
+ {editing && <div className="dc-participant-popover__sub">Editing this canvas</div>}
205
257
  <button
206
258
  type="button"
207
259
  className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
@@ -318,13 +370,21 @@ export function ParticipantsChrome(): JSX.Element | null {
318
370
  controller.setViewport(v);
319
371
  }, [controller, followTarget, peers]);
320
372
 
321
- // Render whenever there's a human peer OR an active agent (so an agent shows
322
- // even with no human collaborators connected).
323
- if (peers.length === 0 && !agent) return null;
373
+ // Phase 30 / DDR-120 drop the authoring machine's own server-side editing
374
+ // echo: a foreign peer carrying MY name with editing set but no cursor/
375
+ // selection is the room's projection of my own agent/inspector edit, which I
376
+ // already see via the local ai-activity avatar. A remote peer never matches
377
+ // (different name), so they still get the "X is editing" heads-up.
378
+ const myName = collab?.myName;
379
+ const visiblePeers = peers.filter((p) => !isOwnEditingEcho(p, myName));
380
+
381
+ // Render whenever there's a visible human peer OR an active agent (so an agent
382
+ // shows even with no human collaborators connected).
383
+ if (visiblePeers.length === 0 && !agent) return null;
324
384
 
325
385
  return (
326
386
  <div className="dc-participants" aria-label="Active collaborators">
327
- {peers.map((p) => (
387
+ {visiblePeers.map((p) => (
328
388
  <Avatar
329
389
  key={p.clientID}
330
390
  peer={p}
@@ -337,4 +397,4 @@ export function ParticipantsChrome(): JSX.Element | null {
337
397
  );
338
398
  }
339
399
 
340
- export { initialsFor };
400
+ export { initialsFor, isOwnEditingEcho };