@1agh/maude 0.60.6 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -27,11 +27,14 @@
27
27
  import { spawn } from 'node:child_process';
28
28
  import fs, { existsSync } from 'node:fs';
29
29
  import { isAbsolute, join, relative, sep } from 'node:path';
30
+ import { StringDecoder } from 'node:string_decoder';
30
31
 
31
32
  import git from 'isomorphic-git';
32
33
  import http from 'isomorphic-git/http/node';
33
34
 
35
+ import { gitLogArgs, gitLogEnv, parseGitLog } from './log-format.ts';
34
36
  import { withRepoLock } from './repo-lock.ts';
37
+ import { safeGitPrefix } from './safe-rel.ts';
35
38
 
36
39
  // Read LIVE, not as a module-load const — the same reason `noSystemGit()` below
37
40
  // is a function, and a sharper one since Cloud Phase 27 D2: a CELL now runs with
@@ -220,9 +223,27 @@ function isRepo(dir: string): boolean {
220
223
  }
221
224
 
222
225
  /** Normalize a status-matrix prefix: strip leading/trailing slashes, forward-slash. */
226
+ /**
227
+ * The design-root containment prefix, normalised and VALIDATED (F-13/B12).
228
+ *
229
+ * This used to strip slashes and nothing else, so a `designRoot` of
230
+ * `../../..` normalised to `../../..` and `underPrefix` then matched nothing —
231
+ * or, on the staging side, named files outside the design root entirely. The
232
+ * rules live in `safe-rel.ts`; see its docblock for why containment (not argv)
233
+ * is the risk here.
234
+ *
235
+ * A REFUSED prefix falls back to `'.design'`, never to `''`. Empty means "no
236
+ * containment filter at all", so treating a hostile value as absent would
237
+ * WIDEN the scope to the whole repository — the opposite of what refusing it
238
+ * is for.
239
+ */
223
240
  function normPrefix(p?: string): string {
224
- if (!p) return '';
225
- return p.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
241
+ const safe = safeGitPrefix(p);
242
+ if (safe !== null) return safe;
243
+ console.warn(
244
+ `[git] refusing an unsafe design-root prefix (${JSON.stringify(p)}) — falling back to '.design'`
245
+ );
246
+ return '.design';
226
247
  }
227
248
 
228
249
  function underPrefix(filepath: string, prefix: string): boolean {
@@ -345,11 +366,20 @@ function runGit(
345
366
  child.kill('SIGKILL');
346
367
  }, timeoutMs);
347
368
  }
369
+ // StringDecoder, NOT `d.toString()` per chunk — a pipe splits at arbitrary
370
+ // BYTE boundaries, so a multi-byte sequence straddling two chunks decodes
371
+ // to U+FFFD on each side and `git show` of a canvas with non-ASCII copy
372
+ // comes back silently corrupted. The hub's runner carries the identical
373
+ // fix: this commit's whole premise is that a cloud row and a local row
374
+ // cannot differ in shape, and "one of the two mangles diacritics" is
375
+ // exactly that kind of difference.
376
+ const outDec = new StringDecoder('utf8');
377
+ const errDec = new StringDecoder('utf8');
348
378
  child.stdout.on('data', (d) => {
349
- stdout += d.toString();
379
+ stdout += outDec.write(d);
350
380
  });
351
381
  child.stderr.on('data', (d) => {
352
- stderr += d.toString();
382
+ stderr += errDec.write(d);
353
383
  });
354
384
  child.on('error', (e) => {
355
385
  if (timer) clearTimeout(timer);
@@ -357,6 +387,8 @@ function runGit(
357
387
  });
358
388
  child.on('close', (code) => {
359
389
  if (timer) clearTimeout(timer);
390
+ stdout += outDec.end();
391
+ stderr += errDec.end();
360
392
  resolveRun({ code: timedOut ? 124 : (code ?? 1), stdout, stderr, timedOut });
361
393
  });
362
394
  });
@@ -884,7 +916,9 @@ async function checkoutLocked(dir: string, name: string): Promise<GitBranchResul
884
916
  } else {
885
917
  const remoteNames = await git
886
918
  .listBranches({ fs, dir, remote: DEFAULT_REMOTE })
887
- .catch(() => []);
919
+ // Annotated: a bare `[]` infers never[], which made the `.includes`
920
+ // below take a `never` and rejected any real branch name.
921
+ .catch((): string[] => []);
888
922
  if (!remoteNames.includes(name))
889
923
  return { ok: false, error: "Couldn't find that draft — try Refresh." };
890
924
  await git.checkout({ fs, dir, ref: name, remote: DEFAULT_REMOTE, track: true });
@@ -1712,29 +1746,14 @@ async function logIso(dir: string, limit: number, filepath?: string): Promise<Gi
1712
1746
  }
1713
1747
 
1714
1748
  async function logSystem(dir: string, limit: number, filepath?: string): Promise<GitLogEntry[]> {
1715
- // Unit-separator field delimiter, record-separator line delimiter — survives
1716
- // any message punctuation.
1717
- const fmt = '%H%x1f%s%x1f%an%x1f%ae%x1f%aI%x1e';
1718
- const args = ['log', `-n${limit}`, `--pretty=format:${fmt}`];
1719
- // `--` makes `filepath` strictly positional — git can't read it as an option
1720
- // (no argument injection even if it began with a dash, which containment
1721
- // validation already rejects upstream).
1722
- if (filepath) args.push('--', filepath);
1723
- // GIT_LITERAL_PATHSPECS — match `filepath` VERBATIM, never as pathspec magic
1724
- // (`:(top)`, `:(exclude)`, globs). The endpoint already restricts it to the
1725
- // design tree; this makes the system-git engine treat it as a plain path
1726
- // regardless, closing the pathspec-magic surface the `--` terminator alone
1727
- // doesn't (security re-review, phase-27.1).
1728
- const r = await runGit(dir, args, filepath ? { GIT_LITERAL_PATHSPECS: '1' } : undefined);
1749
+ // The format, the argv (including the `--` terminator) and the parser all
1750
+ // live in `log-format.ts` — the cloud cell's `/api/history` reads its own
1751
+ // repo through the SAME three, so a cloud row and a local row cannot drift
1752
+ // into different shapes behind one renderer. `gitLogEnv` carries the
1753
+ // GIT_LITERAL_PATHSPECS hardening that must travel with a scoped argv.
1754
+ const r = await runGit(dir, gitLogArgs(limit, filepath), gitLogEnv(filepath));
1729
1755
  if (r.code !== 0) return [];
1730
- return r.stdout
1731
- .split('\x1e')
1732
- .map((rec) => rec.replace(/^\n/, '').trim())
1733
- .filter(Boolean)
1734
- .map((rec) => {
1735
- const [sha, message, author, email, date] = rec.split('\x1f');
1736
- return { sha, message, author, email, date };
1737
- });
1756
+ return parseGitLog(r.stdout);
1738
1757
  }
1739
1758
 
1740
1759
  // ── diff (visual before/after) ─────────────────────────────────────────────
@@ -165,6 +165,16 @@ export function classifyChange(
165
165
  }
166
166
  return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
167
167
  }
168
+ // The PhotoEdit sidecar (`assets/<sha8>.photo.json`) — a peer's photo
169
+ // adjustment arriving over the file plane. Ride the existing `asset` mode:
170
+ // the shell recognises the `.photo.json` shape and, instead of re-pointing
171
+ // an <img> (nothing references the sidecar), tells the canvas to re-fetch
172
+ // the edit and re-bake (`maude:photo-edit-refreshed`). Without this the
173
+ // sidecar changed on disk and no open canvas ever heard — a photo edit from
174
+ // another machine appeared only after a manual reload.
175
+ if (rel.endsWith('.photo.json') && /(^|\/)assets\//.test(rel)) {
176
+ return { type: 'canvas-hmr', mode: 'asset', file: rel, version, scope: 'canvas' };
177
+ }
168
178
  // Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
169
179
  // artboard layout / viewport. Emit a `meta` mode so foreign tabs can
170
180
  // re-fetch + re-apply the layout WITHOUT a full reload (which would
@@ -7,7 +7,8 @@
7
7
  import { createHash } from 'node:crypto';
8
8
  import { existsSync, readFileSync, realpathSync, unlinkSync, watch } from 'node:fs';
9
9
  import { dirname, join, posix, relative, resolve, sep } from 'node:path';
10
-
10
+ // Ownership mutations live with the CLI on purpose — see /_api/sync/ownership.
11
+ import { adoptToHub, detachToRepo, ownershipState } from '../../cli/lib/design-ownership.mjs';
11
12
  import {
12
13
  cancelSignin,
13
14
  getClaudeAuthStatus,
@@ -105,12 +106,15 @@ import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
105
106
  import { probeReadiness } from './readiness.ts';
106
107
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
107
108
  import { currentSession } from './session-scope.ts';
109
+ import { sanitizeForLog } from './sync/cell-pairing.ts';
108
110
  import { linkHub } from './sync/hub-link.ts';
109
111
  import { isHubReadOnly } from './sync/hubs-config.ts';
112
+ import { isFirstAnchorMode, readSyncSettings, writeSyncSettings } from './sync/settings.ts';
113
+ import { listTrash, pruneTrash, restoreFromTrash } from './sync/trash.ts';
110
114
  import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
111
115
  import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
112
116
  import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
113
- import { isWorkspaceMode } from './workspace-mode.ts';
117
+ import { isWorkspaceMode, resolveRenderLane } from './workspace-mode.ts';
114
118
  import { isLoopbackHost } from './ws.ts';
115
119
 
116
120
  // Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
@@ -742,6 +746,49 @@ const HIST_WINDOW_MS = 10_000;
742
746
  const HIST_MAX_BUILDS = 24;
743
747
  let histWindowStart = Date.now();
744
748
  let histBuilds = 0;
749
+
750
+ // Shas neither git NOR the linked cell could resolve (feature-cloud-managed-git-
751
+ // posture). Without it, every re-request of a bad sha costs a git lookup AND a
752
+ // network round trip to the hub — and the sha is attacker-influenceable from the
753
+ // untrusted canvas origin (DDR-054), so "it will fail again" must be cheap to
754
+ // say. Bounded like the positive cache; a miss is only ever a wasted retry.
755
+ const historicalMissCache = new Set<string>();
756
+ const HIST_MISS_MAX = 256;
757
+
758
+ /**
759
+ * The linked cell's blob reader, or null when this project is not
760
+ * cloud-managed.
761
+ *
762
+ * `createCloudEndpoints` is a pure closure factory (it touches disk only when a
763
+ * method is CALLED), and `historyFile` already answers null unless the folder
764
+ * is linked AND credentialed — which is exactly the cloud-managed condition.
765
+ * So the posture is not re-derived here; it is asked of the module that owns
766
+ * it. Memoized per repo root because a process serves one.
767
+ */
768
+ let cloudHistoryFor: { root: string; api: ReturnType<typeof createCloudEndpoints> } | null = null;
769
+
770
+ /**
771
+ * Forget every historical build and every remembered absence.
772
+ *
773
+ * Called when the project's LINK changes (Connect / Disconnect). Both caches
774
+ * are keyed by `(path, sha)` and neither mentions which repo answered, so a
775
+ * cloud-sourced entry — and, more importantly, an absence the CELL was
776
+ * authoritative about — would otherwise outlive the link and be served to a
777
+ * now-local project that may well have the object. (Security re-review of
778
+ * 8134ca8f, finding F5.)
779
+ */
780
+ export function clearHistoricalCaches(): void {
781
+ historicalCanvasCache.clear();
782
+ historicalMissCache.clear();
783
+ cloudHistoryFor = null;
784
+ }
785
+ function cloudHistoryApi(ctx: Context): ReturnType<typeof createCloudEndpoints> {
786
+ if (cloudHistoryFor?.root !== ctx.paths.repoRoot) {
787
+ cloudHistoryFor = { root: ctx.paths.repoRoot, api: createCloudEndpoints(ctx) };
788
+ }
789
+ return cloudHistoryFor.api;
790
+ }
791
+
745
792
  function historicalBuildAllowed(): boolean {
746
793
  const now = Date.now();
747
794
  if (now - histWindowStart >= HIST_WINDOW_MS) {
@@ -772,7 +819,51 @@ async function serveHistoricalCanvas(
772
819
  headers: { 'Retry-After': '10', 'Cache-Control': 'no-store' },
773
820
  });
774
821
  }
775
- const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
822
+ // LOCAL FIRST, THEN THE CLOUD. In cloud-managed posture the History rows
823
+ // come from the cell, so the sha the user clicked may exist ONLY there —
824
+ // the local repo has no commits at all. Local still goes first: it is a
825
+ // disk read against a repo that may well have the object (a folder that was
826
+ // committed to before it was linked), and it costs nothing to try.
827
+ //
828
+ // Everything downstream is unchanged — same build, same CSP, same LRU, same
829
+ // DiffView. Historical content is immutable, so a cloud-sourced build
830
+ // caches under the identical `(path, sha)` key.
831
+ //
832
+ // The sha is validated on BOTH sides: `/^[0-9a-f]{7,40}$/` here (via
833
+ // `historyFile`) and again on the hub. It arrives from the untrusted canvas
834
+ // origin, and "the other end checks it" is how a guard ends up on neither.
835
+ // A sha that already failed AUTHORITATIVELY fails again — say so without
836
+ // spending the git process or the round trip.
837
+ if (historicalMissCache.has(key)) {
838
+ return new Response('No saved version of this canvas', { status: 404 });
839
+ }
840
+ let source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
841
+ if (source == null) {
842
+ const fromCloud = await cloudHistoryApi(ctx).historyFile(sha, repoRel);
843
+ source = fromCloud.ok ? fromCloud.source : null;
844
+ // ONLY AN AUTHORITATIVE ABSENCE IS REMEMBERED (security re-review of
845
+ // 8134ca8f, finding F1). Two things must never be cached here:
846
+ //
847
+ // 1. A TRANSIENT failure. `unreachable` / `not-linked` mean the cell
848
+ // could not answer, not that the version is absent — remembering
849
+ // that outlives the outage and makes a real version permanently
850
+ // unpreviewable, with no way to clear it but a restart.
851
+ // 2. A MUTABLE ref. The local engine's positional guard admits `HEAD`,
852
+ // branch and tag names, and DiffView's own "compare with saved"
853
+ // opens `?sha=HEAD`. Caching a miss for a moving name is a bug by
854
+ // construction: diff an as-yet-uncommitted canvas, commit it, and
855
+ // `HEAD` now resolves — but the cached miss short-circuits before
856
+ // `gitShowFile` is ever asked again. That is exactly the "read
857
+ // failure rendered as absence" this feature exists to delete.
858
+ const authoritative = !fromCloud.ok && fromCloud.reason === 'not-found';
859
+ if (source == null && authoritative && /^[0-9a-f]{7,40}$/.test(sha)) {
860
+ historicalMissCache.add(key);
861
+ if (historicalMissCache.size > HIST_MISS_MAX) {
862
+ const oldest = historicalMissCache.values().next().value;
863
+ if (oldest !== undefined) historicalMissCache.delete(oldest);
864
+ }
865
+ }
866
+ }
776
867
  if (source == null) return new Response('No saved version of this canvas', { status: 404 });
777
868
  try {
778
869
  const result = await buildCanvasModule(absPath, source, {
@@ -1122,7 +1213,11 @@ export function createHttp(
1122
1213
  // GitHub routes: MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
1123
1214
  // startCanvasServer) and loopback-Host gated — every route either bears or
1124
1215
  // stores the cloud credential.
1125
- const cloudApi = createCloudEndpoints(ctx);
1216
+ const cloudApi = createCloudEndpoints({
1217
+ ...ctx,
1218
+ // Connect and Disconnect both change WHO can answer for a past version.
1219
+ onLinkChanged: clearHistoricalCaches,
1220
+ });
1126
1221
  // Figma import (DDR-216). Same dual-allowlist rule as the GitHub + cloud
1127
1222
  // routes: MAIN-ORIGIN ONLY, plus loopback-Host and same-origin gating on
1128
1223
  // every one of them — each either stores or spends the user's Figma PAT.
@@ -1256,6 +1351,23 @@ export function createHttp(
1256
1351
  // blank. See canvasShellUrl().
1257
1352
  tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
1258
1353
  },
1354
+ // DDR-230 — how the render service reaches this project's canvas when
1355
+ // the job dispatches remotely (workspace `remote` lane): the PUBLIC
1356
+ // canvas origin the member's own iframes load from, plus the member's
1357
+ // render token forwarded from THIS request. On a desktop both are
1358
+ // absent/loopback and the lane is `local`, so this is never read.
1359
+ remoteCanvas: {
1360
+ // MAUDE_RENDER_CANVAS_BASE overrides for deployments where the render
1361
+ // service reaches the canvas on a different address than the member's
1362
+ // browser does (the self-hosted sidecar fetches the hub over the
1363
+ // compose network, not the public domain).
1364
+ origin: process.env.MAUDE_RENDER_CANVAS_BASE ?? ctx.canvasOrigin ?? '',
1365
+ // The VIEWER-scoped render token (DDR-230 §c) the hub proxy injects —
1366
+ // NOT `x-maude-canvas-token`, which carries the member's own (up to
1367
+ // write-capable) role. The worker only reads, so it holds only read.
1368
+ token: req.headers.get('x-maude-render-token') ?? undefined,
1369
+ tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
1370
+ },
1259
1371
  };
1260
1372
  }
1261
1373
 
@@ -1629,6 +1741,11 @@ export function createHttp(
1629
1741
  // New feed uses: one answer, so the chip in the status bar and the feed
1630
1742
  // can never name different versions.
1631
1743
  version: resolveMaudeVersion(),
1744
+ // feature-cloud-export-render-workers — which export lane this server
1745
+ // holds (`local` | `remote` | `none`). The client gates the export
1746
+ // dialogs on it so a cell without a render service says WHY a format
1747
+ // is unavailable instead of firing a request the proxy 404s.
1748
+ exportLane: resolveRenderLane(),
1632
1749
  // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
1633
1750
  // canvas origin, minted per session by the proxy. Absent on a desktop,
1634
1751
  // where the canvas origin is loopback and needs none. `canvasUrl()`
@@ -1645,7 +1762,20 @@ export function createHttp(
1645
1762
  ...(isWorkspaceMode()
1646
1763
  ? {
1647
1764
  cloud: {
1648
- dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
1765
+ // PRESENCE IS THE SWITCH — never a default. `HUB_DASHBOARD_URL`
1766
+ // is written only by the managed fleet (cli/lib/cell-plan.mjs);
1767
+ // a self-hosted `maude hub workspace` never sets it and has no
1768
+ // multi-project dashboard to return to. Defaulting it to the
1769
+ // SaaS put a `← Dashboard` link in every self-hoster's menubar
1770
+ // that walked them off their own deployment onto cloud.maude.sh
1771
+ // — a dead end for an account most of them do not have. Same
1772
+ // rule the hub landing already keeps (server.mjs `isPlatform`)
1773
+ // and the auth pages already keep (browser-auth.mjs): no URL,
1774
+ // no link. Absent, not null, so the client's own check is the
1775
+ // single one — exactly like `canvasToken` two fields down.
1776
+ ...(process.env.HUB_DASHBOARD_URL
1777
+ ? { dashboardUrl: process.env.HUB_DASHBOARD_URL }
1778
+ : {}),
1649
1779
  projectName: process.env.MAUDE_PROJECT_NAME ?? ctx.cfg.name ?? null,
1650
1780
  // WHO the tab is signed in as, and WHAT that makes them —
1651
1781
  // per REQUEST, from the proxy's injected headers, never from
@@ -1979,7 +2109,10 @@ export function createHttp(
1979
2109
  { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1980
2110
  );
1981
2111
  }
1982
- return Response.json({ ok: true, ...result }, { headers: { 'Cache-Control': 'no-store' } });
2112
+ // Spread FIRST: `result` carries its own `ok`, so the old ordering made
2113
+ // the literal dead. Same value either way (this branch is past the
2114
+ // `!result.ok` return), but the guarantee now reads as one.
2115
+ return Response.json({ ...result, ok: true }, { headers: { 'Cache-Control': 'no-store' } });
1983
2116
  }
1984
2117
  return new Response('Method not allowed', { status: 405 });
1985
2118
  },
@@ -2233,6 +2366,22 @@ export function createHttp(
2233
2366
  { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2234
2367
  );
2235
2368
  }
2369
+ // WHO moved this file. A move is the one file-tree op that can make a
2370
+ // canvas seem to vanish, and the receiving side's log only ever names the
2371
+ // retirement — never the request that caused it. The caller (browser
2372
+ // webview vs. a script) is the fact that separates "the sync runtime did
2373
+ // something" from "someone asked for this".
2374
+ // SCRUBBED, ALL THREE VALUES. The User-Agent is caller-controlled and this
2375
+ // line's whole job is answering "who moved this canvas" — an unscrubbed
2376
+ // one lets a caller forge a second, convincing `[fs-move]` line and defeat
2377
+ // the record at exactly its own purpose (ANSI/ESC survives even where a
2378
+ // header parser rejects CR/LF). `fromRel`/`toRel` are CONTAINED but not
2379
+ // renderable: `moveCanvas` checks traversal and group membership, never
2380
+ // control characters.
2381
+ console.log(
2382
+ `[fs-move] ${sanitizeForLog(result.fromRel)} → ${sanitizeForLog(result.toRel)} ` +
2383
+ `(ua=${sanitizeForLog(req.headers.get('user-agent') || 'none').slice(0, 60)})`
2384
+ );
2236
2385
  return Response.json(
2237
2386
  {
2238
2387
  ok: true,
@@ -2455,6 +2604,25 @@ export function createHttp(
2455
2604
  return new Response('local request required', { status: 403 });
2456
2605
  return gitJson(cloudApi.signout());
2457
2606
  },
2607
+ // feature-cloud-managed-git-posture — the history the cell is actually
2608
+ // writing. MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API + startCanvasServer
2609
+ // routes, DDR-088): the request is proxied onward under the stored hub
2610
+ // credential, which is exactly the shape the untrusted canvas origin must
2611
+ // never reach. `sameOriginRead` is the CSRF guard, `isTrustedRequestHost`
2612
+ // the DNS-rebind one — both mandatory on a token-bearing route.
2613
+ //
2614
+ // A hub 401 here is a plain read failure. It must NOT take the
2615
+ // `/_api/cloud/status` path that deletes the stored credential (F6): a cell
2616
+ // restarting mid-renewal would otherwise silently unlink a working project
2617
+ // because History polled at the wrong second.
2618
+ '/_api/cloud/history': async (req: Request) => {
2619
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2620
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
2621
+ if (!isTrustedRequestHost(req))
2622
+ return new Response('local request required', { status: 403 });
2623
+ const u = new URL(req.url).searchParams;
2624
+ return gitJson(await cloudApi.history(u.get('path'), u.get('limit')));
2625
+ },
2458
2626
  '/_api/cloud/projects': async (req: Request) => {
2459
2627
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2460
2628
  if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
@@ -2543,6 +2711,216 @@ export function createHttp(
2543
2711
  return gitJson({ status: 200, json: { ok: true, sync } });
2544
2712
  },
2545
2713
 
2714
+ // feature-before-first-external-users Task 2 — the three user-facing sync
2715
+ // toggles (syncFiles / propagateDeletes / resolveFirstAnchor), so the
2716
+ // breaker remediation stops meaning "edit JSON by hand" (DDR-177: the
2717
+ // target user has no terminal). MAIN-ORIGIN ONLY: absent from BOTH
2718
+ // CANVAS_SAFE_API and startCanvasServer's `routes` map (DDR-088) —
2719
+ // untrusted canvas content must not be able to turn delete-propagation on
2720
+ // or resolve a first-anchor hold against the user.
2721
+ '/_api/sync/settings': async (req: Request) => {
2722
+ if (!isTrustedRequestHost(req))
2723
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2724
+ if (req.method === 'GET') {
2725
+ return Response.json(
2726
+ { settings: readSyncSettings(ctx.paths.repoRoot) },
2727
+ { headers: { 'Cache-Control': 'no-store' } }
2728
+ );
2729
+ }
2730
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2731
+ if (!sameOriginWrite(req)) return syncRefusal('cross-origin', 'Settings must be changed');
2732
+ const body = await readJson<{
2733
+ syncFiles?: unknown;
2734
+ propagateDeletes?: unknown;
2735
+ resolveFirstAnchor?: unknown;
2736
+ }>(req, 4 * 1024);
2737
+ const patch: Record<string, unknown> = {};
2738
+ if ('syncFiles' in (body ?? {})) {
2739
+ if (typeof body?.syncFiles !== 'boolean')
2740
+ return new Response('syncFiles must be a boolean', { status: 400 });
2741
+ patch.syncFiles = body.syncFiles;
2742
+ }
2743
+ if ('propagateDeletes' in (body ?? {})) {
2744
+ if (typeof body?.propagateDeletes !== 'boolean')
2745
+ return new Response('propagateDeletes must be a boolean', { status: 400 });
2746
+ patch.propagateDeletes = body.propagateDeletes;
2747
+ }
2748
+ if ('resolveFirstAnchor' in (body ?? {})) {
2749
+ if (!isFirstAnchorMode(body?.resolveFirstAnchor))
2750
+ return new Response('resolveFirstAnchor must be ask|keep-local|keep-cloud', {
2751
+ status: 400,
2752
+ });
2753
+ patch.resolveFirstAnchor = body.resolveFirstAnchor;
2754
+ }
2755
+ if (Object.keys(patch).length === 0)
2756
+ return new Response('nothing to change', { status: 400 });
2757
+ try {
2758
+ const settings = await writeSyncSettings(ctx.paths.repoRoot, patch);
2759
+ reloadConfig(ctx);
2760
+ // The toggles apply at sync boot, so a saved change asks the
2761
+ // supervisor for a restart — same refusal semantics as Resync: when a
2762
+ // cycle is already running the setting is SAVED and applies on the
2763
+ // next restart, and the response says which of the two happened.
2764
+ let applied = false;
2765
+ const control = ctx.syncControl;
2766
+ if (control && !control.busy?.()) {
2767
+ await control.restart();
2768
+ applied = true;
2769
+ }
2770
+ return Response.json(
2771
+ { ok: true, settings, applied },
2772
+ { headers: { 'Cache-Control': 'no-store' } }
2773
+ );
2774
+ } catch (err) {
2775
+ return gitJson({
2776
+ status: 400,
2777
+ json: {
2778
+ ok: false,
2779
+ detail: err instanceof Error ? err.message : 'settings write failed',
2780
+ },
2781
+ });
2782
+ }
2783
+ },
2784
+
2785
+ // feature-before-first-external-users Task 2 — ownership (repo-owned vs
2786
+ // hub-owned) from the UI. `maude design adopt`/`detach` were CLI-only,
2787
+ // against DDR-177's own posture (the target user never opens a terminal),
2788
+ // and B11 flagged settleOwnership mutating `.gitignore`/index without
2789
+ // asking in non-TTY — here the dialog IS the asking: this route only ever
2790
+ // acts on an explicit user click. MAIN-ORIGIN ONLY, both allowlists absent
2791
+ // (it mutates `.gitignore` + the git index and can drop the hub link).
2792
+ //
2793
+ // Imports cli/lib/design-ownership.mjs DIRECTLY — deliberately not the
2794
+ // hubs-config.ts read-only-mirror pattern: these are MUTATIONS with safety
2795
+ // rules (narrow staging, refuse-on-malformed-gitignore, --cached only),
2796
+ // and two owners of that logic would drift exactly where drift is loss.
2797
+ // Both trees ship together (npm `files`, desktop resources), so the
2798
+ // relative import resolves in every install shape.
2799
+ '/_api/sync/ownership': async (req: Request) => {
2800
+ if (!isTrustedRequestHost(req))
2801
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2802
+ const linked = !!ctx.cfg.linkedHub?.url;
2803
+ if (req.method === 'GET') {
2804
+ const st = ownershipState(ctx.paths.repoRoot, { linked });
2805
+ return Response.json(
2806
+ {
2807
+ mode: st.mode,
2808
+ git: st.git,
2809
+ trackedCount: st.trackedCount,
2810
+ linked,
2811
+ hubUrl: ctx.cfg.linkedHub?.url ?? null,
2812
+ syncthingRoot: st.syncthingRoot ?? null,
2813
+ ...(st.stignoreLine ? { stignoreLine: st.stignoreLine } : {}),
2814
+ },
2815
+ { headers: { 'Cache-Control': 'no-store' } }
2816
+ );
2817
+ }
2818
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2819
+ if (!sameOriginWrite(req)) return syncRefusal('cross-origin', 'Ownership must be changed');
2820
+ const body = await readJson<{ action?: unknown }>(req, 4 * 1024);
2821
+ const action = body?.action;
2822
+ try {
2823
+ if (action === 'adopt') {
2824
+ // A → B: gitignore the design root, untrack (--cached — every byte
2825
+ // stays on disk). Only meaningful while linked: with no hub there
2826
+ // would be no owner left at all.
2827
+ if (!linked)
2828
+ return gitJson({
2829
+ status: 400,
2830
+ json: { ok: false, detail: 'Link a workspace first — adopt hands .design/ to it.' },
2831
+ });
2832
+ const res = adoptToHub(ctx.paths.repoRoot);
2833
+ if (res.action === 'refused-malformed')
2834
+ return gitJson({
2835
+ status: 409,
2836
+ json: {
2837
+ ok: false,
2838
+ detail:
2839
+ '.gitignore carries an ownership block Maude did not write — fix it by hand, then retry.',
2840
+ },
2841
+ });
2842
+ return gitJson({ status: 200, json: { ok: true, ...res } });
2843
+ }
2844
+ if (action === 'detach') {
2845
+ // B → A: unlink (same path as the Cloud detach — credential dropped,
2846
+ // sync stopped NOW) and un-ignore so the person can commit again.
2847
+ // detachToRepo deliberately does NOT commit or `git add .design` —
2848
+ // what to commit and when stays theirs.
2849
+ const unlink = await cloudApi.detach();
2850
+ const res = detachToRepo(ctx.paths.repoRoot);
2851
+ if (res.action === 'refused-malformed')
2852
+ return gitJson({
2853
+ status: 409,
2854
+ json: {
2855
+ ok: false,
2856
+ detail:
2857
+ 'Unlinked, but .gitignore carries an ownership block Maude did not write — remove it by hand to finish.',
2858
+ },
2859
+ });
2860
+ return gitJson({
2861
+ status: 200,
2862
+ json: {
2863
+ ok: true,
2864
+ unlinked: (unlink.json as { detached?: boolean } | undefined)?.detached ?? false,
2865
+ ...res,
2866
+ },
2867
+ });
2868
+ }
2869
+ return new Response('action must be adopt|detach', { status: 400 });
2870
+ } catch (err) {
2871
+ return gitJson({
2872
+ status: 500,
2873
+ json: {
2874
+ ok: false,
2875
+ detail: err instanceof Error ? err.message : 'ownership change failed',
2876
+ },
2877
+ });
2878
+ }
2879
+ },
2880
+
2881
+ // feature-before-first-external-users Task 3 (F-6) — `_trash/` becomes
2882
+ // discoverable, restorable and prunable. MAIN-ORIGIN ONLY, both DDR-088
2883
+ // allowlists absent: restore MOVES files into the live project and prune
2884
+ // DELETES quarantined copies — from the canvas origin either would let
2885
+ // untrusted content resurrect or destroy parked user work.
2886
+ '/_api/sync/trash': async (req: Request) => {
2887
+ if (!isTrustedRequestHost(req))
2888
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2889
+ if (req.method === 'GET') {
2890
+ const entries = listTrash(ctx.paths.designRoot);
2891
+ const bytes = entries.reduce((n, e) => n + e.size, 0);
2892
+ return Response.json(
2893
+ // Cap the wire size; totals stay exact so the panel never lies.
2894
+ { entries: entries.slice(0, 500), total: entries.length, bytes },
2895
+ { headers: { 'Cache-Control': 'no-store' } }
2896
+ );
2897
+ }
2898
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2899
+ if (!sameOriginWrite(req))
2900
+ return syncRefusal('cross-origin', 'Trash actions must be started');
2901
+ const body = await readJson<{
2902
+ action?: unknown;
2903
+ trashRel?: unknown;
2904
+ olderThanDays?: unknown;
2905
+ }>(req, 8 * 1024);
2906
+ if (body?.action === 'restore') {
2907
+ if (typeof body.trashRel !== 'string')
2908
+ return new Response('trashRel must be a string', { status: 400 });
2909
+ const res = restoreFromTrash(ctx.paths.designRoot, body.trashRel);
2910
+ return gitJson({ status: res.ok ? 200 : 400, json: res });
2911
+ }
2912
+ if (body?.action === 'prune') {
2913
+ const days = body.olderThanDays === undefined ? 30 : Number(body.olderThanDays);
2914
+ // ≥ 1 day: a 0-day prune is "empty the trash", which deserves its own
2915
+ // deliberate gesture, not a slider slipped to zero.
2916
+ if (!Number.isFinite(days) || days < 1)
2917
+ return new Response('olderThanDays must be a number ≥ 1', { status: 400 });
2918
+ const res = pruneTrash(ctx.paths.designRoot, days);
2919
+ return gitJson({ status: 200, json: { ok: true, ...res } });
2920
+ }
2921
+ return new Response('action must be restore|prune', { status: 400 });
2922
+ },
2923
+
2546
2924
  '/_api/sync/cancel-assets': async (req: Request) => {
2547
2925
  // Cancel is scoped to the ASSET SWEEP: killing a reconnect mid-handshake
2548
2926
  // is not a meaningful gesture, killing a multi-hundred-megabyte upload
@@ -14,6 +14,7 @@
14
14
  * own avatar to release. tldraw-style.
15
15
  */
16
16
 
17
+ import type { JSX } from 'react';
17
18
  import { useCallback, useEffect, useRef, useState } from 'react';
18
19
 
19
20
  import { useViewportControllerContext } from './canvas-lib.tsx';
@@ -144,6 +144,13 @@ export function createPhotoStore(ctx: Context): PhotoStore {
144
144
  try {
145
145
  // Bun.write creates the assets/ dir if absent.
146
146
  await Bun.write(abs, JSON.stringify(toWrite, null, 2));
147
+ // Say the write happened. In a cell the recursive fs.watch never fires,
148
+ // and `fs:any` is how (a) the HMR broadcaster tells open canvases to
149
+ // re-bake the photo and (b) the write-nudge tells the hub to journal the
150
+ // sidecar — without it a photo edit saved in the cloud reached peers
151
+ // only on the walk-import belt, and open canvases not at all. Locally
152
+ // the real watcher fires too; the broadcaster's debounce coalesces.
153
+ ctx.bus.emit('fs:any', path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/'));
147
154
  } catch (e) {
148
155
  return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
149
156
  }
@@ -0,0 +1,17 @@
1
+ // Ambient React augmentations for markup this codebase genuinely emits but
2
+ // @types/react does not model.
3
+ //
4
+ // `xmlns` on an HTML element. Inside `<foreignObject>` the child div MUST carry
5
+ // the XHTML namespace, or the serialized `.annotations.svg` (the VERSIONED
6
+ // artifact, per DDR-115) is not valid SVG and a standalone viewer drops the
7
+ // sticky body. React passes the attribute straight through to the DOM; only the
8
+ // type is missing. Declared once here rather than cast at each call site, so a
9
+ // third foreignObject child does not re-invent the workaround.
10
+
11
+ import 'react';
12
+
13
+ declare module 'react' {
14
+ interface HTMLAttributes<T> {
15
+ xmlns?: string;
16
+ }
17
+ }