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