@1agh/maude 1.2.0 → 1.3.1
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/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- package/plugins/flow/dependencies.json +3 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Plan T34 / writer registry H09 — the checkout of a project that saves through
|
|
2
|
+
// accepted revisions (DDR-241) is a PROJECTION of the shared history, not a
|
|
3
|
+
// second author.
|
|
4
|
+
//
|
|
5
|
+
// A Git operation that rewrites the checkout's files — switching or adding a
|
|
6
|
+
// draft, discarding, getting latest, resolving a merge — would change files
|
|
7
|
+
// under the sync runtime, and its watcher would propose the result as this
|
|
8
|
+
// person's edit: a local Git graph silently overriding what the team accepted.
|
|
9
|
+
// In that mode these routes refuse, and say where the same intent lives:
|
|
10
|
+
// History (restore an earlier version as a new action). Commit, branch, push
|
|
11
|
+
// and fetch stay — they touch the Git graph, not the files (H10).
|
|
12
|
+
|
|
13
|
+
/** The `/_api/git/*` routes that rewrite checkout files. */
|
|
14
|
+
export const CHECKOUT_REWRITING_GIT_ROUTES = [
|
|
15
|
+
'/_api/git/checkout',
|
|
16
|
+
'/_api/git/fold',
|
|
17
|
+
'/_api/git/discard',
|
|
18
|
+
'/_api/git/pull',
|
|
19
|
+
'/_api/git/resolve',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const ACCEPTED_GIT_REFUSAL = {
|
|
23
|
+
ok: false,
|
|
24
|
+
code: 'accepted-project',
|
|
25
|
+
error:
|
|
26
|
+
'This project saves through its shared history, so its files cannot be rewritten from Git here. To go back to an earlier version, restore it from History — it becomes a new change everyone sees.',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** A refusal when the linked project is in accepted-revisions mode, else null. */
|
|
30
|
+
export function refuseCheckoutRewrite(acceptedMode: (() => boolean) | undefined): Response | null {
|
|
31
|
+
if (acceptedMode?.() !== true) return null;
|
|
32
|
+
return Response.json(ACCEPTED_GIT_REFUSAL, {
|
|
33
|
+
status: 409,
|
|
34
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -46,8 +46,19 @@ export interface HmrMessage {
|
|
|
46
46
|
version: number;
|
|
47
47
|
/** Echo of `_lib`-scoped changes for debug + scope reasoning. */
|
|
48
48
|
scope?: 'lib' | 'canvas';
|
|
49
|
+
/**
|
|
50
|
+
* The change was written by sync — the project's version landing on this
|
|
51
|
+
* disk — not by this person's own edit. The iframe may skip a reload it
|
|
52
|
+
* believes is the echo of an optimistic edit it already shows; it must never
|
|
53
|
+
* skip one of these, because a teammate's change can ride the same write (a
|
|
54
|
+
* lost race re-applied onto theirs).
|
|
55
|
+
*/
|
|
56
|
+
remote?: boolean;
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
/** How long after a sync write its file's change still counts as remote. */
|
|
60
|
+
export const REMOTE_WRITE_WINDOW_MS = 3_000;
|
|
61
|
+
|
|
51
62
|
export interface HmrBroadcaster {
|
|
52
63
|
/** Stop subscribing to fs-watch events. */
|
|
53
64
|
stop(): void;
|
|
@@ -103,9 +114,28 @@ export function createHmrBroadcaster(
|
|
|
103
114
|
});
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
// Files sync just wrote (see HmrMessage.remote), with the time of the write.
|
|
118
|
+
const projectedAt = new Map<string, number>();
|
|
119
|
+
const isRemote = (rel: string | undefined): boolean => {
|
|
120
|
+
if (!rel) return false;
|
|
121
|
+
const at = projectedAt.get(rel);
|
|
122
|
+
return at !== undefined && Date.now() - at < REMOTE_WRITE_WINDOW_MS;
|
|
123
|
+
};
|
|
124
|
+
const offProjected = ctx.bus.on('sync:projected', (rel: unknown) => {
|
|
125
|
+
if (typeof rel !== 'string' || !rel) return;
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
projectedAt.set(rel.replace(/\\/g, '/'), now);
|
|
128
|
+
if (projectedAt.size > 256) {
|
|
129
|
+
for (const [k, at] of projectedAt)
|
|
130
|
+
if (now - at >= REMOTE_WRITE_WINDOW_MS) projectedAt.delete(k);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
106
134
|
function enqueue(msg: HmrMessage) {
|
|
107
135
|
const key = msg.mode === 'hard' ? GLOBAL_KEY : (msg.file ?? GLOBAL_KEY);
|
|
108
136
|
const prev = pendingByKey.get(key);
|
|
137
|
+
// Coalescing never loses "a teammate's change is in here".
|
|
138
|
+
if (prev?.remote) msg.remote = true;
|
|
109
139
|
// Same-key coalescing keeps the strongest mode (refreshing the payload for
|
|
110
140
|
// equal rank, so the latest version token wins).
|
|
111
141
|
if (!prev || rank[msg.mode] >= rank[prev.mode]) pendingByKey.set(key, msg);
|
|
@@ -115,12 +145,15 @@ export function createHmrBroadcaster(
|
|
|
115
145
|
|
|
116
146
|
const offAny = ctx.bus.on('fs:any', (rel: string) => {
|
|
117
147
|
const msg = classify(rel);
|
|
118
|
-
if (msg)
|
|
148
|
+
if (!msg) return;
|
|
149
|
+
if (isRemote(rel.replace(/\\/g, '/')) || isRemote(msg.file)) msg.remote = true;
|
|
150
|
+
enqueue(msg);
|
|
119
151
|
});
|
|
120
152
|
|
|
121
153
|
return {
|
|
122
154
|
stop() {
|
|
123
155
|
offAny();
|
|
156
|
+
offProjected();
|
|
124
157
|
if (pending) clearTimeout(pending);
|
|
125
158
|
pending = null;
|
|
126
159
|
pendingByKey.clear();
|
package/apps/studio/http.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validAnnotationWriteId } from './annotations-sync.ts';
|
|
1
2
|
// HTTP layer for Bun.serve.
|
|
2
3
|
//
|
|
3
4
|
// Designed for extension — Phase 3.6 adds /ui/:slug + /_bun_hmr by appending to
|
|
@@ -97,11 +98,13 @@ import {
|
|
|
97
98
|
resolveAutoEngine,
|
|
98
99
|
whisperSetup,
|
|
99
100
|
} from './generation/whisper-models.ts';
|
|
101
|
+
import { refuseCheckoutRewrite } from './git/accepted-guard.ts';
|
|
100
102
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
101
103
|
import { gitShowFile } from './git/service.ts';
|
|
102
104
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
103
105
|
import type { InspectRegistry } from './inspect.ts';
|
|
104
106
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
107
|
+
import { prepareManagedProject } from './managed-projects.ts';
|
|
105
108
|
import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
106
109
|
import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
|
|
107
110
|
import { probeReadiness } from './readiness.ts';
|
|
@@ -523,6 +526,8 @@ const canvasCache = new Map<string, CanvasCacheEntry>();
|
|
|
523
526
|
// boot id in means a restart — the expected workflow after a dev-server source
|
|
524
527
|
// edit (see CLAUDE.md) — changes every canvas etag, so a normal reload re-fetches
|
|
525
528
|
// fresh chrome. (DDR-067.)
|
|
529
|
+
/** When this server process started — anything it wrote is stamped after it. */
|
|
530
|
+
const PROCESS_STARTED_AT = Date.now();
|
|
526
531
|
const RUNTIME_BOOT_ID = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
527
532
|
|
|
528
533
|
// Bumped by the chrome source watchers (below) on every canvas-lib / dev-server
|
|
@@ -851,8 +856,16 @@ async function serveHistoricalCanvas(
|
|
|
851
856
|
if (historicalMissCache.has(key)) {
|
|
852
857
|
return new Response('No saved version of this canvas', { status: 404 });
|
|
853
858
|
}
|
|
854
|
-
|
|
855
|
-
|
|
859
|
+
// ACCEPTED REVISIONS: `r<revision>` names a project revision (T27), read
|
|
860
|
+
// from the project's store — immutable, so it caches like a sha.
|
|
861
|
+
const acceptedRev = /^r(\d{1,12})$/.exec(sha);
|
|
862
|
+
let source = acceptedRev
|
|
863
|
+
? ((await ctx.syncControl
|
|
864
|
+
?.current?.()
|
|
865
|
+
?.acceptedVersion?.(repoRel, Number(acceptedRev[1]))
|
|
866
|
+
.catch(() => null)) ?? null)
|
|
867
|
+
: await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
|
|
868
|
+
if (source == null && !acceptedRev) {
|
|
856
869
|
const fromCloud = await cloudHistoryApi(ctx).historyFile(sha, repoRel);
|
|
857
870
|
source = fromCloud.ok ? fromCloud.source : null;
|
|
858
871
|
// ONLY AN AUTHORITATIVE ABSENCE IS REMEMBERED (security re-review of
|
|
@@ -1227,11 +1240,16 @@ export function createHttp(
|
|
|
1227
1240
|
// GitHub routes: MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
|
|
1228
1241
|
// startCanvasServer) and loopback-Host gated — every route either bears or
|
|
1229
1242
|
// stores the cloud credential.
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1243
|
+
// A LIVE view of ctx, never a copy: server.ts attaches `ctx.syncControl`
|
|
1244
|
+
// after the route table is built, and a spread taken here froze it out —
|
|
1245
|
+
// Connect then linked the project and answered "Restart Maude to start
|
|
1246
|
+
// syncing" instead of starting the sync (cloud-attach E2E, since 2026-08-18).
|
|
1247
|
+
const cloudApi = createCloudEndpoints(
|
|
1248
|
+
Object.assign(Object.create(ctx) as Context, {
|
|
1249
|
+
// Connect and Disconnect both change WHO can answer for a past version.
|
|
1250
|
+
onLinkChanged: clearHistoricalCaches,
|
|
1251
|
+
})
|
|
1252
|
+
);
|
|
1235
1253
|
// Figma import (DDR-216). Same dual-allowlist rule as the GitHub + cloud
|
|
1236
1254
|
// routes: MAIN-ORIGIN ONLY, plus loopback-Host and same-origin gating on
|
|
1237
1255
|
// every one of them — each either stores or spends the user's Figma PAT.
|
|
@@ -1729,7 +1747,16 @@ export function createHttp(
|
|
|
1729
1747
|
return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1730
1748
|
}
|
|
1731
1749
|
try {
|
|
1732
|
-
|
|
1750
|
+
const status = JSON.parse(readFileSync(file, 'utf8'));
|
|
1751
|
+
// The sync runtime lives in THIS process and stamps every payload it
|
|
1752
|
+
// writes. A file older than the process is a previous session's last
|
|
1753
|
+
// word — "offline" against a hub that may no longer be linked at all —
|
|
1754
|
+
// and showing it as the current state greeted the designer with
|
|
1755
|
+
// "Working offline" before anything had been tried.
|
|
1756
|
+
if (!(Number(status?.updatedAt) >= PROCESS_STARTED_AT)) {
|
|
1757
|
+
return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1758
|
+
}
|
|
1759
|
+
return Response.json(status, {
|
|
1733
1760
|
headers: { 'Cache-Control': 'no-store' },
|
|
1734
1761
|
});
|
|
1735
1762
|
} catch {
|
|
@@ -2219,6 +2246,17 @@ export function createHttp(
|
|
|
2219
2246
|
? body.author.trim().slice(0, 120)
|
|
2220
2247
|
: 'Claude';
|
|
2221
2248
|
const entry = ai.start(body.file.trim(), author);
|
|
2249
|
+
// T16 — the edit is ONE project action: what the agent writes until
|
|
2250
|
+
// /end is published together (or held, if it fails).
|
|
2251
|
+
const name =
|
|
2252
|
+
body.file
|
|
2253
|
+
.trim()
|
|
2254
|
+
.split('/')
|
|
2255
|
+
.pop()
|
|
2256
|
+
?.replace(/\.(tsx|html)$/i, '') ?? 'canvas';
|
|
2257
|
+
ctx.syncControl
|
|
2258
|
+
?.current?.()
|
|
2259
|
+
?.beginAiAction?.(`edit:${body.file.trim()}`, `${author} edited ${name}`);
|
|
2222
2260
|
return Response.json(entry, { headers: { 'Cache-Control': 'no-store' } });
|
|
2223
2261
|
},
|
|
2224
2262
|
|
|
@@ -2246,17 +2284,99 @@ export function createHttp(
|
|
|
2246
2284
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2247
2285
|
if (!isTrustedRequestHost(req))
|
|
2248
2286
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2249
|
-
const body = await readJson<{ file?: string }>(req);
|
|
2287
|
+
const body = await readJson<{ file?: string; outcome?: string }>(req);
|
|
2250
2288
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
2251
2289
|
return new Response('body.file required', { status: 400 });
|
|
2252
2290
|
}
|
|
2291
|
+
// T16 — close the action BEFORE the banner clears: `done` publishes
|
|
2292
|
+
// what the agent wrote as one action, `failed` keeps it unpublished for
|
|
2293
|
+
// the person's decision. (No outcome = done, the pre-T16 contract.)
|
|
2294
|
+
const action = await ctx.syncControl
|
|
2295
|
+
?.current?.()
|
|
2296
|
+
?.endAiAction?.(`edit:${body.file.trim()}`, body.outcome === 'failed' ? 'failed' : 'done')
|
|
2297
|
+
.catch(() => null);
|
|
2253
2298
|
const cleared = ai.end(body.file.trim());
|
|
2254
2299
|
return Response.json(
|
|
2255
|
-
{ cleared },
|
|
2300
|
+
{ cleared, ...(action ? { action } : {}) },
|
|
2256
2301
|
{ status: cleared ? 200 : 404, headers: { 'Cache-Control': 'no-store' } }
|
|
2257
2302
|
);
|
|
2258
2303
|
},
|
|
2259
2304
|
|
|
2305
|
+
'/_api/sync/offline': async (req: Request) => {
|
|
2306
|
+
// T19 — "Download everything for offline": every file of the project
|
|
2307
|
+
// now, instead of as the passes get to it.
|
|
2308
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2309
|
+
if (!sameOriginWrite(req))
|
|
2310
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2311
|
+
if (!isTrustedRequestHost(req))
|
|
2312
|
+
return new Response('local request required', { status: 403 });
|
|
2313
|
+
const r = await ctx.syncControl?.current?.()?.prepareOffline?.();
|
|
2314
|
+
if (!r)
|
|
2315
|
+
return Response.json(
|
|
2316
|
+
{ ok: false, error: 'This project has no files to download.' },
|
|
2317
|
+
{ status: 409 }
|
|
2318
|
+
);
|
|
2319
|
+
return Response.json({ ok: true, ...r }, { headers: { 'Cache-Control': 'no-store' } });
|
|
2320
|
+
},
|
|
2321
|
+
|
|
2322
|
+
'/_api/project/conflict': async (req: Request) => {
|
|
2323
|
+
// T28 — a held source conflict: GET the two sides, POST the decision.
|
|
2324
|
+
if (!isTrustedRequestHost(req))
|
|
2325
|
+
return new Response('local request required', { status: 403 });
|
|
2326
|
+
const runtime = ctx.syncControl?.current?.();
|
|
2327
|
+
if (req.method === 'GET') {
|
|
2328
|
+
const file = new URL(req.url).searchParams.get('file') ?? '';
|
|
2329
|
+
const sides = runtime?.conflictVersions?.(file);
|
|
2330
|
+
if (!sides)
|
|
2331
|
+
return Response.json(
|
|
2332
|
+
{ ok: false, error: 'No conflict on that canvas.' },
|
|
2333
|
+
{ status: 404 }
|
|
2334
|
+
);
|
|
2335
|
+
return Response.json({ ok: true, ...sides }, { headers: { 'Cache-Control': 'no-store' } });
|
|
2336
|
+
}
|
|
2337
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2338
|
+
if (!sameOriginWrite(req))
|
|
2339
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2340
|
+
const body = await readJson<{ file?: string; choice?: string }>(req);
|
|
2341
|
+
const choice = body?.choice === 'mine' ? 'mine' : body?.choice === 'theirs' ? 'theirs' : null;
|
|
2342
|
+
if (!body?.file || !choice)
|
|
2343
|
+
return Response.json({ ok: false, error: 'file and choice required' }, { status: 400 });
|
|
2344
|
+
const r = await runtime?.resolveConflict?.(body.file, choice);
|
|
2345
|
+
if (!r)
|
|
2346
|
+
return Response.json({ ok: false, error: 'No conflict on that canvas.' }, { status: 404 });
|
|
2347
|
+
return Response.json(
|
|
2348
|
+
{ ok: r.status !== 'rejected', ...r },
|
|
2349
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2350
|
+
);
|
|
2351
|
+
},
|
|
2352
|
+
|
|
2353
|
+
'/_api/project/ai-action': async (req: Request) => {
|
|
2354
|
+
// T16 — the person's decision on an unfinished AI edit (held stage).
|
|
2355
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2356
|
+
if (!sameOriginWrite(req))
|
|
2357
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2358
|
+
if (!isTrustedRequestHost(req))
|
|
2359
|
+
return new Response('local request required', { status: 403 });
|
|
2360
|
+
const body = await readJson<{ choice?: string }>(req);
|
|
2361
|
+
const choice =
|
|
2362
|
+
body?.choice === 'discard' ? 'discard' : body?.choice === 'publish' ? 'publish' : null;
|
|
2363
|
+
if (!choice)
|
|
2364
|
+
return Response.json(
|
|
2365
|
+
{ ok: false, error: 'choice must be publish or discard' },
|
|
2366
|
+
{ status: 400 }
|
|
2367
|
+
);
|
|
2368
|
+
const r = await ctx.syncControl?.current?.()?.resolveAiAction?.(choice);
|
|
2369
|
+
if (!r)
|
|
2370
|
+
return Response.json(
|
|
2371
|
+
{ ok: false, error: 'There is no unfinished AI edit.' },
|
|
2372
|
+
{ status: 409 }
|
|
2373
|
+
);
|
|
2374
|
+
return Response.json(
|
|
2375
|
+
{ ok: r.status !== 'rejected', ...r },
|
|
2376
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2377
|
+
);
|
|
2378
|
+
},
|
|
2379
|
+
|
|
2260
2380
|
'/_api/annotations': async (req: Request) => {
|
|
2261
2381
|
// Phase 5 — `<designRoot>/<slug>.annotations.svg` read / overwrite.
|
|
2262
2382
|
// GET ?file=<repo-relative-canvas-path> → SVG text (empty if absent)
|
|
@@ -2275,14 +2395,32 @@ export function createHttp(
|
|
|
2275
2395
|
});
|
|
2276
2396
|
}
|
|
2277
2397
|
if (req.method === 'PUT' || req.method === 'POST') {
|
|
2278
|
-
|
|
2398
|
+
// `base` (optional) is the SVG this edit was derived from — the hub
|
|
2399
|
+
// merges a concurrent peer's strokes from it (accepted revisions).
|
|
2400
|
+
const body = await readJson<{
|
|
2401
|
+
file?: string;
|
|
2402
|
+
svg?: string;
|
|
2403
|
+
writeId?: unknown;
|
|
2404
|
+
base?: unknown;
|
|
2405
|
+
}>(req, 2 * 1024 * 1024 + 2048);
|
|
2279
2406
|
if (!body || typeof body.file !== 'string' || !body.file) {
|
|
2280
2407
|
return new Response('body must include { file, svg }', { status: 400 });
|
|
2281
2408
|
}
|
|
2282
2409
|
if (typeof body.svg !== 'string') {
|
|
2283
2410
|
return new Response('body.svg must be a string', { status: 400 });
|
|
2284
2411
|
}
|
|
2285
|
-
|
|
2412
|
+
if (body.writeId !== undefined && !validAnnotationWriteId(body.writeId)) {
|
|
2413
|
+
return new Response('invalid annotation writeId', { status: 400 });
|
|
2414
|
+
}
|
|
2415
|
+
if (body.base !== undefined && typeof body.base !== 'string') {
|
|
2416
|
+
return new Response('body.base must be a string', { status: 400 });
|
|
2417
|
+
}
|
|
2418
|
+
const ok = await api.saveAnnotations(
|
|
2419
|
+
body.file,
|
|
2420
|
+
body.svg,
|
|
2421
|
+
body.writeId as string | undefined,
|
|
2422
|
+
body.base as string | undefined
|
|
2423
|
+
);
|
|
2286
2424
|
if (!ok) return new Response('rejected', { status: 400 });
|
|
2287
2425
|
return new Response(null, { status: 204 });
|
|
2288
2426
|
}
|
|
@@ -2344,7 +2482,11 @@ export function createHttp(
|
|
|
2344
2482
|
64 * 1024
|
|
2345
2483
|
);
|
|
2346
2484
|
if (!body) return new Response('body required', { status: 400 });
|
|
2347
|
-
const
|
|
2485
|
+
const duplicateOf = (body as { duplicateOf?: unknown }).duplicateOf;
|
|
2486
|
+
const result =
|
|
2487
|
+
duplicateOf !== undefined
|
|
2488
|
+
? await api.duplicateCanvas({ file: duplicateOf })
|
|
2489
|
+
: await api.createCanvas(body);
|
|
2348
2490
|
if (!result.ok) {
|
|
2349
2491
|
return Response.json(
|
|
2350
2492
|
{ ok: false, error: result.error },
|
|
@@ -2371,7 +2513,10 @@ export function createHttp(
|
|
|
2371
2513
|
if (!isTrustedRequestHost(req))
|
|
2372
2514
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2373
2515
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2374
|
-
const body = await readJson<{ file?: unknown; toDir?: unknown }>(
|
|
2516
|
+
const body = await readJson<{ file?: unknown; toDir?: unknown; toName?: unknown }>(
|
|
2517
|
+
req,
|
|
2518
|
+
4 * 1024
|
|
2519
|
+
);
|
|
2375
2520
|
if (!body) return new Response('body required', { status: 400 });
|
|
2376
2521
|
const result = await api.moveCanvas(body);
|
|
2377
2522
|
if (!result.ok) {
|
|
@@ -2488,6 +2633,8 @@ export function createHttp(
|
|
|
2488
2633
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2489
2634
|
if (!isTrustedRequestHost(req))
|
|
2490
2635
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2636
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2637
|
+
if (rewrite) return rewrite;
|
|
2491
2638
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2492
2639
|
return gitJson(await gitApi.checkout(body));
|
|
2493
2640
|
},
|
|
@@ -2500,6 +2647,8 @@ export function createHttp(
|
|
|
2500
2647
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2501
2648
|
if (!isTrustedRequestHost(req))
|
|
2502
2649
|
return new Response('adding a draft requires a local request', { status: 403 });
|
|
2650
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2651
|
+
if (rewrite) return rewrite;
|
|
2503
2652
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2504
2653
|
return gitJson(await gitApi.fold(body));
|
|
2505
2654
|
},
|
|
@@ -2532,6 +2681,8 @@ export function createHttp(
|
|
|
2532
2681
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2533
2682
|
if (!isTrustedRequestHost(req))
|
|
2534
2683
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2684
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2685
|
+
if (rewrite) return rewrite;
|
|
2535
2686
|
const body = await readJson<unknown>(req, 256 * 1024);
|
|
2536
2687
|
return gitJson(await gitApi.discard(body));
|
|
2537
2688
|
},
|
|
@@ -2556,6 +2707,8 @@ export function createHttp(
|
|
|
2556
2707
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2557
2708
|
if (!isTrustedRequestHost(req))
|
|
2558
2709
|
return new Response('get latest requires a local request', { status: 403 });
|
|
2710
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2711
|
+
if (rewrite) return rewrite;
|
|
2559
2712
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2560
2713
|
return gitJson(await gitApi.pull(body));
|
|
2561
2714
|
},
|
|
@@ -2570,6 +2723,8 @@ export function createHttp(
|
|
|
2570
2723
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2571
2724
|
if (!isTrustedRequestHost(req))
|
|
2572
2725
|
return new Response('resolve requires a local request', { status: 403 });
|
|
2726
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2727
|
+
if (rewrite) return rewrite;
|
|
2573
2728
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2574
2729
|
return gitJson(await gitApi.resolve(body));
|
|
2575
2730
|
},
|
|
@@ -2689,6 +2844,61 @@ export function createHttp(
|
|
|
2689
2844
|
// (DDR-054) and must not be able to command the desktop to re-push a whole
|
|
2690
2845
|
// project — a canvas that could would be an amplification primitive against
|
|
2691
2846
|
// the person's own hub, and a way to spend their rate-limit budget.
|
|
2847
|
+
// ACCEPTED REVISIONS — the project's logical history (DDR-241, T27/T28).
|
|
2848
|
+
// Main origin only: history names people, restore and undo are writes.
|
|
2849
|
+
'/_api/project/history': async (req: Request) => {
|
|
2850
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
2851
|
+
if (!isTrustedRequestHost(req)) return new Response('Forbidden', { status: 403 });
|
|
2852
|
+
const url = new URL(req.url);
|
|
2853
|
+
const runtime = ctx.syncControl?.current?.();
|
|
2854
|
+
const rows = runtime?.acceptedHistory
|
|
2855
|
+
? await runtime
|
|
2856
|
+
.acceptedHistory({
|
|
2857
|
+
limit: Number(url.searchParams.get('limit') ?? 50) || 50,
|
|
2858
|
+
before: Number(url.searchParams.get('before') ?? 0) || null,
|
|
2859
|
+
path: url.searchParams.get('path'),
|
|
2860
|
+
})
|
|
2861
|
+
.catch(() => undefined)
|
|
2862
|
+
: null;
|
|
2863
|
+
if (rows === undefined) {
|
|
2864
|
+
return gitJson({ status: 200, json: { ok: false, reason: 'unreachable' } });
|
|
2865
|
+
}
|
|
2866
|
+
if (rows === null) return gitJson({ status: 200, json: { ok: false, reason: 'legacy' } });
|
|
2867
|
+
return gitJson({ status: 200, json: { ok: true, history: rows } });
|
|
2868
|
+
},
|
|
2869
|
+
'/_api/project/restore': async (req: Request) => {
|
|
2870
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2871
|
+
if (!sameOriginWrite(req) || !isTrustedRequestHost(req))
|
|
2872
|
+
return new Response('Forbidden', { status: 403 });
|
|
2873
|
+
const body = await readJson<{ path?: unknown; revision?: unknown }>(req, 4096);
|
|
2874
|
+
const revision = Number(body?.revision);
|
|
2875
|
+
if (typeof body?.path !== 'string' || !Number.isSafeInteger(revision) || revision < 0) {
|
|
2876
|
+
return new Response('body must include { path, revision }', { status: 400 });
|
|
2877
|
+
}
|
|
2878
|
+
const r = await ctx.syncControl?.current?.()?.acceptedRestore?.(body.path, revision);
|
|
2879
|
+
if (!r) return gitJson({ status: 200, json: { ok: false, reason: 'legacy' } });
|
|
2880
|
+
return gitJson({
|
|
2881
|
+
status: r.status === 'accepted' ? 200 : 409,
|
|
2882
|
+
json: { ok: r.status === 'accepted', ...r },
|
|
2883
|
+
});
|
|
2884
|
+
},
|
|
2885
|
+
'/_api/project/undo': async (req: Request) => {
|
|
2886
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2887
|
+
if (!sameOriginWrite(req) || !isTrustedRequestHost(req))
|
|
2888
|
+
return new Response('Forbidden', { status: 403 });
|
|
2889
|
+
const body = await readJson<{ actionId?: unknown; redo?: unknown }>(req, 4096);
|
|
2890
|
+
if (typeof body?.actionId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(body.actionId)) {
|
|
2891
|
+
return new Response('body must include { actionId }', { status: 400 });
|
|
2892
|
+
}
|
|
2893
|
+
const r = await ctx.syncControl
|
|
2894
|
+
?.current?.()
|
|
2895
|
+
?.acceptedUndo?.(body.actionId, body.redo === true);
|
|
2896
|
+
if (!r) return gitJson({ status: 200, json: { ok: false, reason: 'legacy' } });
|
|
2897
|
+
return gitJson({
|
|
2898
|
+
status: r.status === 'accepted' ? 200 : 409,
|
|
2899
|
+
json: { ok: r.status === 'accepted', ...r },
|
|
2900
|
+
});
|
|
2901
|
+
},
|
|
2692
2902
|
'/_api/sync/resync': async (req: Request) => {
|
|
2693
2903
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2694
2904
|
// REFUSALS ANSWER IN JSON, WITH A REASON.
|
|
@@ -3133,6 +3343,38 @@ export function createHttp(
|
|
|
3133
3343
|
// rather than a token to paste. Same gates as the link route (MAIN ORIGIN
|
|
3134
3344
|
// only, loopback, POST CSRF) for the same reason — this handler receives a
|
|
3135
3345
|
// PASSWORD, so the untrusted canvas origin must never be able to reach it.
|
|
3346
|
+
// MANAGED PROJECTS (T21/T22): sign in / open, store the credential and
|
|
3347
|
+
// describe the project, so the native shell can create its own copy.
|
|
3348
|
+
// Main origin only — this mints and stores a credential.
|
|
3349
|
+
'/_api/projects/prepare': async (req: Request) => {
|
|
3350
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
3351
|
+
if (!sameOriginWrite(req))
|
|
3352
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
3353
|
+
if (!isTrustedRequestHost(req))
|
|
3354
|
+
return new Response('local request required', { status: 403 });
|
|
3355
|
+
const body = (await readJson<Record<string, unknown>>(req, 8 * 1024)) ?? {};
|
|
3356
|
+
const kind = body.kind;
|
|
3357
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
3358
|
+
const input =
|
|
3359
|
+
kind === 'cloud'
|
|
3360
|
+
? { kind, projectId: str(body.projectId) }
|
|
3361
|
+
: kind === 'handoff'
|
|
3362
|
+
? { kind, code: str(body.code), claimedProject: str(body.claimedProject) || undefined }
|
|
3363
|
+
: kind === 'hub'
|
|
3364
|
+
? { kind, url: str(body.url), email: str(body.email), password: str(body.password) }
|
|
3365
|
+
: kind === 'known-hub'
|
|
3366
|
+
? { kind, url: str(body.url) }
|
|
3367
|
+
: null;
|
|
3368
|
+
if (!input) return new Response('unknown kind', { status: 400 });
|
|
3369
|
+
const r = await prepareManagedProject(
|
|
3370
|
+
ctx,
|
|
3371
|
+
input as Parameters<typeof prepareManagedProject>[1]
|
|
3372
|
+
);
|
|
3373
|
+
return Response.json(r, {
|
|
3374
|
+
status: r.ok ? 200 : r.status,
|
|
3375
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
3376
|
+
});
|
|
3377
|
+
},
|
|
3136
3378
|
'/_api/workspace/sign-in': async (req: Request) => {
|
|
3137
3379
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
3138
3380
|
if (!sameOriginWrite(req))
|
|
@@ -3192,17 +3434,22 @@ export function createHttp(
|
|
|
3192
3434
|
value?: unknown;
|
|
3193
3435
|
reset?: unknown;
|
|
3194
3436
|
idIndex?: unknown;
|
|
3437
|
+
expected?: unknown;
|
|
3195
3438
|
}>(req, 8 * 1024);
|
|
3196
3439
|
if (!body) return new Response('body required', { status: 400 });
|
|
3197
3440
|
const result = await api.editCss(body);
|
|
3198
3441
|
if (!result.ok) {
|
|
3199
3442
|
return Response.json(
|
|
3200
|
-
{ ok: false, error: result.error },
|
|
3443
|
+
{ ok: false, error: result.error, ...(result.conflict ? { conflict: true } : {}) },
|
|
3201
3444
|
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
3202
3445
|
);
|
|
3203
3446
|
}
|
|
3204
3447
|
return Response.json(
|
|
3205
|
-
{
|
|
3448
|
+
{
|
|
3449
|
+
ok: true,
|
|
3450
|
+
delta: result.delta,
|
|
3451
|
+
...('previous' in result ? { previous: result.previous } : {}),
|
|
3452
|
+
},
|
|
3206
3453
|
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
3207
3454
|
);
|
|
3208
3455
|
},
|
|
@@ -3257,17 +3504,23 @@ export function createHttp(
|
|
|
3257
3504
|
attr?: unknown;
|
|
3258
3505
|
value?: unknown;
|
|
3259
3506
|
reset?: unknown;
|
|
3507
|
+
expected?: unknown;
|
|
3260
3508
|
}>(req, 8 * 1024);
|
|
3261
3509
|
if (!body) return new Response('body required', { status: 400 });
|
|
3262
3510
|
const result = await api.editAttr(body);
|
|
3263
3511
|
if (!result.ok) {
|
|
3264
3512
|
return Response.json(
|
|
3265
|
-
{ ok: false, error: result.error },
|
|
3513
|
+
{ ok: false, error: result.error, ...(result.conflict ? { conflict: true } : {}) },
|
|
3266
3514
|
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
3267
3515
|
);
|
|
3268
3516
|
}
|
|
3269
3517
|
return Response.json(
|
|
3270
|
-
{
|
|
3518
|
+
{
|
|
3519
|
+
ok: true,
|
|
3520
|
+
delta: result.delta,
|
|
3521
|
+
seq: result.seq,
|
|
3522
|
+
...('previous' in result ? { previous: result.previous } : {}),
|
|
3523
|
+
},
|
|
3271
3524
|
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
3272
3525
|
);
|
|
3273
3526
|
},
|
|
@@ -4049,6 +4302,35 @@ export function createHttp(
|
|
|
4049
4302
|
);
|
|
4050
4303
|
},
|
|
4051
4304
|
|
|
4305
|
+
'/_api/set-artboard-label': async (req: Request) => {
|
|
4306
|
+
// Plan T25/L08 — rename an artboard. POST { canvas, artboardId, label }
|
|
4307
|
+
// → api.setArtboardLabelOp. The canvas iframe only REQUESTS this over
|
|
4308
|
+
// the dgn bus (double-click the name); the shell writes it. MAIN-ORIGIN
|
|
4309
|
+
// ONLY, same gate pair as set-artboard-kind.
|
|
4310
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
4311
|
+
if (!sameOriginWrite(req))
|
|
4312
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
4313
|
+
if (!isTrustedRequestHost(req))
|
|
4314
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
4315
|
+
const body = await readJson<{
|
|
4316
|
+
canvas?: unknown;
|
|
4317
|
+
artboardId?: unknown;
|
|
4318
|
+
label?: unknown;
|
|
4319
|
+
}>(req, 2 * 1024);
|
|
4320
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
4321
|
+
const result = await api.setArtboardLabelOp(body);
|
|
4322
|
+
if (!result.ok) {
|
|
4323
|
+
return Response.json(
|
|
4324
|
+
{ ok: false, error: result.error },
|
|
4325
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
4326
|
+
);
|
|
4327
|
+
}
|
|
4328
|
+
return Response.json(
|
|
4329
|
+
{ ok: true, seq: result.seq },
|
|
4330
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
4331
|
+
);
|
|
4332
|
+
},
|
|
4333
|
+
|
|
4052
4334
|
'/_api/set-artboard-guides': async (req: Request) => {
|
|
4053
4335
|
// feature-1-artboard-kinds-foundation, T5. POST { canvas, artboardId,
|
|
4054
4336
|
// guides: {...}|null } → api.setArtboardGuidesOp. REPLACE-whole-prop
|
package/apps/studio/inspect.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface Inspect {
|
|
|
114
114
|
* when the moved canvas wasn't referenced anywhere in this state.
|
|
115
115
|
*/
|
|
116
116
|
retarget(fromFile: string, toFile: string): boolean;
|
|
117
|
+
/** Forget a deleted canvas without moving its selection onto another file. */
|
|
118
|
+
remove(file: string): boolean;
|
|
117
119
|
save(): Promise<void>;
|
|
118
120
|
injectInspector(html: string): string;
|
|
119
121
|
}
|
|
@@ -333,6 +335,22 @@ export function createInspect(
|
|
|
333
335
|
ctx.bus.emit('selected', state.selected, { session: sessionKey });
|
|
334
336
|
}
|
|
335
337
|
|
|
338
|
+
function remove(file: string): boolean {
|
|
339
|
+
const slug = deriveCanvasSlug(file);
|
|
340
|
+
const active = state.active === file;
|
|
341
|
+
const referenced =
|
|
342
|
+
active || state.open_tabs.includes(file) || Object.hasOwn(state.selections, slug);
|
|
343
|
+
if (!referenced) return false;
|
|
344
|
+
// setActive parks the outgoing selection; remove that memory afterwards.
|
|
345
|
+
if (active) setActive('');
|
|
346
|
+
state.open_tabs = state.open_tabs.filter((tab) => tab !== file);
|
|
347
|
+
delete state.selections[slug];
|
|
348
|
+
if (active) state.active_comments = [];
|
|
349
|
+
state.last_change = new Date().toISOString();
|
|
350
|
+
scheduleSave();
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
336
354
|
function retarget(fromFile: string, toFile: string): boolean {
|
|
337
355
|
const fromSlug = deriveCanvasSlug(fromFile);
|
|
338
356
|
const toSlug = deriveCanvasSlug(toFile);
|
|
@@ -396,6 +414,7 @@ export function createInspect(
|
|
|
396
414
|
setOpenTabs,
|
|
397
415
|
setSelected,
|
|
398
416
|
retarget,
|
|
417
|
+
remove,
|
|
399
418
|
save,
|
|
400
419
|
injectInspector,
|
|
401
420
|
};
|