@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.
Files changed (91) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. 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) enqueue(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();
@@ -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
- let source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
855
- if (source == null) {
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
- const cloudApi = createCloudEndpoints({
1231
- ...ctx,
1232
- // Connect and Disconnect both change WHO can answer for a past version.
1233
- onLinkChanged: clearHistoricalCaches,
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
- return Response.json(JSON.parse(readFileSync(file, 'utf8')), {
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
- const body = await readJson<{ file?: string; svg?: string }>(req, 1024 * 1024 + 1024);
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
- const ok = await api.saveAnnotations(body.file, body.svg);
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 result = await api.createCanvas(body);
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 }>(req, 4 * 1024);
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
- { ok: true, delta: result.delta },
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
- { ok: true, delta: result.delta, seq: result.seq },
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
@@ -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
  };