@1agh/maude 0.52.1 → 0.53.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.
@@ -79,6 +79,19 @@ export interface BuildCanvasOptions {
79
79
  * detected.
80
80
  */
81
81
  designRoot?: string;
82
+ /**
83
+ * Cloud Phase 25 A1 — the SANDBOX's import allowlist.
84
+ *
85
+ * On a laptop an unrestricted resolver is correct and harmless: your code,
86
+ * your machine, your files. In a CELL the same resolver is a build-time read
87
+ * primitive pointed at a container that holds this tenant's credentials, so
88
+ * a tenant's own source may reach ONLY the runtime packages,
89
+ * `@maude/canvas-lib`, and relative paths that resolve INSIDE the design
90
+ * root. Anything else is a legible build error, never a silent read.
91
+ *
92
+ * Set to the absolute design root to arm it. Absent (desktop) ⇒ unchanged.
93
+ */
94
+ restrictImportsTo?: string;
82
95
  }
83
96
 
84
97
  /**
@@ -115,6 +128,7 @@ export async function buildCanvasModule(
115
128
  // in the bundle). The flatMap legacy alias for `react-dom` is no longer
116
129
  // needed; RUNTIME_PACKAGES already lists every specifier the importmap covers.
117
130
  const externalSpecifiers = new Set<string>(RUNTIME_PACKAGES);
131
+ const denials: Array<{ specifier: string; reason: string }> = [];
118
132
 
119
133
  const built = await Bun.build({
120
134
  entrypoints: [canvasAbsPath],
@@ -122,6 +136,12 @@ export async function buildCanvasModule(
122
136
  format: 'esm',
123
137
  minify: false,
124
138
  splitting: false,
139
+ // Bun.build THROWS by default (an AggregateError whose message is the
140
+ // useless string "Bundle failed"), which made the `built.success` branch
141
+ // below dead code and hid every real diagnostic — including, once the
142
+ // sandbox arrived, the allowlist's own explanation of what it refused.
143
+ // Take the result, report it ourselves.
144
+ throw: false,
125
145
  define: {
126
146
  // Match runtime-bundle.ts: production React. Without this the canvas's
127
147
  // `import { jsxDEV } from "react/jsx-dev-runtime"` resolves against a
@@ -154,10 +174,20 @@ export async function buildCanvasModule(
154
174
  });
155
175
  },
156
176
  },
177
+ // LAST on purpose: by the time a specifier reaches this plugin, the
178
+ // runtime packages are external and `@maude/canvas-lib` is resolved, so
179
+ // what is left is exactly "everything else" — which, from a tenant's
180
+ // own file, must stay inside the design root.
181
+ ...(options.restrictImportsTo ? [importAllowlist(options.restrictImportsTo, denials)] : []),
157
182
  ],
158
183
  });
159
184
 
160
185
  if (!built.success) {
186
+ // A DENIED IMPORT IS THE MESSAGE. Bun collapses a plugin throw into a bare
187
+ // "Bundle failed" log, so the allowlist records its own reasons and they
188
+ // win here — the person who wrote the canvas has to be able to act on it,
189
+ // and "Bundle failed" tells them nothing.
190
+ if (denials.length > 0) throw new Error(denials.map((d) => d.reason).join('\n'));
161
191
  const msg = built.logs.map((l) => l.message).join('\n');
162
192
  throw new Error(`Bun.build failed on ${canvasAbsPath}:\n${msg}`);
163
193
  }
@@ -201,6 +231,67 @@ function buildCssInjector(slug: string, css: string): string {
201
231
  return `// canvas-build: inject bundled sibling CSS so the canvas is self-contained.\n(function(){if(typeof document==="undefined")return;if(document.getElementById(${JSON.stringify(id)}))return;var s=document.createElement("style");s.id=${JSON.stringify(id)};s.dataset.canvasCss="bundled";s.textContent=${enc};document.head.appendChild(s);})();\n`;
202
232
  }
203
233
 
234
+ /**
235
+ * The sandbox's import allowlist (Cloud Phase 25 A1).
236
+ *
237
+ * The rule is stated by IMPORTER, not by target, because the two trees have
238
+ * different rights: canvas-lib and its siblings are OUR code and import each
239
+ * other freely; a tenant's canvas may only reach inside the design root. A
240
+ * target-only rule would let a tenant's `../../../app/...` traversal land in
241
+ * the maude install and be waved through as "our" file.
242
+ *
243
+ * Denials THROW with the specifier and the reason — a rejected import must
244
+ * reach the person as a legible build error, never a 500 and never a silent
245
+ * read of something they should not have.
246
+ */
247
+ function importAllowlist(
248
+ designRoot: string,
249
+ denials: Array<{ specifier: string; reason: string }>
250
+ ): {
251
+ name: string;
252
+ setup: (builder: {
253
+ onResolve: (
254
+ opts: { filter: RegExp },
255
+ cb: (args: { path: string; importer: string }) => unknown
256
+ ) => void;
257
+ }) => void;
258
+ } {
259
+ const root = path.resolve(designRoot);
260
+ const inRoot = (p: string) => p === root || p.startsWith(root + path.sep);
261
+ const deny = (specifier: string, reason: string): never => {
262
+ denials.push({ specifier, reason });
263
+ throw new Error(reason);
264
+ };
265
+ return {
266
+ name: 'maude-import-allowlist',
267
+ setup(builder) {
268
+ builder.onResolve({ filter: /.*/ }, (args: { path: string; importer: string }) => {
269
+ const importer = args.importer ? path.resolve(args.importer) : '';
270
+ // Our own modules (canvas-lib and its graph) resolve normally.
271
+ if (importer && !inRoot(importer)) return null;
272
+ if (!args.path.startsWith('.') && !path.isAbsolute(args.path)) {
273
+ deny(
274
+ args.path,
275
+ `This canvas imports "${args.path}", which is not available when it renders in a browser. ` +
276
+ `A canvas here can use the Maude runtime (react, motion, @maude/canvas-lib) and its own ` +
277
+ `project files — npm packages are not installed. Open the project in Maude Desktop if you ` +
278
+ `need one.`
279
+ );
280
+ }
281
+ const resolved = path.resolve(path.dirname(importer || root), args.path);
282
+ if (!inRoot(resolved)) {
283
+ deny(
284
+ args.path,
285
+ `This canvas imports "${args.path}", which is outside the project (${path.relative(root, resolved)}). ` +
286
+ `A canvas may only import files inside its own design.`
287
+ );
288
+ }
289
+ return null; // inside the root — let Bun's resolver finish the job
290
+ });
291
+ },
292
+ };
293
+ }
294
+
204
295
  function filterForExactPath(absPath: string): RegExp {
205
296
  // Bun.build hands onLoad the resolved on-disk path. On macOS /tmp is a
206
297
  // symlink to /private/tmp, so an entrypoint of "/tmp/foo.tsx" arrives in
@@ -128,6 +128,7 @@ import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
128
128
  // (photo/pipeline.ts, which imports pixi.js) is loaded via a DYNAMIC import
129
129
  // inside <PhotoLayer> only when an edited photo actually mounts — see there.
130
130
  import { isDefaultEdit, type PhotoEdit } from './photo/schema.ts';
131
+ import { isReadOnlyCanvas } from './read-only-mode.ts';
131
132
  import { AgentPresenceProvider, useAgentPresence } from './use-agent-presence.tsx';
132
133
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
133
134
  import {
@@ -757,6 +758,14 @@ function patchCanvasMeta(patch: {
757
758
  layout?: { artboards: ArtboardRect[] };
758
759
  }): void {
759
760
  if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
761
+ // Cloud Phase 25 C2 — a read-only session persists its CAMERA (per-user
762
+ // `_canvas-state/` view, DDR-115) but never the LAYOUT lane (versioned
763
+ // `.meta.json`). The dev-server refuses the layout lane too (http.ts);
764
+ // dropping it here keeps the UI from even trying.
765
+ if (patch.layout && isReadOnlyCanvas()) {
766
+ if (!patch.viewport) return;
767
+ patch = { viewport: patch.viewport };
768
+ }
760
769
  const file = readCanvasMetaFile();
761
770
  if (!file) return;
762
771
  const sanitized: {
@@ -2096,7 +2105,9 @@ export function DCArtboard({
2096
2105
  rectFor: (rid) => (ctx ? ctx.rectFor(rid) : null),
2097
2106
  allRects: ctx?.artboards ?? [],
2098
2107
  viewport: ctx?.viewport ?? null,
2099
- enabled: !!ctx && (toolMode?.tool ?? 'move') === 'move',
2108
+ // Cloud Phase 25 C2 — a read-only canvas keeps the Move tool for
2109
+ // selection, but artboards don't drag (editing is absent, not hidden).
2110
+ enabled: !!ctx && (toolMode?.tool ?? 'move') === 'move' && !isReadOnlyCanvas(),
2100
2111
  onCommit: (moved) => {
2101
2112
  if (dragBus) dragBus.commitPositions(moved);
2102
2113
  },
@@ -89,6 +89,7 @@ import {
89
89
  import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
90
90
  import { MeasureOverlay } from './measure-overlay.tsx';
91
91
  import { ParticipantsChrome } from './participants-chrome.tsx';
92
+ import { isReadOnlyCanvas } from './read-only-mode.ts';
92
93
  import { mountCaret, placeCaretAt } from './text-caret.ts';
93
94
  import { ToolPalette } from './tool-palette.tsx';
94
95
  import { UndoHud } from './undo-hud.tsx';
@@ -1872,7 +1873,7 @@ function buildRegistry(deps: {
1872
1873
  window as unknown as { __maudeConvertArtboard?: typeof postConvertArtboardToAbsolute }
1873
1874
  ).__maudeConvertArtboard = postConvertArtboardToAbsolute;
1874
1875
 
1875
- return {
1876
+ const registry: ContextRegistry = {
1876
1877
  element: [
1877
1878
  [
1878
1879
  {
@@ -2280,6 +2281,43 @@ function buildRegistry(deps: {
2280
2281
  ],
2281
2282
  overlay: [],
2282
2283
  };
2284
+ return filterRegistryForReadOnly(registry);
2285
+ }
2286
+
2287
+ /**
2288
+ * Cloud Phase 25 C2 — a read-only canvas keeps only the context-menu items
2289
+ * that READ the project (navigate, inspect, copy, export). Everything else is
2290
+ * ABSENT, not disabled. Deliberately an ALLOWLIST: a future item defaults to
2291
+ * absent for viewers (the C1 posture), rather than leaking in by omission.
2292
+ * `add-comment` joins when Phase 25 C3 lands comments on the cell's read-only
2293
+ * allowlist.
2294
+ */
2295
+ const READ_ONLY_MENU_IDS = new Set([
2296
+ 'fit-view',
2297
+ 'reset-view',
2298
+ 'fit-one',
2299
+ 'open-timeline',
2300
+ 'copy-css',
2301
+ 'copy-id',
2302
+ 'copy-style',
2303
+ 'inspect',
2304
+ 'select-layer',
2305
+ 'deselect',
2306
+ 'export-selection',
2307
+ 'export-artboard',
2308
+ 'export-canvas',
2309
+ 'export-project',
2310
+ ]);
2311
+
2312
+ function filterRegistryForReadOnly(registry: ContextRegistry): ContextRegistry {
2313
+ if (!isReadOnlyCanvas()) return registry;
2314
+ const out = {} as ContextRegistry;
2315
+ for (const key of Object.keys(registry) as (keyof ContextRegistry)[]) {
2316
+ out[key] = registry[key]
2317
+ .map((section) => section.filter((item) => READ_ONLY_MENU_IDS.has(item.id)))
2318
+ .filter((section) => section.length > 0);
2319
+ }
2320
+ return out;
2283
2321
  }
2284
2322
 
2285
2323
  function boundsOf(el: HTMLElement) {
@@ -3003,6 +3041,10 @@ function CanvasRouter({
3003
3041
  opts?: { selectAll?: boolean }
3004
3042
  ): void {
3005
3043
  if (editing) return;
3044
+ // Cloud Phase 25 C2 — inline text edit is a source write; a read-only
3045
+ // canvas never enters edit mode (one gate for BOTH entry points: the
3046
+ // dblclick drill and the annotation Text-tool click-through).
3047
+ if (isReadOnlyCanvas()) return;
3006
3048
  editing = stamped;
3007
3049
  original = stamped.textContent ?? '';
3008
3050
  stamped.setAttribute('contenteditable', 'plaintext-only');
@@ -3248,6 +3290,11 @@ function CanvasRouter({
3248
3290
  },
3249
3291
  });
3250
3292
 
3293
+ // Cloud Phase 25 C2 — a read-only canvas mounts no EDIT chrome (resize /
3294
+ // spacing / grid handles, reorder drag, contextual + multi-artboard
3295
+ // toolbars). Editing is absent, not disabled. Selection, halos, measure,
3296
+ // annotations DISPLAY, cursors and presence all stay — they read.
3297
+ const readOnly = isReadOnlyCanvas();
3251
3298
  return (
3252
3299
  <>
3253
3300
  {children}
@@ -3259,19 +3306,21 @@ function CanvasRouter({
3259
3306
  <ElementMarqueeOverlay />
3260
3307
  <HoverHalo el={hoverEl} />
3261
3308
  <SelectionHalos />
3262
- <ElementResizeOverlay />
3263
- <SpacingHandlesOverlay />
3264
- <GridTrackHandlesOverlay />
3265
- <ReorderDrag />
3309
+ {!readOnly && <ElementResizeOverlay />}
3310
+ {!readOnly && <SpacingHandlesOverlay />}
3311
+ {!readOnly && <GridTrackHandlesOverlay />}
3312
+ {!readOnly && <ReorderDrag />}
3266
3313
  <LayersLiveSync />
3267
3314
  <GroupBbox />
3268
- <EqualSpacingHandles />
3315
+ {!readOnly && <EqualSpacingHandles />}
3269
3316
  <MeasureOverlay />
3270
- <ContextualToolbar />
3271
- <MultiArtboardToolbar
3272
- distributeArtboards={distributeArtboards}
3273
- alignArtboards={alignArtboards}
3274
- />
3317
+ {!readOnly && <ContextualToolbar />}
3318
+ {!readOnly && (
3319
+ <MultiArtboardToolbar
3320
+ distributeArtboards={distributeArtboards}
3321
+ alignArtboards={alignArtboards}
3322
+ />
3323
+ )}
3275
3324
  <SnapGuideOverlay />
3276
3325
  <PhotoPreviewBridge />
3277
3326
  <UndoHud />