// JSON endpoint backers: comments, canvas state, index-data, system-data. // Returns plain objects; http.ts wraps them in Response.json(). import crypto from 'node:crypto'; import { type Dirent, renameSync } from 'node:fs'; import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat as statp, } from 'node:fs/promises'; import path from 'node:path'; import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts'; import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts'; import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts'; import { rewriteRelativeImports } from './canvas-imports.ts'; import { canvasSlugFromRel } from './canvas-slug.ts'; import { atomicWrite } from './sync/atomic-write.ts'; import { dedupeCommentsById } from './sync/comment-identity.ts'; import { isRuntimeStateRel } from './sync/file-membership.ts'; // Re-exported so existing external callers (canvas-list-watch.ts, tests) keep // importing it from api.ts — the actual implementation now lives in // canvas-slug.ts (a leaf module) so canvas-artifacts.ts can depend on it // without a cycle back through api.ts. export { canvasSlugFromRel } from './canvas-slug.ts'; /** Plan T17/L03 — the supporting files the file tree can move, rename and * delete: what it previews (notes, styles, data, images, media, fonts), minus * the canvas's own sidecars, which only ever travel with their canvas. */ export function isSupportingFileRel(rel: string): boolean { if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false; return /\.(md|css|json|txt|ya?ml|svg|png|jpe?g|gif|webp|avif|mp4|webm|mov|mp3|wav|ogg|m4a|woff2?|ttf|otf)$/i.test( rel ); } import { type AssembleClip, type AttributeState, assembleCompSource, CanvasEditError, type ClipInfo, type ConvertChildBox, type ConvertContainerSpec, componentMapForCanvas, convertToAbsolute, deleteArtboard, deleteElement, detachComponent, duplicateArtboard, duplicateElement, type EditScope, editArrayElementString, editAttribute, enumerateClips, type InsertKind, insertArtboard, insertClip, insertElement, insertElementIntoArtboard, type MovePosition, moveElement, removeAttribute, reorderClip, resizeArtboard, resolveEditScope, retimeSequence, retimeSequenceByClip, editText as runEditText, setArtboardGuides, setArtboardHug, setArtboardKind, setArtboardLabel, setArtboardPrint, setArtboardStyle, toggleClipHidden, } from './canvas-edit.ts'; import { applyClipAudio, applyClipFraming, applyClipGrade, applyDetachAudio, applyEditTransition, applyFitTotalToContent, applyInsertTransition, applyMoveClipToOverlay, applyMoveClipToStoryline, applyOnDisk, applyRemoveTransition, applyReorderOverlayLayer, applyResolvePlaceholder, applySetClipText, applySetPlaybackRate, applySplitClip, applyTrimIn, type GradeParams, insertClipAt, removeClipRippled, seriesMove, } from './clip-ops.ts'; import type { Context } from './context.ts'; import { type AudioMatch, type Candidate, rankMatches, sanitizeReuseText, } from './generation/audio-library.ts'; import { createHistory } from './history.ts'; import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts'; import { STICKERS_DIR } from './paths.ts'; import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts'; import { sessionDir } from './session-scope.ts'; import { describeSourceOp } from './sync/source-ops.ts'; import { isWorkspaceMode } from './workspace-mode.ts'; // Directories that never hold user-facing canvases. Exported so the // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of // a hand-synced copy. (activity.ts still carries its own historical mirror.) export const SKIP_DIRS = new Set([ 'node_modules', '.git', '.next', '.turbo', 'dist', 'build', '.expo', 'coverage', 'dev-server', '_history', ]); const HIDDEN_OK = new Set(['.ai', '.claude', '.design']); // feature-studio-file-preview — binary/media extensions the tree lists so a // DS's assets/{fonts,graphics,logos,photos,...} files show up (previously // only their parent folders did, via findFiles's dirsOut accounting). Kept as // an explicit enumerated list rather than a broad pattern so it can never // accidentally widen to swallow runtime JSON — findFiles already excludes // `_`-prefixed entries before this list is even consulted. export const PREVIEW_ASSET_EXTS = [ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.woff', '.woff2', '.ttf', '.otf', '.mp4', '.webm', '.mov', '.mp3', '.wav', '.ogg', '.m4a', ]; // ---------- File tree ---------- /** * Find canvas files under a non-DS group root. Phase 3.6+ accepts both `.tsx` * (current authoring format) and `.html` (legacy, pre-codemod) so the tree * keeps rendering during the migration grace window. DS preview specimens * (`system//preview/*.html`) intentionally stay `.html` and travel via * the DS-aware `findFiles()` path below. */ /** * True when `abs` is a directory tree holding nothing but folder placeholders * (`.gitkeep`, `.DS_Store`) — what a folder projection leaves before any * content arrives. False when it is absent, is not a directory, or holds * anything real (a symlink counts as real: never followed, never removed). */ async function onlyFolderPlaceholders(abs: string, depth = 0): Promise { if (depth > 16) return false; let entries: Dirent[]; try { entries = await readdir(abs, { withFileTypes: true }); } catch { return false; } for (const e of entries) { if (e.isDirectory()) { if (!(await onlyFolderPlaceholders(path.join(abs, e.name), depth + 1))) return false; } else if (!(e.isFile() && (e.name === '.gitkeep' || e.name === '.DS_Store'))) { return false; } } return true; } export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): Promise { const out: string[] = []; let entries: Dirent[]; try { entries = await readdir(absRoot, { withFileTypes: true }); } catch { return out; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const e of entries) { if (e.name.startsWith('.') && !HIDDEN_OK.has(e.name) && !e.name.startsWith('_')) continue; if (e.name.startsWith('_')) continue; if (SKIP_DIRS.has(e.name)) continue; const full = path.join(absRoot, e.name); const rel = path.posix.join(prefixUnderRepo, e.name); if (e.isDirectory()) out.push(...(await findHtmlFiles(full, rel))); else { const low = e.name.toLowerCase(); if (low.endsWith('.tsx') || low.endsWith('.html')) out.push(rel); } } return out; } /** * `dirsOut`, when passed, accumulates every directory visited (group-relative * POSIX paths, same shape as the returned file paths) — INCLUDING empty ones, * since a directory is recorded before recursing into it, not after finding a * match inside. feature-file-tree-drag-drop-folders (Task 6) reuses this one * traversal instead of adding a second full walk of `system/` (the largest * group) just to enumerate directories. */ export async function findFiles( absRoot: string, prefix: string, exts: string[], dirsOut?: string[] ): Promise { const out: string[] = []; let entries: Dirent[]; try { entries = await readdir(absRoot, { withFileTypes: true }); } catch { return out; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const e of entries) { if (e.name.startsWith('.') && !HIDDEN_OK.has(e.name)) continue; if (e.name.startsWith('_')) continue; if (SKIP_DIRS.has(e.name)) continue; const full = path.join(absRoot, e.name); const rel = path.posix.join(prefix, e.name); if (e.isDirectory()) { dirsOut?.push(rel); out.push(...(await findFiles(full, rel, exts, dirsOut))); } else if (e.isFile() && exts.some((x) => e.name.toLowerCase().endsWith(x))) { // feature-studio-file-preview security review — `e.isFile()` (not just // "not a directory") excludes symlinks: a symlink Dirent is neither // isDirectory() nor isFile(), so without this check a symlink dropped // into an assets/ folder (e.g. pointing at ~/.ssh/id_rsa, named to fit // an allowlisted extension) would be listed and, since this feature // makes every listed row one-click-fetchable, served straight into the // preview panel. // // A HARDLINK survives `isFile()` (hardlinks are, by design, ordinary // files — same inode, indistinguishable from the "original" at the // Dirent level), so it needs a second check: `nlink > 1` means this // directory entry shares its inode with at least one other name // somewhere on the filesystem. A design system's own assets are never // legitimately multiply-linked, so excluding them closes the same // one-click-disclosure path for a hardlink planted at, say, a // teammate's readable dotfile. try { const st = await lstat(full); if (st.nlink > 1) continue; } catch { continue; } out.push(rel); } } return out; } // ---------- Comments ---------- /** * Phase 6 — single reply on a comment thread. `id` is `r_`; persists inside * the parent `Comment.thread[]`. Bodies are bounded the same way as comment * bodies (4000 chars), and `@handle` tokens in `body` flow into the parent's * `mentions[]` union. */ export interface Reply { id: string; author: string; body: string; created: string; } export interface Comment { id: string; file: string; selector: string; /** Occurrence index among `querySelectorAll(selector)` — disambiguates a * component repeated within one artboard. Absent on legacy comments. */ index?: number; dom_path: string[]; tag: string; classes: string; bounds: { x: number; y: number; w: number; h: number } | null; html_excerpt: string; text: string; status: 'open' | 'resolved'; created: string; resolved_at: string | null; // Phase 6 — author + threading + mentions. Default-filled on read for legacy // comments missing these fields (see `loadCommentsForFile`); persisted on next // write. `author` defaults to the local `git config user.name` resolved at // create time, `thread` to `[]`, `mentions` to `[]`. author: string; thread: Reply[]; mentions: string[]; /** enhanced-video-editing (Task 23) — a TIMELINE anchor: preferred * `{ clipStableId, frameOffset }` (survives reorder/ripple), fallback * `{ frame }` (track-level). Absent on ordinary element comments. Comment * text is untrusted user/peer text (DDR-054) — rendered as text, never * into TSX. */ timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string }; } export interface GitCommitter { name: string; email: string; commits: number; } export type CreateCanvasResult = | { ok: true; file: string; rel: string; slug: string } | { ok: false; status: number; error: string }; export type DeleteCanvasResult = | { ok: true; rel: string; slug: string; trashed: string[]; trashDir: string } | { ok: false; status: number; error: string }; // feature-file-tree-drag-drop-folders (Task 3/4). export type MoveCanvasResult = | { ok: true; fromRel: string; toRel: string; fromSlug: string; toSlug: string; moved: string[] } | { ok: false; status: number; error: string }; export type CreateFolderResult = | { ok: true; dir: string } | { ok: false; status: number; error: string }; /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */ export type EditOpResult = /** `previous` — what a css/attr write replaced (`null` = was unset); absent * when it was an expression no literal undo can restore. */ | { ok: true; delta: number; seq?: number; previous?: string | null } /** `conflict` = the target no longer held the caller's expected value. */ | { ok: false; status: number; error: string; conflict?: true }; /** * Optional expected-current value for a css/attr write (audit 2026-09-13 P1 * #5). An absent `expected` key keeps the unconditional legacy behaviour; a * present one must be a bounded string or `null` ("currently unset"). */ function expectedValueOf(input: { expected?: unknown; }): { ok: true; expected?: string | null } | { ok: false } { if (!Object.hasOwn(input, 'expected') || input.expected === undefined) return { ok: true }; if (input.expected === null) return { ok: true, expected: null }; if (typeof input.expected === 'string' && input.expected.length <= 256) { return { ok: true, expected: input.expected }; } return { ok: false }; } /** * Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle * hints the client uses to re-select the moved element through the positional * `data-cd-id` churn: `movedId` (recomputed positional id == the post-reload DOM * id, best-effort) and `semanticId` (the moved element's `data-dc-element`, which * survives the move verbatim — the reliable key when present). */ export type ReorderOpResult = | { ok: true; delta: number; movedId: string | null; semanticId: string | null; seq: number } | { ok: false; status: number; error: string }; export type ReorderRevertResult = | { ok: true; dir: 'undo' | 'redo' } | { ok: false; status: number; error: string }; export interface Api { // File tree fileSlug(file: string): string; loadCommentsForFile(file: string): Promise; saveCommentsForFile(file: string, list: Comment[]): Promise; loadAllComments(): Promise>; /** * Resolve a canvas URL slug back to its repo-relative `file` path by scanning * the ACTUAL canvas files under each canvas group — independent of whether the * canvas has any comments yet. The inverse of `fileSlug`. Returns null when no * canvas matches. Load-bearing for collab: a peer that has not yet received any * comment for a canvas must still resolve the file to MATERIALIZE the first * hub-pushed comment to disk (the receiving-peer projection gap, DDR-064). */ fileForSlug(slug: string): Promise; commentsAdd(payload: Partial & { file: string; text: string }): Promise; commentsPatch(id: string, patch: Partial): Promise; commentsDelete(id: string): Promise; commentsAddReply(id: string, payload: { body: string; author?: string }): Promise; gitCommitters(): Promise; /** * Phase 8 — local `git config user.name`, cached for the process lifetime. * Used by the collab client to derive a stable color hash per peer. * Empty string when git is unset; the client falls back to `anonymous-`. */ gitCurrentUser(): Promise; parseMentions(text: string): string[]; // Canvas state loadCanvasState(file: string): Promise | null>; saveCanvasState(file: string, state: Record): Promise; timelineMediaLoad(key: string): Promise | null>; timelineMediaSave(key: string, data: Record): Promise; // Canvas meta sidecar (Phase 4 T5 — .design/ui/.meta.json) loadCanvasMeta(file: string): Promise | null>; /** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */ loadCanvasSource( file: unknown ): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }>; patchCanvasMeta( file: string, patch: Record ): Promise | null>; // Annotations sidecar (Phase 5 — .design/.annotations.svg) loadAnnotations(file: string): Promise; saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise; /** Materialize a document snapshot without publishing it as another user edit. */ projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise; // Phase 23 — content-addressed binary image write (drag-drop / paste / picker) saveAsset(bytes: Uint8Array): Promise; /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */ listAssets(): Promise<{ ok: true; assets: AssetListing[] }>; /** feature-ai-media-generation (Task 1.2) — read a content-addressed * `assets/.` source's bytes + sniffed mime for the image-edit / * image-to-video generation flows. Contained to /assets/; * null for an unknown/contained-out path. */ readAssetBytes(rel: unknown): Promise<{ bytes: Uint8Array; mime: string } | null>; /** feature-ai-media-generation (Task 2.6) — write a caption sidecar * (`assets/.srt|.vtt`) next to a content-addressed source, so a cloud * STT result lands where the local whisper path also writes it. Contained to * /assets/; text byte-capped; format allowlisted. */ writeCaptionSidecar( sourceRel: unknown, format: unknown, text: unknown ): Promise<{ ok: boolean; path?: string; error?: string }>; /** feature-ai-media-generation (Task 2.5) — write the audio-intent sidecar * (`assets/.audio.json`) for reuse-before-you-pay search. */ writeAudioIntent( assetRel: unknown, meta: { kind?: string; prompt?: string; provider?: string; model?: string; at?: string } ): Promise<{ ok: boolean; path?: string; error?: string }>; /** feature-ai-media-generation (Task 2.5) — keyword-search the project's own * generated audio by recorded intent; ranked reuse candidates. */ searchAudioLibrary(query: unknown, limit?: number): Promise; /** Phase 4 (feature-whiteboard-annotation-improvements) — the bundled sticker * catalogue (MAUDE's own, not the served project's) for the StickerPicker. */ listStickers(): Promise<{ ok: true; packs: StickerPack[] }>; /** DDR-148 — streaming variant for the HTTP route (100 MB video without a * full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */ saveAssetFromStream(stream: ReadableStream): Promise; // Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`, // returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment). saveChatAttachment(bytes: Uint8Array): Promise; // Resolve a content-addressed attachment name (`.`) to its absolute // path, or null (GET /_api/acp/attachment — the read side of the pair above). resolveChatAttachment(name: unknown): Promise; // Create a blank brief board OR an assembled video-comp from the browser // (Phase 22 — POST /_api/canvas; DDR-150 P4 Task 12 adds kind "video-comp"). createCanvas(input: { name?: unknown; kind?: unknown; group?: unknown; clips?: unknown; fps?: unknown; width?: unknown; height?: unknown; }): Promise; // Duplicate a canvas beside itself (" copy") — POST /_api/canvas // { duplicateOf }. Source, meta and the whiteboard layer; not comments or // history, which belong to the original. duplicateCanvas(input: { file?: unknown }): Promise; // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas) deleteCanvas(input: { file?: unknown }): Promise; // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its // full artifact set (POST /_api/fs-move). moveCanvas(input: { file?: unknown; toDir?: unknown }): Promise; // feature-file-tree-drag-drop-folders (Task 4) — create an empty folder // inside a canvas group, with a `.gitkeep` (POST /_api/fs-mkdir). createFolder(input: { parent?: unknown; name?: unknown }): Promise; // Phase 12 (DDR-103) — single-property inline CSS edit (POST /_api/edit-css). // Main-origin only: writes one key into the element's inline `style={{}}` object. editCss(input: { canvas?: unknown; id?: unknown; property?: unknown; value?: unknown; reset?: unknown; idIndex?: unknown; /** Expected current value (string) or `null` = currently unset. Absent = unconditional. */ expected?: unknown; }): Promise; // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only. editText(input: { canvas?: unknown; id?: unknown; text?: unknown; occurrence?: unknown; before?: unknown; }): Promise; // Phase 12.2 (DDR-104) — custom HTML attribute edit (POST /_api/edit-attr). Main-origin // only. The CSS panel's "custom HTML attribute" escape hatch (data-*, aria-*, role, …); // writes a plain JSX attribute via editAttribute's non-`style.` path. editAttr(input: { canvas?: unknown; id?: unknown; attr?: unknown; value?: unknown; reset?: unknown; expected?: unknown; }): Promise; // Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin // only. Moves the element with data-cd-id `id` to `position` relative to // `refId` (reparent-capable), snapshotting pre-move for /design:rollback. reorder(input: { canvas?: unknown; id?: unknown; refId?: unknown; position?: unknown; }): Promise; /** DDR-148 — Timeline drag-to-retime a sequence's durationInFrames / from. */ retimeSequenceOp(input: { canvas?: unknown; // DDR-150 P2 — prefer stableId (comp-scoped, multi-comp-safe) over index. stableId?: unknown; artboardId?: unknown; contentHash?: unknown; index?: unknown; durationInFrames?: unknown; from?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; // feature-enhanced-video-editing (Phase 2) — parametric clip verbs (speed · // trim-in · audio · detach-audio · framing · grade · transition). clipEditOp(input: { canvas?: unknown; artboardId?: unknown; stableId?: unknown; contentHash?: unknown; verb?: unknown; rate?: unknown; deltaFrames?: unknown; muted?: unknown; volume?: unknown; framing?: unknown; grade?: unknown; presentation?: unknown; durationInFrames?: unknown; atFrame?: unknown; src?: unknown; mediaKind?: unknown; text?: unknown; toIndex?: unknown; }): Promise< | { ok: true; seq?: number; extra?: Record } | { ok: false; status: number; error: string } >; // DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic // gate; refuses the only clip; drops an adjacent transition in a series). removeSequenceOp(input: { canvas?: unknown; stableId?: unknown; artboardId?: unknown; contentHash?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; // DDR-150 P4 — insert a new (optionally with media) after a comp's // last clip. Returns the new clip's stableId. insertSequenceOp(input: { canvas?: unknown; artboardId?: unknown; from?: unknown; durationInFrames?: unknown; mediaTag?: unknown; src?: unknown; }): Promise< | { ok: true; stableId: string | null; seq?: number } | { ok: false; status: number; error: string } >; // DDR-150 P5 — z-order reorder: move a standalone before/after a // sibling (render stacking), reusing moveElement + the semantic gate. Both // clips are fingerprint-checked. Returns the moved clip's (re-settled) stableId. reorderSequenceOp(input: { canvas?: unknown; artboardId?: unknown; stableId?: unknown; contentHash?: unknown; refStableId?: unknown; refContentHash?: unknown; position?: unknown; }): Promise< | { ok: true; stableId: string | null; seq?: number } | { ok: false; status: number; error: string } >; // DDR-150 dogfood — replace a media src that lives in an array literal // (the showreel `CLIPS[i].src` pattern), addressed by mediaArrayRef. editArraySrcOp(input: { canvas?: unknown; arrayName?: unknown; index?: unknown; field?: unknown; value?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; // DDR-150 dogfood — hide/show a clip (gates its body behind {false && …}). toggleHideOp(input: { canvas?: unknown; stableId?: unknown; artboardId?: unknown; contentHash?: unknown; }): Promise< { ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string } >; // DDR-150 P2 — the single authoritative clip enumerator for a video-comp. // Read-only; the Timeline addresses every op by the returned `stableId` // (never a regex document-order index — the multi-comp mis-hit defect). compClips(input: { canvas?: unknown; artboardId?: unknown }): Promise< | { ok: true; compName: string | null; artboardId: string | null; fps: number | null; durationInFrames: number | null; clips: Array>; } | { ok: false; status: number; error: string } >; // Stage I (feature-element-editing-robustness) — general element structural // edits. Each logs a whole-file undo seq (reverted via reorderRevert). /** Delete an element by data-cd-id (reused-component instance via idIndex). */ deleteElementOp(input: { canvas?: unknown; id?: unknown; idIndex?: unknown; }): Promise< { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string } >; /** * feature-4 T8 (convert-to-absolute, DDR-188) — rewrite a container's stamped * children to `position:absolute` with frozen boxes (+ container relative), in * ONE whole-file write with ONE undo `seq`. */ convertChildrenToAbsoluteOp(input: { canvas?: unknown; containerId?: unknown; containerIdIndex?: unknown; containerSetRelative?: unknown; allowShared?: unknown; children?: unknown; containers?: unknown; dissolve?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** * Insert a synthesized div/text/image relative to a reference element, OR — * when the artboard has no element to anchor on yet — as a direct child of * `artboardId` (the tool-palette "+ Element" empty-artboard fallback). * Exactly one of `refId` / `artboardId` must be provided. */ insertElementOp(input: { canvas?: unknown; refId?: unknown; artboardId?: unknown; position?: unknown; kind?: unknown; src?: unknown; refIndex?: unknown; }): Promise< { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string } >; /** Insert a new empty artboard from a screen-size preset. */ insertArtboardOp(input: { canvas?: unknown; id?: unknown; label?: unknown; width?: unknown; height?: unknown; }): Promise< { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string } >; /** Duplicate an artboard at a new width (feature-3-web-artboards T3). */ duplicateArtboardOp(input: { canvas?: unknown; artboardId?: unknown; width?: unknown; }): Promise< { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string } >; /** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */ resizeArtboardOp(input: { canvas?: unknown; artboardId?: unknown; width?: unknown; height?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Toggle an artboard's Hug/Fixed height sizing mode (CSS-panel control). */ setArtboardHugOp(input: { canvas?: unknown; artboardId?: unknown; fixed?: unknown; freezeHeight?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Set artboard "more settings" — background / padding / layout / gap. */ setArtboardStyleOp(input: { canvas?: unknown; artboardId?: unknown; background?: unknown; padding?: unknown; layout?: unknown; gap?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Kind-switch surfaces (T8) — context menu + Inspector picker. */ setArtboardKindOp(input: { canvas?: unknown; artboardId?: unknown; kind?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Rename an artboard (T25/L08 — double-click its name on the canvas). */ setArtboardLabelOp(input: { canvas?: unknown; artboardId?: unknown; label?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Generic layout guides (T5) — replace-whole-prop write. */ setArtboardGuidesOp(input: { canvas?: unknown; artboardId?: unknown; guides?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** feature-2-print-artboards T2 — paper/orientation/bleed/margins, replace-whole-prop write. */ setArtboardPrintOp(input: { canvas?: unknown; artboardId?: unknown; print?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */ duplicateElementOp(input: { canvas?: unknown; id?: unknown; idIndex?: unknown; }): Promise< { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string } >; /** Delete an artboard by its `id` prop (whole-file undo seq). */ deleteArtboardOp(input: { canvas?: unknown; artboardId?: unknown; }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>; /** Edit-scope verdict (local vs shared component instance) for the INV-3 badge. */ editScopeOp(input: { canvas?: unknown; id?: unknown; rendered?: unknown; }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>; /** feature-4 T7a — Layers-panel component map (purple instance rows). */ componentMapOp(input: { canvas?: unknown; }): Promise< | { ok: true; map: Record } | { ok: false; status: number; error: string } >; /** feature-4 detach-component — clone the definition + repoint ONE usage. */ detachComponentOp(input: { canvas?: unknown; id?: unknown; idIndex?: unknown; }): Promise< { ok: true; detachedName: string; seq?: number } | { ok: false; status: number; error: string } >; // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole- // file content swap from the in-memory revert log — immune to the positional // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale). // Refuses (409) when the canvas changed since the reorder (external edit). reorderRevert(input: { canvas?: unknown; seq?: unknown; dir?: unknown; }): Promise; // Aggregate data buildIndexData(): Promise; buildSystemData(dsName?: string | null): Promise; } export interface ApiHooks { /** * Publish a comments mutation to the SHARED DOC (and the shell's sidebar). * * `comments` is the post-mutation list, handed over IN MEMORY — issue #111. * The hook used to take only `file` and re-read `_comments/.json` * itself, which put a full disk round-trip between the mutation's write and * the doc publish. `Room.flush()` fires on ANY doc update (every hub update * re-arms it), so on a hub-linked project a flush landed inside that window, * projected the PRE-mutation doc back over the file, and the new comment was * gone before the hook's own read reached it — "only the first comment * stays". Passing the list removes the read, and `publishComments` awaits * this hook BEFORE touching disk so the doc is never the stale side. */ /** `base` — the list this mutation started from (a merge hint for the project). */ onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise; /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */ onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void; /** * Accepted-revisions mode (DDR-241): propose a folder operation as ONE * project action before touching disk. Absent, or answering `null`, means * the project is not in that mode and the local operation is the whole story. */ /** * A layout (shared meta) write, with the file text it replaced — accepted * revisions propose it with that base instead of inferring one. */ onMetaChanged?: (file: string, text: string, baseText: string | null) => void; proposeFolder?: ( op: | { op: 'dir.create'; path: string } | { op: 'dir.delete'; path: string } | { op: 'dir.move'; from: string; to: string } ) => Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null; /** * feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned * (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the * move rather than rename a file out from under a live hub session. */ isRoomPinned?: (slug: string) => boolean; /** * The MOVE protocol (codec `stampMovedTo`): stamp the slug's shared document * retired-to-`toRel`, push the stamp, and detach the sync provider — so the * move can proceed instead of being refused. On a cell EVERY canvas is * pinned (the studio child's own runtime holds the doc), which made the * pinned refusal a universal "cannot move anything in the cloud". * Returns false when no runtime carries the slug — the caller keeps the * refusal for that case (an unretired pinned room is still unsafe to move). */ retireCanvasForMove?: (fromSlug: string, toRel: string) => Promise; /** Flush + force-tear-down a canvas's collab room ahead of a move (best * effort — a room may not be live for the slug at all). */ flushAndDropRoom?: (slug: string) => Promise; /** Retarget `_active.json` (active/open_tabs/selected) after a move. */ retargetActive?: (fromFile: string, toFile: string) => void; } // FigJam v3 — the annotation sanitizer moved to annotations-model.ts (the // schema owner; the allowlist guards exactly that vocabulary, and the // headless `maude design annotate` write verb needs it without pulling the // server modules). Re-exported here so every existing `from './api.ts'` // import keeps working unchanged. export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts'; import { sanitizeAnnotationSvg } from './annotations-model.ts'; /** * Phase 23 — per-file ceiling for a still image. Raised 10 MB → 50 MB (still * well under the video cap / MAX_REQUEST_BODY headroom) after a real drone * photo tripped the old ceiling. Overridable via `MAUDE_ASSET_MAX_IMAGE_BYTES` * (bytes), mirroring {@link ASSET_MAX_VIDEO_BYTES}'s override. The route lives * on the (untrusted) canvas origin (DDR-088), and images stream through the * same category-capped writer as video/audio (`saveAssetFromStream`), so * raising this doesn't reintroduce the memory-amplification risk DDR-088 capped. */ export const ASSET_MAX_BYTES = (() => { const env = Number(process.env.MAUDE_ASSET_MAX_IMAGE_BYTES); return Number.isFinite(env) && env > 0 ? env : 50 * 1024 * 1024; })(); /** * Phase 23 security review (DDR-088 follow-up) — aggregate per-server-instance * write budget for `/_api/asset`. Content-addressing dedupes IDENTICAL bytes, * but a one-byte mutation (a PNG `tEXt` chunk / a single pixel) yields a fresh * sha8 each time, so dedup is NOT a disk-fill defense. This caps total bytes a * single dev-server instance will ever write to `assets/` — generous for real * reference material, but bounds a scripted loop from the untrusted canvas * origin. Overridable via `MAUDE_ASSET_SESSION_BUDGET` (bytes) for power users. */ export const ASSET_SESSION_BUDGET = (() => { const env = Number(process.env.MAUDE_ASSET_SESSION_BUDGET); // DDR-148 — raised 256 MB → 1 GB now that the route accepts video/audio (one // 100 MB clip would blow a 256 MB budget after a couple of drops). Still an // aggregate per-server-instance disk-fill bound; env-overridable. return Number.isFinite(env) && env > 0 ? env : 1024 * 1024 * 1024; })(); /** * Phase 23 — content-type sniff from the first bytes (magic numbers). The * declared name / extension / Content-Type is NEVER trusted — the bytes decide * the stored extension (a `.png` name carrying GIF bytes is stored as `.gif`). * SVG (XML/text) matches nothing here → returns null → rejected, so a * script-bearing vector can't ride in through the image route. See DDR (Task 9). */ export function sniffImageType(bytes: Uint8Array): 'png' | 'jpg' | 'gif' | 'webp' | null { const b = bytes; // PNG — 89 50 4E 47 0D 0A 1A 0A if ( b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a ) { return 'png'; } // JPEG — FF D8 FF if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg'; // GIF — "GIF87a" / "GIF89a" if ( b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61 ) { return 'gif'; } // WEBP — "RIFF"????"WEBP" if ( b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50 ) { return 'webp'; } return null; } export interface SaveAssetResult { ok: boolean; status?: number; error?: string; /** Relative `assets/.` path on success. */ path?: string; } /** Stage F1 — one media asset in the AssetPicker listing. */ export interface AssetListing { /** designRoot-relative path (`assets/.`) — what a src/href uses. */ path: string; name: string; ext: string; kind: 'image' | 'video' | 'audio'; size: number; mtimeMs: number; } /** Phase 4 (feature-whiteboard-annotation-improvements) — one bundled sticker. */ export interface StickerItem { file: string; keywords: string[]; /** Servable path — `/_stickers//` (main-origin static route). */ url: string; } /** A bundled sticker pack — one `apps/studio/stickers//manifest.json`. */ export interface StickerPack { slug: string; name: string; author: string; attributionUrl: string; license: string; stickers: StickerItem[]; } /** DDR-148 — media category, decides which per-file cap applies. */ export type AssetCategory = 'image' | 'video' | 'audio'; export interface AssetTypeInfo { /** Stored file extension (bytes decide it, never the upload name). */ ext: string; category: AssetCategory; } /** * DDR-148 — per-file ceiling for time-based media (video + audio). Images keep * the tighter {@link ASSET_MAX_BYTES} cap. Overridable via * `MAUDE_ASSET_MAX_VIDEO_BYTES` (bytes) for power users. The route lives on the * (untrusted) canvas origin, so this cap + the session budget + the streamed * write are the trust mitigation, exactly like the image caps (DDR-088). */ export const ASSET_MAX_VIDEO_BYTES = (() => { const env = Number(process.env.MAUDE_ASSET_MAX_VIDEO_BYTES); return Number.isFinite(env) && env > 0 ? env : 100 * 1024 * 1024; })(); /** The byte cap for a category. */ export function assetCapForCategory(category: AssetCategory): number { return category === 'image' ? ASSET_MAX_BYTES : ASSET_MAX_VIDEO_BYTES; } const UNSUPPORTED_ASSET_MSG = 'unsupported media type — png/jpeg/gif/webp images or mp4/mov/webm/mp3/wav/m4a media only (SVG/script rejected)'; function capError(category?: AssetCategory): string { if (category === 'image') { return `image exceeds the ${Math.round(ASSET_MAX_BYTES / (1024 * 1024))} MB cap`; } const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024)); return `media exceeds the ${mb} MB cap`; } /** Concatenate a small list of chunks (used only for the ≤ few-KB sniff head). */ function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { let len = 0; for (const c of chunks) len += c.length; const out = new Uint8Array(len); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; } /** * DDR-148 — magic-byte type sniff for the WIDENED asset route: images (via * {@link sniffImageType}) PLUS time-based media. The declared name / extension * / Content-Type is NEVER trusted — the bytes decide the stored extension AND * the category (→ which cap applies). The server only sniffs; it never PARSES a * container (parsing happens in the sandboxed capture browser). Anything that * isn't a recognised raster/video/audio magic number (SVG, HTML, arbitrary * script) → null → rejected (415). Needs ≥ 12 bytes for the ISO-BMFF brands. */ export function sniffAssetType(bytes: Uint8Array): AssetTypeInfo | null { const img = sniffImageType(bytes); if (img) return { ext: img, category: 'image' }; const b = bytes; // ISO-BMFF (mp4 / mov / m4a): "ftyp" box at offset 4, brand at offset 8. if ( b.length >= 12 && b[4] === 0x66 && // f b[5] === 0x74 && // t b[6] === 0x79 && // y b[7] === 0x70 // p ) { const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0); if (brand === 'qt ') return { ext: 'mov', category: 'video' }; if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' }; if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' }; // isom / mp41 / mp42 / avc1 / iso2 / iso5 / dash / mp4v / … → treat as mp4. return { ext: 'mp4', category: 'video' }; } // Matroska / WebM — EBML header 1A 45 DF A3. if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) { return { ext: 'webm', category: 'video' }; } // MP3 — "ID3" tag OR a frame-sync (0xFF followed by 0b111xxxxx). if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) { return { ext: 'mp3', category: 'audio' }; } if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0) { return { ext: 'mp3', category: 'audio' }; } // WAV — "RIFF"????"WAVE". if ( b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x41 && b[10] === 0x56 && b[11] === 0x45 ) { return { ext: 'wav', category: 'audio' }; } return null; } export function createApi(ctx: Context, hooks: ApiHooks): Api { const onCommentsChanged = hooks.onCommentsChanged; const onAnnotationsChanged = hooks.onAnnotationsChanged; const { paths, cfg } = ctx; // Phase 12.1 — in-memory reorder revert log (Cmd+Z for drag/keyboard moves). // Whole-file {before, after} per reorder, keyed by a monotonic seq the client // stores in its undo record. Ephemeral by design: a server restart drops it // (undo answers 404 and the canvas stack entry is a no-op, honest failure). const REORDER_LOG_CAP = 50; const reorderLog = new Map< number, { abs: string; before: string; after: string; undoActionId?: string } >(); let reorderSeq = 0; // DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops // (retime / remove / insert / z-reorder / replace-src): every successful op // registers its {before, after} and returns the seq; the shell keeps a // per-canvas undo/redo stack of seqs and replays them through // /_api/reorder-revert (guarded whole-file swap, 409 on divergence). function logUndo(abs: string, before: string, after: string): number { const seq = ++reorderSeq; reorderLog.set(seq, { abs, before, after }); while (reorderLog.size > REORDER_LOG_CAP) { const oldest = reorderLog.keys().next().value; if (oldest === undefined) break; reorderLog.delete(oldest); } return seq; } // ── Structural-write throttle + source-size ceiling (G3 security, DDR-152) ── // The new structural verbs (delete / insert-element / insert-artboard / // resize-artboard) are the first that let an UNTRUSTED active canvas both // *remove* and *grow* its own source: the shell relays a canvas's `dgn:*` // request after gating only on `e.source === activeWin` — there is no user // gesture on the wire, so a hostile on-load script can drive these in a loop. // Two bounds close the disk-fill / silent-shred DoS the adversarial review // flagged (mirrors the ASSET_SESSION_BUDGET the /_api/asset lane already has, // DDR-088 — this is the OTHER untrusted-origin disk-write surface): // (a) a per-api token bucket caps the sustained rate. A human does a few // structural edits/sec; a scripted loop can't beat the refill, which // keeps every whole-file _history snapshot + RAM undo entry rate-bound. // (b) a growth op is refused once the source already exceeds // MAX_CANVAS_SOURCE, so inserts can't inflate the .tsx (and the snapshot // + undo copies it holds) without bound. Deletes/shrinks always pass. // Bucket state is per-createApi (each test/instance starts full); env-tunable. const STRUCTURAL_BURST = (() => { const env = Number(process.env.MAUDE_STRUCTURAL_BURST); return Number.isFinite(env) && env > 0 ? Math.floor(env) : 40; })(); const STRUCTURAL_REFILL_PER_SEC = 8; let structuralTokens = STRUCTURAL_BURST; let structuralLastRefill = Date.now(); /** Consume one token; false when the caller is over the sustained rate. */ function takeStructuralToken(): boolean { const now = Date.now(); structuralTokens = Math.min( STRUCTURAL_BURST, structuralTokens + ((now - structuralLastRefill) / 1000) * STRUCTURAL_REFILL_PER_SEC ); structuralLastRefill = now; if (structuralTokens < 1) return false; structuralTokens -= 1; return true; } const RATE_LIMITED = { ok: false as const, status: 429, error: 'too many structural edits — slow down', }; const MAX_CANVAS_SOURCE = (() => { const env = Number(process.env.MAUDE_MAX_CANVAS_SOURCE); return Number.isFinite(env) && env > 0 ? Math.floor(env) : 512 * 1024; })(); function fileSlug(file: string): string { return canvasSlugFromRel(file, paths.designRel); } async function fileForSlug(slug: string): Promise { // Authoritative slug → canvas-file resolver: enumerate the real canvas // files under each canvas group and match by the canonical slug. Unlike a // comments-file scan, this resolves even when the canvas has NO comments yet // — the fix for the receiving-peer projection gap where a fresh peer could // not locate the file to write the first hub-pushed comment (DDR-064). for (const g of cfg.canvasGroups) { const groupAbs = path.join(paths.designRoot, g.path); const groupRel = path.posix.join(paths.designRel, g.path); let files: string[]; try { files = await findHtmlFiles(groupAbs, groupRel); } catch { continue; } for (const rel of files) { if (fileSlug(rel) === slug) return rel; } } return null; } function commentsPath(file: string): string { return path.join(paths.commentsDir, `${fileSlug(file)}.json`); } async function loadCommentsForFile(file: string): Promise { try { const raw = await Bun.file(commentsPath(file)).text(); const arr = JSON.parse(raw); if (!Array.isArray(arr)) return []; // Phase 6 — default-fill `author` / `thread` / `mentions` for legacy // rows. No write-back here; the on-disk shape stays stable until the // next mutation persists the upgraded record. // // Deduped by comment identity first (issue #112). This is the read half // of the repair: a project whose `_comments/.json` was already // doubled by the pre-fix sync lane heals the moment it is loaded, and no // consumer — pins, sidebar, `/_comments`, the /design:edit agent — ever // sees the same comment eight times. First occurrence wins, matching the // rule the codec and the cold-start union use. return dedupeCommentsById(arr).map(backfillComment); } catch { return []; } } // Security (adversarial review 2026-07-30): the timeline anchor is read by // the /design:edit agent (edit.md §0.6b treats `lane` as navigation), and a // comment can arrive from an UNTRUSTED hub peer (DDR-054) via the sync-persist // path — which does NOT go through commentsAdd's validation. So re-clamp the // anchor HERE, at the read boundary every consumer (incl. /_comments served to // the agent) passes through: bound `lane`/`clipStableId` length, coerce frame // ints, and drop unknown fields — a poisoned over-long `lane` can't smuggle an // instruction past the 40-char label the feature is documented to carry. function sanitizeTimelineAnchor(raw: unknown): Comment['timeline'] | undefined { if (!raw || typeof raw !== 'object') return undefined; const t = raw as Record; const anchor: NonNullable = {}; if (typeof t.clipStableId === 'string' && t.clipStableId.length <= 200) anchor.clipStableId = t.clipStableId; if (Number.isFinite(Number(t.frameOffset))) anchor.frameOffset = Math.max(0, Math.round(Number(t.frameOffset))); if (Number.isFinite(Number(t.frame))) anchor.frame = Math.max(0, Math.round(Number(t.frame))); if (typeof t.lane === 'string' && t.lane.length <= 40) anchor.lane = t.lane; return anchor.clipStableId != null || anchor.frame != null ? anchor : undefined; } function backfillComment(raw: unknown): Comment { const c = (raw ?? {}) as Partial; const timeline = sanitizeTimelineAnchor((c as { timeline?: unknown }).timeline); return { ...(c as Comment), author: typeof c.author === 'string' ? c.author : '', thread: Array.isArray(c.thread) ? c.thread : [], mentions: Array.isArray(c.mentions) ? c.mentions : [], ...(timeline ? { timeline } : { timeline: undefined }), }; } async function saveCommentsForFile(file: string, list: Comment[]) { // The write half of the #112 repair, and the reason it belongs HERE: this // is the one choke point every writer passes through — the API mutations, // and the collab room's `persistJson`, which materializes the Y.Array // straight to disk. Deduping at the door means a doc caught mid-convergence // (or a peer still running the old wholesale write) cannot persist a // duplicated list as the canvas's new truth, which is how the doubling // survived restarts and compounded. await Bun.write(commentsPath(file), JSON.stringify(dedupeCommentsById(list), null, 2)); } /** * Land one comments mutation: SHARED DOC FIRST, disk second (issue #111). * * The order is the fix, not a style choice. Under DDR-064 the room's Y.Doc is * the shared object and `_comments/.json` is its projection — and * `Room.flush()` projects doc→file on an 800 ms trailing debounce that ANY * doc update re-arms, hub traffic included. So on a linked project a flush is * almost always pending, and any window in which the doc is behind the file * is a window in which that flush silently writes the older list back. * * Publishing to the doc first removes the window instead of narrowing it: a * flush firing at any point from here on carries a doc that already holds the * mutation. Both halves are awaited, so the HTTP response cannot report a * comment the doc never received. * * The list is deduped ONCE here so the doc and the file are handed byte-equal * content — otherwise the file→doc import that follows the write would see a * difference and re-enter the loop. */ async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise { const settled = dedupeCommentsById(list); await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined); await saveCommentsForFile(file, settled); } async function loadAllComments(): Promise> { const out: Record = {}; let entries: Dirent[]; try { entries = await readdir(paths.commentsDir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { if (!e.isFile() || !e.name.endsWith('.json')) continue; try { const raw = await readFile(path.join(paths.commentsDir, e.name), 'utf8'); const arr = JSON.parse(raw); if (!Array.isArray(arr) || arr.length === 0) continue; const file = arr[0]?.file as string | undefined; // Backfill legacy rows so callers see the v2 shape uniformly. if (file) out[file] = arr.map(backfillComment); } catch { /* ignore */ } } return out; } function newCommentId(): string { return `c_${crypto.randomBytes(6).toString('hex')}`; } function newReplyId(): string { return `r_${crypto.randomBytes(6).toString('hex')}`; } // ---------- Git author resolution ---------- // // Author defaults flow from `git config user.name` resolved against the // repo root. Cached for the lifetime of the process — the local git // identity doesn't shift mid-session and `Bun.spawn` is cheap-but-not-free. let cachedGitUser: string | null = null; let cachedGitUserAttempted = false; async function gitCurrentUser(): Promise { if (cachedGitUserAttempted) return cachedGitUser ?? ''; cachedGitUserAttempted = true; try { const proc = Bun.spawn(['git', 'config', 'user.name'], { cwd: paths.repoRoot, stdout: 'pipe', stderr: 'pipe', }); const out = await new Response(proc.stdout).text(); await proc.exited; const name = out.trim(); cachedGitUser = name || null; } catch { cachedGitUser = null; } return cachedGitUser ?? ''; } // `git shortlog -sne` against the repo head — cached for 60 s so the // @mention popup doesn't re-fork git on every keystroke. let cachedCommitters: GitCommitter[] | null = null; let cachedCommittersAt = 0; async function gitCommitters(): Promise { const now = Date.now(); if (cachedCommitters && now - cachedCommittersAt < 60_000) return cachedCommitters; try { const proc = Bun.spawn(['git', 'shortlog', '-sne', 'HEAD'], { cwd: paths.repoRoot, stdout: 'pipe', stderr: 'pipe', }); const text = await new Response(proc.stdout).text(); await proc.exited; const lines = text .split('\n') .map((l) => l.trim()) .filter(Boolean) .slice(0, 20); const out: GitCommitter[] = []; for (const line of lines) { // Format: `\t <>` const m = line.match(/^(\d+)\s+(.+?)\s+<([^>]+)>$/); if (!m) continue; const commits = Number(m[1]); const name = m[2]?.trim() ?? ''; const email = m[3]?.trim() ?? ''; if (!name) continue; out.push({ name, email, commits }); } cachedCommitters = out; cachedCommittersAt = now; return out; } catch { cachedCommitters = cachedCommitters ?? []; cachedCommittersAt = now; return cachedCommitters; } } /** * Extract `@handle` tokens from free text. Deduped, returns the literal * `@name` form (matching what the autocomplete inserts), so a comment with * `"@ada @lin @ada"` collapses to `["@ada","@lin"]`. */ function parseMentions(text: string): string[] { const out: string[] = []; const seen = new Set(); if (typeof text !== 'string' || !text) return out; const re = /@[\w][\w.-]*/g; for (const m of text.matchAll(re)) { const tok = m[0]; if (!tok || seen.has(tok)) continue; seen.add(tok); out.push(tok); } return out; } function mentionsUnion(c: Comment): string[] { const all = [c.text, ...c.thread.map((r) => r.body)].join('\n'); return parseMentions(all); } async function commentsAdd(payload: Partial & { file: string; text: string }) { if (!payload || typeof payload.file !== 'string' || !payload.file) return null; if (typeof payload.text !== 'string' || !payload.text.trim()) return null; const list = await loadCommentsForFile(payload.file); const base = structuredClone(list); const text = String(payload.text).trim().slice(0, 4000); const author = typeof payload.author === 'string' && payload.author.trim() ? payload.author.trim().slice(0, 120) : await gitCurrentUser(); const c: Comment = { id: newCommentId(), file: payload.file, selector: String(payload.selector || ''), index: typeof payload.index === 'number' ? payload.index : undefined, dom_path: Array.isArray(payload.dom_path) ? payload.dom_path.slice(0, 16) : [], tag: String(payload.tag || ''), classes: String(payload.classes || ''), bounds: payload.bounds ?? null, html_excerpt: String(payload.html_excerpt || '').slice(0, 2000), text, status: 'open', created: new Date().toISOString(), resolved_at: null, author, thread: [], mentions: parseMentions(text), }; // Task 23 — timeline anchor pass-through (validated shape only). const tl = payload.timeline; if (tl && typeof tl === 'object') { const anchor: NonNullable = {}; if (typeof tl.clipStableId === 'string' && tl.clipStableId.length <= 200) { anchor.clipStableId = tl.clipStableId; } if (Number.isFinite(Number(tl.frameOffset))) { anchor.frameOffset = Math.max(0, Math.round(Number(tl.frameOffset))); } if (Number.isFinite(Number(tl.frame))) { anchor.frame = Math.max(0, Math.round(Number(tl.frame))); } // Dogfood (2026-07-30) — the C-tool records WHICH lane the click landed // on (storyline · V overlay · A audio) so an agent reading the // comment knows exactly where to look. if ( typeof (tl as { lane?: unknown }).lane === 'string' && (tl as { lane: string }).lane.length <= 40 ) { anchor.lane = (tl as { lane: string }).lane; } if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor; } list.push(c); await publishComments(payload.file, list, base); return c; } async function commentsAddReply( id: string, payload: { body: string; author?: string } ): Promise { if (!payload || typeof payload.body !== 'string' || !payload.body.trim()) return null; const all = await loadAllComments(); for (const [file, list] of Object.entries(all)) { const i = list.findIndex((c) => c.id === id); if (i < 0) continue; const entry = list[i]; if (!entry) continue; const base = structuredClone(list); const body = payload.body.trim().slice(0, 4000); const author = typeof payload.author === 'string' && payload.author.trim() ? payload.author.trim().slice(0, 120) : await gitCurrentUser(); const reply: Reply = { id: newReplyId(), author, body, created: new Date().toISOString(), }; entry.thread = [...entry.thread, reply]; entry.mentions = mentionsUnion(entry); await publishComments(file, list, base); return entry; } return null; } // Mutations are ID-TOTAL, not first-match (issue #112). `loadAllComments` // dedupes, so under normal operation there is exactly one entry per id and // these behave as they always did. They stay total as defense in depth: the // reporter's second symptom — "I can't then close or resolve the comments" — // was a `findIndex` + `return` marking copy 1 of 8 resolved while the overlay // (which filters `status !== 'resolved'`) kept drawing the other seven, and a // delete removing one copy per click. A duplicated list can still reach these // from an older peer mid-upgrade; resolve must resolve either way. async function commentsPatch(id: string, patch: Partial) { const all = await loadAllComments(); for (const [file, list] of Object.entries(all)) { const matches = list.filter((c) => c.id === id); const first = matches[0]; if (!first) continue; const base = structuredClone(list); for (const entry of matches) { if (patch.status === 'resolved' || patch.status === 'open') { entry.status = patch.status; entry.resolved_at = patch.status === 'resolved' ? new Date().toISOString() : null; } if (typeof patch.text === 'string' && patch.text.trim()) { entry.text = patch.text.trim().slice(0, 4000); entry.mentions = mentionsUnion(entry); } } await publishComments(file, list, base); return first; } return null; } async function commentsDelete(id: string): Promise { const all = await loadAllComments(); for (const [file, list] of Object.entries(all)) { const remaining = list.filter((c) => c.id !== id); if (remaining.length === list.length) continue; await publishComments(file, remaining, list); return true; } return false; } // ---------- Canvas state ---------- // Cloud Phase 27 D3 — one member's place in the project is not another's. // `sessionDir` is a no-op without an ambient session, so a desktop resolves // the identical path it always did. function canvasStatePath(file: string): string { return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.json`); } async function loadCanvasState(file: string) { try { const raw = await Bun.file(canvasStatePath(file)).text(); const obj = JSON.parse(raw); return obj && typeof obj === 'object' ? obj : null; } catch { return null; } } // ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ---- // // Filmstrip dataURL strips + waveform peak arrays, keyed `:`, // persisted under `_canvas-state/timeline-media/` — per-machine runtime state // (DDR-115; `_canvas-state/` is already on all three ignore lists, so a // subdirectory needs no list change). const TL_MEDIA_KEY_RE = /^[A-Za-z0-9._:-]{1,160}$/; function timelineMediaPath(key: string): string | null { if (!TL_MEDIA_KEY_RE.test(key) || key.includes('..')) return null; return path.join(paths.canvasStateDir, 'timeline-media', `${key.replaceAll(':', '__')}.json`); } async function timelineMediaLoad(key: string): Promise | null> { const p = timelineMediaPath(key); if (!p) return null; try { const obj = JSON.parse(await Bun.file(p).text()); return obj && typeof obj === 'object' ? obj : null; } catch { return null; } } async function timelineMediaSave(key: string, data: Record): Promise { const p = timelineMediaPath(key); if (!p) return false; // Shape gate (security review 2026-07-30): `strip[]` is later rendered as // `` in the trusted shell (TimelinePanel), so persist only the // two known shapes — a filmstrip of `data:image/*` URLs and a numeric peak // array. Anything else (a poisoned `http(s)://` beacon URL, a non-array) is // dropped rather than cached. Defense-in-depth over the loopback guard. const clean: Record = {}; if (Array.isArray(data.strip)) { const strip = data.strip.filter( (u): u is string => typeof u === 'string' && /^data:image\//.test(u) ); if (strip.length !== data.strip.length) return false; // reject if any entry was rejected clean.strip = strip; } if (Array.isArray(data.peaks)) { if (!data.peaks.every((n) => typeof n === 'number' && Number.isFinite(n))) return false; clean.peaks = data.peaks; } if (Object.keys(clean).length === 0) return false; try { await Bun.write(p, JSON.stringify(clean)); return true; } catch { return false; } } // ---------- Canvas meta sidecar (Phase 4 T5; split DDR-115) ---------- // // Each canvas under `/ui/.tsx` has a sibling // `.meta.json` — the SHARED, versioned document (title, sections, // `layout` per-artboard world-coord rects, css_mode, …). The PATCH path is // intentionally merge-shallow on top-level keys — never clobber `title`, // `sections`, `ai_context`, or any other authoring metadata. // // DDR-115 — the PER-USER camera (`viewport` pan/zoom) NO LONGER lives in // `.meta.json`. It churns on every mouse pan/zoom, so persisting it inline // dirtied a tracked file. It now lives in a gitignored per-machine view file // (`canvasViewPath` below). PATCH splits the lanes (viewport → view file, // layout → meta); GET merges them back so the client (`window.__canvas_meta__`) // is unchanged. `last_modified` is stamped into meta ONLY on a real shared // (layout) change — never on a viewport-only patch. /** * Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`) into * the absolute path of the canvas SOURCE file. Refuses traversal, paths that * escape repoRoot, and non-canvas extensions. Returns null on rejection. */ function canvasSourceAbs(file: string): string | null { let p = String(file).replace(/^\/+/, ''); try { p = decodeURIComponent(p); } catch { /* ignore */ } if (p.includes('..')) return null; const abs = path.join(paths.repoRoot, p); if (!abs.startsWith(`${paths.repoRoot}/`)) return null; const ext = path.extname(abs).toLowerCase(); if (ext !== '.tsx' && ext !== '.html') return null; return abs; } /** * Resolve `file` into the absolute path of its sibling `.meta.json` sidecar. * Same containment guard as `canvasSourceAbs` (refuses paths that escape * repoRoot / non-canvas extensions). */ function canvasMetaPath(file: string): string | null { const abs = canvasSourceAbs(file); return abs ? abs.replace(/\.(tsx|html)$/i, '.meta.json') : null; } // ---------- Per-machine canvas view / camera (DDR-115) ---------- // // The canvas pan/zoom ("camera") is PER-USER runtime state, separate from the // shared `.meta.json` document. It lives in `_canvas-state/.view.json` // ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from // the legacy `_canvas-state/.json` ({ sections, viewport:{x,y,scale} }) // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.02–4, so // overloading one file would let the two writers clobber each other's shape. /** Validate a candidate viewport — finite x/y, zoom clamped [0.02, 4] (the * Phase 4 rule). Mirrors `ZOOM_MIN` in canvas-lib.tsx (issue #91) — keep the * two floors in lockstep or a save/reload round-trip silently re-clamps a * zoom the client just let the user reach. Returns the normalized viewport, * or null when invalid. */ function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null { if (!v || typeof v !== 'object' || Array.isArray(v)) return null; const vv = v as { x?: unknown; y?: unknown; zoom?: unknown }; if ( Number.isFinite(vv.x as number) && Number.isFinite(vv.y as number) && Number.isFinite(vv.zoom as number) ) { const zoom = Math.min(4, Math.max(0.02, vv.zoom as number)); return { x: vv.x as number, y: vv.y as number, zoom }; } return null; } /** Per-machine view file for a canvas: `_canvas-state/.view.json`. Gated * by the same containment guard as the meta sidecar (traversal / repoRoot / * canvas-ext). Returns null when `file` is not a valid canvas path. */ function canvasViewPath(file: string): string | null { if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate // D3 again — the camera is the other per-machine singleton two members in // one cell were silently sharing. return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.view.json`); } const MAX_OVERLAY_KEYS = 32; const MAX_OVERLAY_KEY_LEN = 64; /** * Validate a candidate overlay-visibility bag — feature-1-artboard-kinds- * foundation T6. A flat string→boolean map (`{ guides: true }`); downstream * plans (print bleed, web breakpoints) add their own keys without a schema * change here. Reachable from the untrusted canvas origin (DDR-054) via the * same `/_api/canvas-meta` PATCH the viewport lane already uses, so it's * capped the same way `set-artboard-style` caps its patch shape — bounded * key COUNT and key LENGTH, not just type. Empty object is valid (an * explicit "nothing on" state); malformed input is null (silent no-op). */ function normalizeOverlays(v: unknown): Record | null { if (!v || typeof v !== 'object' || Array.isArray(v)) return null; const entries = Object.entries(v as Record); if (entries.length > MAX_OVERLAY_KEYS) return null; const out: Record = {}; for (const [k, val] of entries) { if (k.length === 0 || k.length > MAX_OVERLAY_KEY_LEN) return null; if (typeof val !== 'boolean') return null; out[k] = val; } return out; } // feature-4 T7b — per-user LOCKED layer keys (`":"`). // Runtime state per DDR-115 (like the camera + overlays): view.json, never the // versioned `.meta.json`. Reachable from the untrusted canvas origin via the // same PATCH lane, so shape + count are bounded. const MAX_LOCKED_KEYS = 500; const LOCKED_KEY_RE = /^[\w-]{1,64}:\d{1,4}$/; function normalizeLocked(v: unknown): string[] | null { if (!Array.isArray(v) || v.length > MAX_LOCKED_KEYS) return null; const out: string[] = []; for (const k of v) { if (typeof k !== 'string' || !LOCKED_KEY_RE.test(k)) return null; out.push(k); } return [...new Set(out)]; } // Issue #91 security follow-up — `layout.artboards[]` (DDR-027 position-only // entries) is reachable from the untrusted canvas origin (DDR-054) via the // same PATCH lane as overlays/locked above, but previously had no count or // magnitude bound. `fit()`/`computeFit` (canvas-lib.tsx) now correctly frames // whatever bounding box this describes — before the zoom-floor fix, a hard // 0.1 zoom clamp accidentally masked an unbounded layout into a cropped, // mismatched view; after it, an oversized synced layout can legitimately be // framed whole, promoting every artboard in it to its own GPU layer at once // (canvas-lib.tsx `content-visibility` comment). Cap count + coordinate // magnitude the same way `normalizeOverlays`/`normalizeLocked` cap theirs. const MAX_LAYOUT_ARTBOARDS = 2000; const MAX_LAYOUT_COORD = 1_000_000; function normalizeLayoutArtboards( v: unknown ): Array<{ id: string; x: number; y: number }> | null { if (!Array.isArray(v) || v.length > MAX_LAYOUT_ARTBOARDS) return null; const out: Array<{ id: string; x: number; y: number }> = []; for (const entry of v) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; const e = entry as { id?: unknown; x?: unknown; y?: unknown }; if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 128) return null; if ( !Number.isFinite(e.x as number) || !Number.isFinite(e.y as number) || Math.abs(e.x as number) > MAX_LAYOUT_COORD || Math.abs(e.y as number) > MAX_LAYOUT_COORD ) { return null; } out.push({ id: e.id, x: e.x as number, y: e.y as number }); } return out; } /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the * read half of the read-modify-write both `saveCanvasView` and * `saveCanvasOverlays` need so writing one lane never clobbers the other. */ async function readCanvasViewRaw(file: string): Promise> { const viewAbs = canvasViewPath(file); if (!viewAbs) return {}; try { const obj = JSON.parse(await Bun.file(viewAbs).text()); return obj && typeof obj === 'object' && !Array.isArray(obj) ? (obj as Record) : {}; } catch { return {}; } } async function loadCanvasView(file: string): Promise<{ viewport?: { x: number; y: number; zoom: number }; overlays?: Record; locked?: string[]; } | null> { const viewAbs = canvasViewPath(file); if (!viewAbs) return null; try { const obj = JSON.parse(await Bun.file(viewAbs).text()); if (obj && typeof obj === 'object' && !Array.isArray(obj)) { const vp = normalizeViewport((obj as { viewport?: unknown }).viewport); const ov = normalizeOverlays((obj as { overlays?: unknown }).overlays); const lk = normalizeLocked((obj as { locked?: unknown }).locked); const result: { viewport?: { x: number; y: number; zoom: number }; overlays?: Record; locked?: string[]; } = {}; if (vp) result.viewport = vp; if (ov && Object.keys(ov).length > 0) result.overlays = ov; if (lk && lk.length > 0) result.locked = lk; return result; } return null; } catch { return null; } } /** Persist the per-user camera. Validates + clamps; best-effort (mkdir the * bucket if absent). Returns the normalized viewport on write, null when the * path is rejected or the viewport is invalid (no write). Read-modify-write * so an existing `overlays` key in the same view file survives. */ async function saveCanvasView( file: string, viewport: unknown ): Promise<{ x: number; y: number; zoom: number } | null> { const viewAbs = canvasViewPath(file); if (!viewAbs) return null; const vp = normalizeViewport(viewport); if (!vp) return null; try { await mkdir(sessionDir(paths.canvasStateDir), { recursive: true }); const current = await readCanvasViewRaw(file); await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`); return vp; } catch { return null; } } /** * Persist the per-user overlay-visibility bag (T6). Shallow-merges into * whatever `overlays` the view file already has — a `{ guides: true }` patch * doesn't erase a `bleed` key a downstream print-plan toggle already set. * Never touches the versioned `.meta.json`. Read-modify-write so an existing * `viewport` key survives. */ async function saveCanvasOverlays( file: string, overlays: unknown ): Promise | null> { const viewAbs = canvasViewPath(file); if (!viewAbs) return null; const ov = normalizeOverlays(overlays); if (ov === null) return null; try { await mkdir(sessionDir(paths.canvasStateDir), { recursive: true }); const current = await readCanvasViewRaw(file); const prevOverlays = current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays) ? (current.overlays as Record) : {}; const merged = normalizeOverlays({ ...prevOverlays, ...ov }) ?? ov; await Bun.write(viewAbs, `${JSON.stringify({ ...current, overlays: merged }, null, 2)}\n`); return merged; } catch { return null; } } async function loadCanvasMeta(file: string): Promise | null> { const metaAbs = canvasMetaPath(file); if (!metaAbs) return null; let obj: Record = {}; let hadMeta = false; try { const parsed = JSON.parse(await Bun.file(metaAbs).text()); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { obj = parsed as Record; hadMeta = true; } } catch { // No meta on disk — fall through to a possible view-only result. } // DDR-115 — never surface the per-user camera or the local write-timestamp // from the on-disk meta: `viewport` is stale (the live camera lives in the // view file), `last_modified` is local bookkeeping. Strip both, then overlay // the current camera so the shell's `window.__canvas_meta__.viewport` still // restores on reload — the client stays unchanged. (`= undefined` over // `delete` — JSON.stringify/Response.json drop undefined keys, matching the // codebase convention + biome's noDelete.) obj.viewport = undefined; obj.last_modified = undefined; // T6 — `overlays` (per-user guide/mark visibility) is runtime state same as // `viewport`; a versioned `.meta.json` should never carry one (sanitizer), // but strip defensively before overlaying the real per-user value below. obj.overlays = undefined; // feature-4 T7b — `locked` (per-user locked layer keys) is the same class. obj.locked = undefined; const view = await loadCanvasView(file); if (view?.viewport) obj.viewport = view.viewport; if (view?.overlays) obj.overlays = view.overlays; if (view?.locked) obj.locked = view.locked; // Preserve the historic contract: no meta AND no camera/overlays → null // (GET → {}, PATCH-on-rejected-path → 404). A view-only canvas still // returns its camera/overlays. if (!hadMeta && !view?.viewport && !view?.overlays && !view?.locked) return null; return obj; } /** * Apply a `patch` from the (untrusted) client, splitting the two lanes * (DDR-115): * - `viewport` → the per-machine view file (`saveCanvasView`); NEVER the * versioned meta. A viewport-only patch leaves `.meta.json` byte-unchanged * (no `last_modified` bump) — this is the mouse-move churn killer. * - `layout` → the shared `.meta.json`, shallow-merged so `title`, * `sections`, `ai_context`, … are preserved; `last_modified` is stamped * ONLY here (a real shared change the user wants committable). * Returns the same coherent object a GET would produce (shared meta + camera), * or null only when the path itself is rejected (traversal / bad ext) so the * route maps it to 404. A viewport-only patch on a canvas that has no meta yet * still succeeds (writes only the view file) and returns `{ viewport }`. */ async function patchCanvasMeta( file: string, patch: Record ): Promise | null> { const metaAbs = canvasMetaPath(file); if (!metaAbs) return null; if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return null; // DDR-115 security (F-A2) — the PATCH lanes are reachable from the untrusted // canvas origin (DDR-054). Refuse to mint per-canvas state (view file or // `.meta.json`) for a canvas that doesn't exist, so a malicious origin can't // spray arbitrary-slug files/inodes via fabricated `file` paths. A valid // patch only ever targets a canvas the user actually has; `.meta.json` may // still be absent (first layout/viewport write), but the source must exist. const srcAbs = canvasSourceAbs(file); if (!srcAbs || !(await Bun.file(srcAbs).exists())) return null; // --- Per-user camera lane: viewport → view file, never the versioned meta. if (patch.viewport !== undefined) { if (patch.viewport === null) { // Explicit clear — best-effort remove the view file. const viewAbs = canvasViewPath(file); if (viewAbs) { try { await rm(viewAbs); } catch { /* absent / unreadable — nothing to clear */ } } } else { // saveCanvasView validates + clamps; an invalid viewport is a silent no-op. await saveCanvasView(file, patch.viewport); } } // --- Per-user overlay-visibility lane (T6): overlays → view file, never // the versioned meta. Same shape as the viewport lane above — `null` // clears, an invalid bag is a silent no-op (normalizeOverlays → null). if (patch.overlays !== undefined) { if (patch.overlays === null) { const viewAbs = canvasViewPath(file); if (viewAbs) { try { const current = await readCanvasViewRaw(file); const { overlays: _drop, ...rest } = current; await mkdir(sessionDir(paths.canvasStateDir), { recursive: true }); await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`); } catch { /* best-effort clear */ } } } else { await saveCanvasOverlays(file, patch.overlays); } } // --- Per-user locked-layers lane (feature-4 T7b): locked → view file, // never the versioned meta. REPLACE semantics (the client sends the full // set — merge semantics would make unlocking impossible); `null` clears; an // invalid array is a silent no-op (normalizeLocked → null). if (patch.locked !== undefined) { const viewAbs = canvasViewPath(file); if (viewAbs) { try { const current = await readCanvasViewRaw(file); if (patch.locked === null) { const { locked: _drop, ...rest } = current; await mkdir(sessionDir(paths.canvasStateDir), { recursive: true }); await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`); } else { const lk = normalizeLocked(patch.locked); if (lk !== null) { await mkdir(sessionDir(paths.canvasStateDir), { recursive: true }); await Bun.write(viewAbs, `${JSON.stringify({ ...current, locked: lk }, null, 2)}\n`); } } } catch { /* best-effort */ } } } // --- Shared document lane: layout → versioned meta, stamps last_modified. --- if (patch.layout !== undefined) { let current: Record = {}; try { const parsed = JSON.parse(await Bun.file(metaAbs).text()); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { current = parsed as Record; } } catch { // No existing meta — create one with just the layout key. } const next = { ...current }; if (patch.layout === null) { next.layout = undefined; } else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) { const rawLayout = patch.layout as Record; if ('artboards' in rawLayout) { // Bounded — see normalizeLayoutArtboards. An oversized/malformed // `artboards` array is a silent no-op (leaves the prior persisted // layout in place), matching the overlays/locked lanes' convention. const artboards = normalizeLayoutArtboards(rawLayout.artboards); if (artboards !== null) { next.layout = { ...rawLayout, artboards }; } } else { next.layout = patch.layout; } } // Defensive: a stale inline viewport must never persist in the versioned // file (the camera lane owns it now). JSON.stringify drops undefined keys. next.viewport = undefined; next.last_modified = new Date().toISOString(); // Trailing newline — consistent with canvas-create.ts + sync/codec.ts // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline. const baseText = Object.keys(current).length ? JSON.stringify(current) : null; const nextText = `${JSON.stringify(next, null, 2)}\n`; await Bun.write(metaAbs, nextText); hooks.onMetaChanged?.(file, nextText, baseText); // Same reason as the annotations sidecar below, and the same bug: this // lane writes the FILE and nothing else, so in a cell — where there is no // `fs.watch` — the layout never entered the doc and never reached a peer. // Not "late", NEVER: a canvas `.meta.json` is `canvas-owned`, so it is // excluded from the journal by design and the walk-import belt behind // every other class does not cover it either. Dragging an artboard in the // cloud left every other machine showing the old position indefinitely, // while the same drag on a laptop crossed in seconds (a laptop watcher // fires) — which reads as "sync is broken one way" rather than as a // missing announce. announceWritten(path.relative(paths.designRoot, metaAbs)); } // Return the merged view (shared meta + camera) — identical to GET, so the // client gets a coherent object regardless of which lane(s) the patch hit. return await loadCanvasMeta(file); } // ---------- Annotations sidecar (Phase 5) ---------- // // Each canvas keeps a single `.annotations.svg` file under `/` // named by the canonical `fileSlug()`. The client posts the full SVG string // on every stroke commit; the server overwrites the file. SVG is bounded at // 1 MB (rejects larger bodies) — well above realistic annotation sizes for // hundreds of strokes but small enough that a malicious POST can't fill the // disk in one round-trip. function annotationsPath(file: string): string { return path.join(paths.designRoot, `${fileSlug(file)}.annotations.svg`); } async function loadAnnotations(file: string): Promise { try { return await Bun.file(annotationsPath(file)).text(); } catch { return null; } } async function saveAnnotations( file: string, svg: string, writeId?: string, base?: string ): Promise { if (typeof svg !== 'string') return false; if (svg.length > 1024 * 1024) return false; // Cheap content gate — must look like an document. Avoids accidental // writes of arbitrary blobs through this endpoint. if (!/^\s*]/i.test(svg)) return false; // A3 (DDR-060 F1 re-audit) — sanitize active content before persisting. // This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab // write") and accepts ANY `file`, so a hub-pushed canvas can write a // sibling's `.annotations.svg`. The persisted SVG is currently consumed only // via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React // re-render), so a `