@1agh/maude 0.60.7 → 1.0.3

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 +201 -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 -71
  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 +915 -308
  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 +71 -108
  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 +90 -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
@@ -28,6 +28,7 @@ import { mkdir, rm } from 'node:fs/promises';
28
28
  import path from 'node:path';
29
29
 
30
30
  import type { Bus } from '../context.ts';
31
+ import { resolveRenderLane } from '../workspace-mode.ts';
31
32
  import {
32
33
  type ExportContext,
33
34
  type ExportDegradation,
@@ -37,7 +38,16 @@ import {
37
38
  runExport,
38
39
  type Scope,
39
40
  } from './index.ts';
40
- import type { ResolveScopeArgs } from './scope.ts';
41
+ import {
42
+ formatNeedsBrowser,
43
+ NoRenderServiceError,
44
+ REMOTE_UNSUPPORTED_FORMATS,
45
+ REMOTE_UNSUPPORTED_MESSAGE,
46
+ type RemoteCanvasAccess,
47
+ renderRemotely,
48
+ resolveRenderService,
49
+ } from './remote.ts';
50
+ import { type ResolveScopeArgs, resolveScope } from './scope.ts';
41
51
 
42
52
  /** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
43
53
  export interface ExportHistoryEntry {
@@ -85,6 +95,14 @@ export interface EnqueueArgs {
85
95
  options: ExportOptions;
86
96
  resolve: Omit<ResolveScopeArgs, 'scope'>;
87
97
  ctx: ExportContext;
98
+ /**
99
+ * feature-cloud-export-render-workers (DDR-230) — how the render service
100
+ * reaches this project's canvas when the job dispatches remotely: the
101
+ * PUBLIC canvas origin plus the requesting member's own render token,
102
+ * forwarded from the enqueue request. Absent on a desktop, where the lane
103
+ * is `local` and this is never read.
104
+ */
105
+ remoteCanvas?: RemoteCanvasAccess;
88
106
  }
89
107
 
90
108
  export type DownloadResult =
@@ -310,7 +328,33 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
310
328
 
311
329
  const controller = new AbortController();
312
330
 
331
+ // DDR-230 — where this job renders. `local` is the pre-render-workers
332
+ // spine, byte-identical. In a workspace the browser formats dispatch to
333
+ // the maude-render service (`remote`) or refuse with a remedy (`none`);
334
+ // browser-free formats (zip) run in-cell in every lane, because there is
335
+ // nothing to evaluate. Resolved per job, not per process, so tests can
336
+ // exercise the lanes without a reboot.
337
+ const lane = resolveRenderLane();
338
+ const dispatchRemote = lane !== 'local' && formatNeedsBrowser(args.format);
339
+
313
340
  const result = (async (): Promise<ExportResult> => {
341
+ // Fail BEFORE taking a render slot: a job that can never render must not
342
+ // queue behind ones that can, and the refusal message is the remedy the
343
+ // export dialog shows (belt to the client's own lane gate).
344
+ const laneRefusal =
345
+ lane !== 'local' && REMOTE_UNSUPPORTED_FORMATS.has(args.format)
346
+ ? REMOTE_UNSUPPORTED_MESSAGE
347
+ : dispatchRemote && lane === 'none'
348
+ ? new NoRenderServiceError().message
349
+ : null;
350
+ if (laneRefusal) {
351
+ job.status = 'failed';
352
+ job.finishedAt = new Date().toISOString();
353
+ job.error = laneRefusal;
354
+ emit(job);
355
+ await persistAndEvict();
356
+ throw new Error(laneRefusal);
357
+ }
314
358
  const release = await semaphore.acquire();
315
359
  // Started here, not at enqueue — the timeout bounds RENDER time (the
316
360
  // intent), not queue-wait time. A job can legitimately sit `queued`
@@ -323,20 +367,38 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
323
367
  job.startedAt = new Date().toISOString();
324
368
  emit(job);
325
369
 
326
- const res = await runExport({
327
- format: args.format,
328
- scope: args.scope,
329
- options: args.options,
330
- resolve: args.resolve,
331
- ctx: args.ctx,
332
- hooks: {
333
- signal: controller.signal,
334
- onProgress: (update) => {
335
- job.progress = update;
336
- emit(job);
337
- },
338
- },
339
- });
370
+ const res = dispatchRemote
371
+ ? await renderRemotely({
372
+ format: args.format,
373
+ // Scope resolves HERE, against the cell's own checkout — Target
374
+ // is pure data, and the render service holds no tenant store to
375
+ // resolve against (DDR-230 §1).
376
+ targets: await resolveScope({
377
+ scope: args.scope,
378
+ ...args.resolve,
379
+ options: args.options,
380
+ }),
381
+ options: args.options,
382
+ canvas: args.remoteCanvas ?? { origin: '' },
383
+ // resolveRenderService() is non-null on the `remote` lane by
384
+ // construction (the lane IS the presence of MAUDE_RENDER_URL).
385
+ service: resolveRenderService()!,
386
+ signal: controller.signal,
387
+ })
388
+ : await runExport({
389
+ format: args.format,
390
+ scope: args.scope,
391
+ options: args.options,
392
+ resolve: args.resolve,
393
+ ctx: args.ctx,
394
+ hooks: {
395
+ signal: controller.signal,
396
+ onProgress: (update) => {
397
+ job.progress = update;
398
+ emit(job);
399
+ },
400
+ },
401
+ });
340
402
 
341
403
  job.status = 'done';
342
404
  job.finishedAt = new Date().toISOString();
@@ -0,0 +1,201 @@
1
+ // Remote render dispatch — feature-cloud-export-render-workers (DDR-230).
2
+ //
3
+ // In a workspace cell the browser-needing exporters cannot run: no browser
4
+ // enters the image (DDR-209 A′1, unchanged). What a cell CAN do is resolve the
5
+ // export's scope against its own checkout (Target is pure data) and hand the
6
+ // fully-resolved job to the `maude-render` service, which holds a Chromium and
7
+ // NOTHING ELSE — no HUB_SECRET, no tenant store, no provider keys (DDR-230).
8
+ // The service reaches the canvas exactly the way the member's browser does:
9
+ // through the public canvas origin, authenticated by the member's own
10
+ // short-lived read-only render token, forwarded from the enqueue request.
11
+ //
12
+ // This module is deliberately dumb: one POST, the artifact (or a typed error)
13
+ // back. Progress is coarse in v1 — queued → running → done; the per-frame
14
+ // stream stays a local-lane feature until the service grows a progress channel.
15
+
16
+ import path from 'node:path';
17
+
18
+ import type { ExportDegradation } from './degraded.ts';
19
+ import type { ExportOptions, ExportResult, Format } from './index.ts';
20
+ import type { Target } from './scope.ts';
21
+
22
+ // The render service is the fleet's LEAST-trusted process (DDR-230 — it is the
23
+ // one place tenant TSX is evaluated in Chromium). Everything crossing back from
24
+ // it is untrusted, so its response is sanitized at THIS boundary before it
25
+ // reaches disk or the job record — security-review defender findings, 2026-08-21.
26
+
27
+ /**
28
+ * Cap on the artifact a render worker may return — a hostile one must not OOM
29
+ * the cell. Read per-call (not a module const) so the deployment can tune it
30
+ * and tests can exercise the ceiling.
31
+ */
32
+ function artifactCap(): number {
33
+ return Math.max(
34
+ 1_000_000,
35
+ Number(process.env.MAUDE_RENDER_MAX_ARTIFACT_BYTES) || 512 * 1024 * 1024
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Reduce whatever the render service put in `x-maude-filename` to a safe
41
+ * basename. This value has TWO untrusted sinks (security-review): it lands in
42
+ * `Bun.write(path.join(jobsDir, id, name), …)` (jobs.ts), so `../../…` is an
43
+ * arbitrary-path write; and it is reflected into a `Content-Disposition:
44
+ * attachment; filename="…"` header (http.ts), so a quote or CR/LF is header
45
+ * injection. A strict allowlist charset closes BOTH: keep only letters, digits,
46
+ * dot, dash, underscore — drop everything else (separators, quotes, control
47
+ * chars) — then reject a name that is only dots.
48
+ */
49
+ function safeArtifactName(raw: string | null, format: Format): string {
50
+ const fallback = `export.${format === 'canva' ? 'zip' : format}`;
51
+ if (!raw) return fallback;
52
+ const base = path.basename(raw).replace(/[^A-Za-z0-9._-]/g, '');
53
+ if (!base || /^\.+$/.test(base)) return fallback;
54
+ return base.slice(0, 255);
55
+ }
56
+
57
+ /** Read a response body with a hard byte ceiling — `arrayBuffer()` has none. */
58
+ async function readCapped(res: Response, max: number): Promise<Uint8Array> {
59
+ const declared = Number(res.headers.get('content-length'));
60
+ if (Number.isFinite(declared) && declared > max) {
61
+ throw new Error(`render artifact too large (${declared} bytes > ${max})`);
62
+ }
63
+ const reader = res.body?.getReader();
64
+ if (!reader) return new Uint8Array(0);
65
+ const chunks: Uint8Array[] = [];
66
+ let total = 0;
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ total += value.byteLength;
71
+ if (total > max) {
72
+ await reader.cancel().catch(() => {});
73
+ throw new Error(`render artifact exceeded ${max} bytes mid-stream`);
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ const out = new Uint8Array(total);
78
+ let offset = 0;
79
+ for (const c of chunks) {
80
+ out.set(c, offset);
81
+ offset += c.byteLength;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Formats that never touch a browser. `zip` is JSZip over project files — it
88
+ * runs in-cell in every lane. Everything else captures through the Playwright
89
+ * spine and must dispatch (or refuse) in a workspace.
90
+ */
91
+ export const BROWSER_FREE_FORMATS: ReadonlySet<Format> = new Set<Format>(['zip']);
92
+
93
+ export function formatNeedsBrowser(format: Format): boolean {
94
+ return !BROWSER_FREE_FORMATS.has(format);
95
+ }
96
+
97
+ /**
98
+ * Formats the render service cannot produce: `canva` bundles PNG captures WITH
99
+ * project files (CSV, README, sources), and the service holds no tenant store
100
+ * to read them from (DDR-230 §1). In a workspace these refuse with a
101
+ * use-the-desktop remedy on BOTH the `remote` and `none` lanes.
102
+ */
103
+ export const REMOTE_UNSUPPORTED_FORMATS: ReadonlySet<Format> = new Set<Format>(['canva']);
104
+
105
+ export const REMOTE_UNSUPPORTED_MESSAGE =
106
+ 'Canva handoff bundles project files the render service can’t reach — export it from the desktop app.';
107
+
108
+ /** Where the render service lives, from the cell/hub environment (T6 wiring). */
109
+ export interface RenderService {
110
+ /** `MAUDE_RENDER_URL` — origin of the maude-render service. */
111
+ url: string;
112
+ /** `MAUDE_RENDER_SECRET` — shared ingress bearer. Never a hub secret. */
113
+ secret: string;
114
+ }
115
+
116
+ /** How the render service reaches the tenant's canvas — the member's own grant. */
117
+ export interface RemoteCanvasAccess {
118
+ /** PUBLIC canvas origin (what the member's browser iframes load from). */
119
+ origin: string;
120
+ /** The member's render token (x-maude-canvas-token), forwarded verbatim. */
121
+ token?: string;
122
+ /** designRoot-relative tokens CSS path (`?tokens=` on the shell URL). */
123
+ tokensCssRel?: string;
124
+ /** designRoot-relative shared components CSS path, when the DS has one. */
125
+ componentsCssRel?: string;
126
+ }
127
+
128
+ export function resolveRenderService(env: NodeJS.ProcessEnv = process.env): RenderService | null {
129
+ const url = env.MAUDE_RENDER_URL;
130
+ if (!url) return null;
131
+ return { url: url.replace(/\/+$/, ''), secret: env.MAUDE_RENDER_SECRET ?? '' };
132
+ }
133
+
134
+ /** The message a lane-`none` refusal carries — surfaced verbatim in the job UI. */
135
+ export const NO_RENDER_SERVICE_MESSAGE =
136
+ 'This format needs the render service, which this workspace doesn’t have configured. ' +
137
+ 'ZIP still works here; for rendered formats use the desktop app, or add the maude-render ' +
138
+ 'service (see the self-hosting docs).';
139
+
140
+ export class NoRenderServiceError extends Error {
141
+ constructor() {
142
+ super(NO_RENDER_SERVICE_MESSAGE);
143
+ this.name = 'NoRenderServiceError';
144
+ }
145
+ }
146
+
147
+ /**
148
+ * POST one fully-resolved export job to the render service and return the
149
+ * artifact as a normal `ExportResult`. Degradation travels back as a JSON
150
+ * header rather than a wrapped body so the artifact bytes stream unmodified.
151
+ */
152
+ export async function renderRemotely(args: {
153
+ format: Format;
154
+ targets: Target[];
155
+ options: ExportOptions;
156
+ canvas: RemoteCanvasAccess;
157
+ service: RenderService;
158
+ signal?: AbortSignal;
159
+ }): Promise<ExportResult> {
160
+ const { format, targets, options, canvas, service, signal } = args;
161
+ // The exporters' per-navigation timeouts (png.ts goto `--timeout`, etc.)
162
+ // default to ~8s — tuned for the DESKTOP path where the canvas origin is
163
+ // loopback and already warm. The render worker instead loads the canvas from
164
+ // the REMOTE canvas origin, and a cold cell builds the tenant's TSX in its
165
+ // sandbox on first access, so `_canvas-shell.html` can take far longer than
166
+ // 8s to fire `load`. Give the remote path a generous floor (unless the
167
+ // caller asked for more) so a cold-cell first render doesn't time out.
168
+ const remoteOptions: ExportOptions = {
169
+ ...options,
170
+ timeoutSec: Math.max(Number((options as { timeoutSec?: number }).timeoutSec) || 0, 60),
171
+ };
172
+ const res = await fetch(`${service.url}/render`, {
173
+ method: 'POST',
174
+ signal,
175
+ headers: {
176
+ 'content-type': 'application/json',
177
+ authorization: `Bearer ${service.secret}`,
178
+ },
179
+ body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
180
+ });
181
+ if (!res.ok) {
182
+ const detail = (await res.text().catch(() => '')) || `${res.status}`;
183
+ throw new Error(`render service refused the job: ${detail}`);
184
+ }
185
+ // Sanitize the filename BEFORE it can reach `Bun.write` in jobs.ts — the
186
+ // service is untrusted (DDR-230), and this header would otherwise choose the
187
+ // write path.
188
+ const filename = safeArtifactName(res.headers.get('x-maude-filename'), format);
189
+ const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
190
+ let degraded: ExportDegradation | undefined;
191
+ const degradedHeader = res.headers.get('x-maude-degraded');
192
+ if (degradedHeader) {
193
+ try {
194
+ degraded = JSON.parse(degradedHeader) as ExportDegradation;
195
+ } catch {
196
+ /* a malformed degradation note must not fail a real artifact */
197
+ }
198
+ }
199
+ const body = await readCapped(res, artifactCap());
200
+ return { filename, contentType, body, ...(degraded ? { degraded } : {}) };
201
+ }
@@ -76,11 +76,16 @@ async function decodeToBitmap(
76
76
  b64: string,
77
77
  mime: 'image/png' | 'image/jpeg' = 'image/png'
78
78
  ): Promise<ImageBitmap> {
79
- return createImageBitmap(new Blob([b64ToBytes(b64)], { type: mime }));
79
+ // Uint8Array<ArrayBufferLike> vs BlobPart — see saveAsset in api.ts.
80
+ return createImageBitmap(new Blob([b64ToBytes(b64) as Uint8Array<ArrayBuffer>], { type: mime }));
80
81
  }
81
82
 
82
83
  let vstate: {
83
84
  output: Output;
85
+ // Held alongside `output` because mediabunny's `Output` types `target` as the
86
+ // Target union — the concrete BufferTarget (the only one this encoder ever
87
+ // constructs) is what carries `.buffer`.
88
+ target: BufferTarget;
84
89
  source: CanvasSource;
85
90
  canvas: OffscreenCanvas;
86
91
  ctx: OffscreenCanvasRenderingContext2D;
@@ -123,7 +128,8 @@ const api: MaudeEnc = {
123
128
  }
124
129
  if (!codec) throw new Error('no encodable video codec available in this browser');
125
130
 
126
- const output = new Output({ format: outFormat, target: new BufferTarget() });
131
+ const target = new BufferTarget();
132
+ const output = new Output({ format: outFormat, target });
127
133
  // `latencyMode: 'quality'` is pinned, not left to the default. Per WebCodecs
128
134
  // §7.11 `realtime` explicitly permits the encoder to DROP frames to hit a
129
135
  // bitrate/framerate target, while `quality` MUST NOT — and mediabunny flips
@@ -139,7 +145,7 @@ const api: MaudeEnc = {
139
145
  output.addVideoTrack(source, { frameRate: fps });
140
146
  await output.start();
141
147
  const container = outFormat instanceof Mp4OutputFormat ? 'mp4' : 'webm';
142
- vstate = { output, source, canvas, ctx, fps, frame: 0, container, codec };
148
+ vstate = { output, target, source, canvas, ctx, fps, frame: 0, container, codec };
143
149
  return { container, codec };
144
150
  },
145
151
 
@@ -170,7 +176,7 @@ const api: MaudeEnc = {
170
176
  async finishVideo() {
171
177
  if (!vstate) throw new Error('startVideo not called');
172
178
  await vstate.output.finalize();
173
- const buf = vstate.output.target.buffer;
179
+ const buf = vstate.target.buffer;
174
180
  if (!buf) throw new Error('encode produced no buffer');
175
181
  const res: EncodeResult = {
176
182
  b64: bytesToB64(buf),
@@ -40,6 +40,7 @@ import {
40
40
  type Stroke,
41
41
  type TextStroke,
42
42
  } from '../annotations-model.ts';
43
+ import type { ArrowLineType } from '../canvas-arrowheads.ts';
43
44
  import {
44
45
  attrValue,
45
46
  type Bounds,
@@ -107,14 +108,15 @@ const CAP_MAP: Readonly<Record<string, 'none' | 'triangle' | 'line'>> = Object.a
107
108
  }
108
109
  );
109
110
 
110
- const LINE_TYPE_MAP: Readonly<Record<string, 'straight' | 'elbow' | 'curve'>> = Object.assign(
111
- Object.create(null),
112
- {
113
- STRAIGHT: 'straight',
114
- ELBOWED: 'elbow',
115
- CURVED: 'curve',
116
- }
117
- );
111
+ // Values are ArrowLineType — 'curved', not 'curve'. The old 'curve' matched no
112
+ // arm of the renderer's `lineType === 'curved'` checks, so every curved FigJam
113
+ // connector imported as a stroke nothing could draw as curved (it fell through
114
+ // to straight). Typed against the real union so a third spelling cannot recur.
115
+ const LINE_TYPE_MAP: Readonly<Record<string, ArrowLineType>> = Object.assign(Object.create(null), {
116
+ STRAIGHT: 'straight',
117
+ ELBOWED: 'elbow',
118
+ CURVED: 'curved',
119
+ });
118
120
 
119
121
  export interface PendingImage {
120
122
  /** The stroke whose `href` must be rewritten once the asset lands. */
@@ -184,7 +186,7 @@ function imageRef(node: FigmaNode): string | null {
184
186
  export function nearestStickyColor(hex: string | null): string {
185
187
  const target = hex ? hexToRgb01(hex) : null;
186
188
  if (!target) return STICKY_PALETTE[0];
187
- let best = STICKY_PALETTE[0];
189
+ let best: (typeof STICKY_PALETTE)[number] = STICKY_PALETTE[0];
188
190
  let bestDist = Number.POSITIVE_INFINITY;
189
191
  for (const candidate of STICKY_PALETTE) {
190
192
  const c = hexToRgb01(candidate);
@@ -0,0 +1,51 @@
1
+ // Ambient types for `gifenc` — the package ships no declarations, so without
2
+ // this the GIF export lane was `any` end to end (and, before the A2 tsconfig
3
+ // completion, checked by nothing at all).
4
+ //
5
+ // Narrowed to the surface exporters/video-encode-lib.ts actually calls; widen
6
+ // it here rather than casting at a call site.
7
+
8
+ declare module 'gifenc' {
9
+ export type Palette = number[][];
10
+
11
+ export interface WriteFrameOptions {
12
+ palette?: Palette;
13
+ /** Frame delay in milliseconds. */
14
+ delay?: number;
15
+ transparent?: boolean;
16
+ transparentIndex?: number;
17
+ repeat?: number;
18
+ dispose?: number;
19
+ }
20
+
21
+ export interface GifEncoder {
22
+ writeFrame(
23
+ index: Uint8Array | number[],
24
+ width: number,
25
+ height: number,
26
+ opts?: WriteFrameOptions
27
+ ): void;
28
+ finish(): void;
29
+ bytes(): Uint8Array;
30
+ bytesView(): Uint8Array;
31
+ reset(): void;
32
+ }
33
+
34
+ export function GIFEncoder(opts?: { auto?: boolean; initialCapacity?: number }): GifEncoder;
35
+
36
+ export function quantize(
37
+ rgba: Uint8Array | Uint8ClampedArray,
38
+ maxColors: number,
39
+ opts?: {
40
+ format?: 'rgb565' | 'rgb444' | 'rgba4444';
41
+ oneBitAlpha?: boolean;
42
+ clearAlpha?: boolean;
43
+ }
44
+ ): Palette;
45
+
46
+ export function applyPalette(
47
+ rgba: Uint8Array | Uint8ClampedArray,
48
+ palette: Palette,
49
+ format?: 'rgb565' | 'rgb444' | 'rgba4444'
50
+ ): Uint8Array;
51
+ }
@@ -0,0 +1,88 @@
1
+ // The shape of a History row — the one answer both committers produce.
2
+ //
3
+ // A commit row is drawn by the same `GitPanel` renderer whether it came from
4
+ // the desktop's own repo (`git/service.ts` → `/_api/git/log`) or from the cloud
5
+ // cell that owns a linked project's history (`apps/hub/src/history.mjs` →
6
+ // `/api/history`). Two spellings of "how a log line is asked for and parsed" is
7
+ // how a cloud row and a local row come to differ in a field the renderer reads
8
+ // — and the drift would surface as a blank author or an unparseable date on ONE
9
+ // of the two surfaces, which is precisely the class of bug nobody notices until
10
+ // the surface they don't use every day is the one that matters.
11
+ //
12
+ // ONE COPY, TWO RUNTIMES (the `apps/hub/Dockerfile` rule). The hub imports this
13
+ // file rather than re-typing it in `.mjs`, exactly as it does for
14
+ // `sync/autocommit.ts`, `sync/canvas-path.ts` and `cloud/mirror.mjs`: re-typing
15
+ // a guarantee is re-typing it without its tests. Hence React-free and
16
+ // dependency-free — no `node:*`, nothing a plain-Node hub or a bun-compiled
17
+ // sidecar has to resolve.
18
+
19
+ /** One commit, as every History surface renders it. */
20
+ export interface GitLogRecord {
21
+ sha: string;
22
+ message: string;
23
+ author: string;
24
+ email: string;
25
+ /** ISO-8601 commit date. */
26
+ date: string;
27
+ }
28
+
29
+ /**
30
+ * Unit-separator field delimiter, record-separator line delimiter — survives
31
+ * any message punctuation (a commit subject may contain newlines, tabs, quotes
32
+ * and every separator a "sensible" format would have picked).
33
+ */
34
+ export const GIT_LOG_FORMAT = '%H%x1f%s%x1f%an%x1f%ae%x1f%aI%x1e';
35
+
36
+ /**
37
+ * `git log` argv for `limit` commits, optionally scoped to one `filepath`.
38
+ *
39
+ * The `--` terminator makes `filepath` strictly positional — git can't read it
40
+ * as an option (no argument injection even if it began with a dash, which the
41
+ * caller's containment validation already rejects upstream).
42
+ *
43
+ * THE CALLER MUST ALSO SET `GIT_LITERAL_PATHSPECS=1` whenever `filepath` is
44
+ * present — see `gitLogEnv`. That hardening is a recorded phase-27.1 security
45
+ * re-review outcome, and it lives beside the argv precisely so a second call
46
+ * site cannot pick up one half of the pair.
47
+ */
48
+ export function gitLogArgs(limit: number, filepath?: string): string[] {
49
+ const args = ['log', `-n${limit}`, `--pretty=format:${GIT_LOG_FORMAT}`];
50
+ if (filepath) args.push('--', filepath);
51
+ return args;
52
+ }
53
+
54
+ /**
55
+ * The env `gitLogArgs`'s output must run under, or undefined when unscoped.
56
+ *
57
+ * GIT_LITERAL_PATHSPECS — match `filepath` VERBATIM, never as pathspec magic
58
+ * (`:(top)`, `:(exclude)`, globs). Callers already restrict it to the design
59
+ * tree; this makes system-git treat it as a plain path regardless, closing the
60
+ * pathspec-magic surface the `--` terminator alone doesn't.
61
+ */
62
+ export function gitLogEnv(filepath?: string): { GIT_LITERAL_PATHSPECS: '1' } | undefined {
63
+ return filepath ? { GIT_LITERAL_PATHSPECS: '1' } : undefined;
64
+ }
65
+
66
+ /**
67
+ * Parse `git log --pretty=format:GIT_LOG_FORMAT` stdout into records.
68
+ *
69
+ * Total and forgiving in one direction only: a record short of its five fields
70
+ * yields empty strings rather than `undefined`, so a renderer never has to
71
+ * defend against a half-parsed row. Garbage in is an empty list, never a throw.
72
+ */
73
+ export function parseGitLog(stdout: string): GitLogRecord[] {
74
+ return String(stdout ?? '')
75
+ .split('\x1e')
76
+ .map((rec) => rec.replace(/^\n/, '').trim())
77
+ .filter(Boolean)
78
+ .map((rec) => {
79
+ const [sha, message, author, email, date] = rec.split('\x1f');
80
+ return {
81
+ sha: sha ?? '',
82
+ message: message ?? '',
83
+ author: author ?? '',
84
+ email: email ?? '',
85
+ date: date ?? '',
86
+ };
87
+ });
88
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * One place that decides whether a relative path is safe to hand to git.
3
+ *
4
+ * WHY A HELPER AND NOT A REGEX PER CALL SITE (F-13 / B12). `designRel` and the
5
+ * per-file rels built on top of it reach git from four directions — the status
6
+ * prefix (`git/endpoints.ts`, `git/watch.ts`), the porcelain filter
7
+ * (`git/service.ts`), and the autocommit staging batch (`sync/autocommit.ts`).
8
+ * Three of those grew their own partial normalisation (`normPrefix` strips
9
+ * slashes and nothing else), and a rule spelled four ways is a rule with three
10
+ * chances to be spelled wrong.
11
+ *
12
+ * WHAT IS AND IS NOT THE RISK HERE, stated plainly so the next reader does not
13
+ * re-derive it:
14
+ *
15
+ * - **Argv injection is already defused** and this helper is not what defuses
16
+ * it. Every `runGit` / `run` call that carries a dynamic path puts it after
17
+ * a `--` separator, so a rel beginning with `-` is a filename to git, not a
18
+ * flag. The leading-`-` check below is belt-and-braces for a FUTURE call
19
+ * site that forgets the separator — a cheap guard, not the load-bearing one.
20
+ * - **Containment is the real risk**, and `--` does nothing about it. A rel
21
+ * carrying `..` is a perfectly well-formed pathspec that simply names a file
22
+ * outside the design root: `../../.github/workflows/release.yml` staged by
23
+ * an autocommit is a repo-wide write primitive reached from a path that is
24
+ * supposed to be scoped to `.design/`. That is what this refuses.
25
+ *
26
+ * The rules mirror `sync/file-membership.ts`'s `relShape` deliberately — the
27
+ * file plane already refuses exactly these shapes on the way in, and a second
28
+ * gate that disagreed with the first would just be a bug with two spellings.
29
+ */
30
+
31
+ /** Matches `file-membership.ts` MAX_REL_LEN — the same cap, on purpose. */
32
+ export const MAX_GIT_REL_LEN = 512;
33
+
34
+ /**
35
+ * Is `rel` safe to use as a git pathspec or as a containment prefix?
36
+ *
37
+ * Total: any input that is not a plainly safe repo-relative path is `false`.
38
+ * Never throws, never normalises — callers that want a normalised value use
39
+ * `safeGitPrefix`.
40
+ */
41
+ export function isSafeGitRel(rel: unknown): rel is string {
42
+ if (typeof rel !== 'string' || rel.length === 0 || rel.length > MAX_GIT_REL_LEN) return false;
43
+ // NUL and other control characters: a NUL truncates the path at the syscall
44
+ // boundary, so `assets/a\0../../x` is two different paths depending on who
45
+ // is looking at it.
46
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
47
+ if (/[\u0000-\u001f\u007f]/.test(rel)) return false;
48
+ // Absolute, Windows-absolute, or backslash-separated — none of these are a
49
+ // repo-relative path, and each reads differently to git than to `path.join`.
50
+ if (rel.startsWith('/') || rel.includes('\\') || /^[A-Za-z]:/.test(rel)) return false;
51
+ // A leading `-` at the START of the whole rel. See the docblock: this is the
52
+ // guard for a future call site that forgets `--`.
53
+ if (rel.startsWith('-')) return false;
54
+ for (const seg of rel.split('/')) {
55
+ if (!seg || seg === '.' || seg === '..') return false;
56
+ // A trailing space is invisible in every UI that would show this path and
57
+ // makes two different files look like one.
58
+ if (/ $/.test(seg)) return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * Normalise a design-root prefix, or `null` when it is not safe to use.
65
+ *
66
+ * `''` is a legitimate ANSWER, not a refusal: an absent prefix means "no
67
+ * containment filter", which is what `gitStatus` has always done for a caller
68
+ * that passes none. `null` means the caller supplied something and it was
69
+ * refused — a caller must not silently treat that as "no filter", because that
70
+ * would widen the scope rather than narrow it.
71
+ */
72
+ export function safeGitPrefix(raw: unknown): string | null {
73
+ if (raw === undefined || raw === null || raw === '') return '';
74
+ if (typeof raw !== 'string') return null;
75
+ const normalised = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
76
+ if (normalised === '') return '';
77
+ return isSafeGitRel(normalised) ? normalised : null;
78
+ }
79
+
80
+ /**
81
+ * Split a batch of rels into the ones git may be handed and the ones it may not.
82
+ *
83
+ * Batch-shaped rather than throw-shaped because the autocommit caller must keep
84
+ * making progress on the rest of a batch: one poisoned path is not a reason to
85
+ * stop committing a person's actual work, and a throw there would wedge the
86
+ * whole queue (the failure mode `partitionForStaging` was written to avoid).
87
+ */
88
+ export function partitionSafeGitRels(rels: readonly string[]): {
89
+ safe: string[];
90
+ refused: string[];
91
+ } {
92
+ const safe: string[] = [];
93
+ const refused: string[] = [];
94
+ for (const rel of rels) (isSafeGitRel(rel) ? safe : refused).push(rel);
95
+ return { safe, refused };
96
+ }