@1agh/maude 1.2.0 → 1.3.0

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 (83) 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 +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. package/plugins/flow/dependencies.json +3 -3
@@ -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
@@ -102,6 +103,7 @@ import { gitShowFile } from './git/service.ts';
102
103
  import { createGitHubEndpoints } from './github/endpoints.ts';
103
104
  import type { InspectRegistry } from './inspect.ts';
104
105
  import { canvasSlug, writeLocator } from './locator.ts';
106
+ import { prepareManagedProject } from './managed-projects.ts';
105
107
  import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
106
108
  import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
107
109
  import { probeReadiness } from './readiness.ts';
@@ -523,6 +525,8 @@ const canvasCache = new Map<string, CanvasCacheEntry>();
523
525
  // boot id in means a restart — the expected workflow after a dev-server source
524
526
  // edit (see CLAUDE.md) — changes every canvas etag, so a normal reload re-fetches
525
527
  // fresh chrome. (DDR-067.)
528
+ /** When this server process started — anything it wrote is stamped after it. */
529
+ const PROCESS_STARTED_AT = Date.now();
526
530
  const RUNTIME_BOOT_ID = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
527
531
 
528
532
  // Bumped by the chrome source watchers (below) on every canvas-lib / dev-server
@@ -851,8 +855,16 @@ async function serveHistoricalCanvas(
851
855
  if (historicalMissCache.has(key)) {
852
856
  return new Response('No saved version of this canvas', { status: 404 });
853
857
  }
854
- let source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
855
- if (source == null) {
858
+ // ACCEPTED REVISIONS: `r<revision>` names a project revision (T27), read
859
+ // from the project's store — immutable, so it caches like a sha.
860
+ const acceptedRev = /^r(\d{1,12})$/.exec(sha);
861
+ let source = acceptedRev
862
+ ? ((await ctx.syncControl
863
+ ?.current?.()
864
+ ?.acceptedVersion?.(repoRel, Number(acceptedRev[1]))
865
+ .catch(() => null)) ?? null)
866
+ : await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
867
+ if (source == null && !acceptedRev) {
856
868
  const fromCloud = await cloudHistoryApi(ctx).historyFile(sha, repoRel);
857
869
  source = fromCloud.ok ? fromCloud.source : null;
858
870
  // ONLY AN AUTHORITATIVE ABSENCE IS REMEMBERED (security re-review of
@@ -1227,11 +1239,16 @@ export function createHttp(
1227
1239
  // GitHub routes: MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
1228
1240
  // startCanvasServer) and loopback-Host gated — every route either bears or
1229
1241
  // 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
- });
1242
+ // A LIVE view of ctx, never a copy: server.ts attaches `ctx.syncControl`
1243
+ // after the route table is built, and a spread taken here froze it out —
1244
+ // Connect then linked the project and answered "Restart Maude to start
1245
+ // syncing" instead of starting the sync (cloud-attach E2E, since 2026-08-18).
1246
+ const cloudApi = createCloudEndpoints(
1247
+ Object.assign(Object.create(ctx) as Context, {
1248
+ // Connect and Disconnect both change WHO can answer for a past version.
1249
+ onLinkChanged: clearHistoricalCaches,
1250
+ })
1251
+ );
1235
1252
  // Figma import (DDR-216). Same dual-allowlist rule as the GitHub + cloud
1236
1253
  // routes: MAIN-ORIGIN ONLY, plus loopback-Host and same-origin gating on
1237
1254
  // every one of them — each either stores or spends the user's Figma PAT.
@@ -1729,7 +1746,16 @@ export function createHttp(
1729
1746
  return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
1730
1747
  }
1731
1748
  try {
1732
- return Response.json(JSON.parse(readFileSync(file, 'utf8')), {
1749
+ const status = JSON.parse(readFileSync(file, 'utf8'));
1750
+ // The sync runtime lives in THIS process and stamps every payload it
1751
+ // writes. A file older than the process is a previous session's last
1752
+ // word — "offline" against a hub that may no longer be linked at all —
1753
+ // and showing it as the current state greeted the designer with
1754
+ // "Working offline" before anything had been tried.
1755
+ if (!(Number(status?.updatedAt) >= PROCESS_STARTED_AT)) {
1756
+ return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
1757
+ }
1758
+ return Response.json(status, {
1733
1759
  headers: { 'Cache-Control': 'no-store' },
1734
1760
  });
1735
1761
  } catch {
@@ -2219,6 +2245,17 @@ export function createHttp(
2219
2245
  ? body.author.trim().slice(0, 120)
2220
2246
  : 'Claude';
2221
2247
  const entry = ai.start(body.file.trim(), author);
2248
+ // T16 — the edit is ONE project action: what the agent writes until
2249
+ // /end is published together (or held, if it fails).
2250
+ const name =
2251
+ body.file
2252
+ .trim()
2253
+ .split('/')
2254
+ .pop()
2255
+ ?.replace(/\.(tsx|html)$/i, '') ?? 'canvas';
2256
+ ctx.syncControl
2257
+ ?.current?.()
2258
+ ?.beginAiAction?.(`edit:${body.file.trim()}`, `${author} edited ${name}`);
2222
2259
  return Response.json(entry, { headers: { 'Cache-Control': 'no-store' } });
2223
2260
  },
2224
2261
 
@@ -2246,17 +2283,99 @@ export function createHttp(
2246
2283
  return new Response('cross-origin write rejected', { status: 403 });
2247
2284
  if (!isTrustedRequestHost(req))
2248
2285
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2249
- const body = await readJson<{ file?: string }>(req);
2286
+ const body = await readJson<{ file?: string; outcome?: string }>(req);
2250
2287
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
2251
2288
  return new Response('body.file required', { status: 400 });
2252
2289
  }
2290
+ // T16 — close the action BEFORE the banner clears: `done` publishes
2291
+ // what the agent wrote as one action, `failed` keeps it unpublished for
2292
+ // the person's decision. (No outcome = done, the pre-T16 contract.)
2293
+ const action = await ctx.syncControl
2294
+ ?.current?.()
2295
+ ?.endAiAction?.(`edit:${body.file.trim()}`, body.outcome === 'failed' ? 'failed' : 'done')
2296
+ .catch(() => null);
2253
2297
  const cleared = ai.end(body.file.trim());
2254
2298
  return Response.json(
2255
- { cleared },
2299
+ { cleared, ...(action ? { action } : {}) },
2256
2300
  { status: cleared ? 200 : 404, headers: { 'Cache-Control': 'no-store' } }
2257
2301
  );
2258
2302
  },
2259
2303
 
2304
+ '/_api/sync/offline': async (req: Request) => {
2305
+ // T19 — "Download everything for offline": every file of the project
2306
+ // now, instead of as the passes get to it.
2307
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2308
+ if (!sameOriginWrite(req))
2309
+ return new Response('cross-origin write rejected', { status: 403 });
2310
+ if (!isTrustedRequestHost(req))
2311
+ return new Response('local request required', { status: 403 });
2312
+ const r = await ctx.syncControl?.current?.()?.prepareOffline?.();
2313
+ if (!r)
2314
+ return Response.json(
2315
+ { ok: false, error: 'This project has no files to download.' },
2316
+ { status: 409 }
2317
+ );
2318
+ return Response.json({ ok: true, ...r }, { headers: { 'Cache-Control': 'no-store' } });
2319
+ },
2320
+
2321
+ '/_api/project/conflict': async (req: Request) => {
2322
+ // T28 — a held source conflict: GET the two sides, POST the decision.
2323
+ if (!isTrustedRequestHost(req))
2324
+ return new Response('local request required', { status: 403 });
2325
+ const runtime = ctx.syncControl?.current?.();
2326
+ if (req.method === 'GET') {
2327
+ const file = new URL(req.url).searchParams.get('file') ?? '';
2328
+ const sides = runtime?.conflictVersions?.(file);
2329
+ if (!sides)
2330
+ return Response.json(
2331
+ { ok: false, error: 'No conflict on that canvas.' },
2332
+ { status: 404 }
2333
+ );
2334
+ return Response.json({ ok: true, ...sides }, { headers: { 'Cache-Control': 'no-store' } });
2335
+ }
2336
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2337
+ if (!sameOriginWrite(req))
2338
+ return new Response('cross-origin write rejected', { status: 403 });
2339
+ const body = await readJson<{ file?: string; choice?: string }>(req);
2340
+ const choice = body?.choice === 'mine' ? 'mine' : body?.choice === 'theirs' ? 'theirs' : null;
2341
+ if (!body?.file || !choice)
2342
+ return Response.json({ ok: false, error: 'file and choice required' }, { status: 400 });
2343
+ const r = await runtime?.resolveConflict?.(body.file, choice);
2344
+ if (!r)
2345
+ return Response.json({ ok: false, error: 'No conflict on that canvas.' }, { status: 404 });
2346
+ return Response.json(
2347
+ { ok: r.status !== 'rejected', ...r },
2348
+ { headers: { 'Cache-Control': 'no-store' } }
2349
+ );
2350
+ },
2351
+
2352
+ '/_api/project/ai-action': async (req: Request) => {
2353
+ // T16 — the person's decision on an unfinished AI edit (held stage).
2354
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2355
+ if (!sameOriginWrite(req))
2356
+ return new Response('cross-origin write rejected', { status: 403 });
2357
+ if (!isTrustedRequestHost(req))
2358
+ return new Response('local request required', { status: 403 });
2359
+ const body = await readJson<{ choice?: string }>(req);
2360
+ const choice =
2361
+ body?.choice === 'discard' ? 'discard' : body?.choice === 'publish' ? 'publish' : null;
2362
+ if (!choice)
2363
+ return Response.json(
2364
+ { ok: false, error: 'choice must be publish or discard' },
2365
+ { status: 400 }
2366
+ );
2367
+ const r = await ctx.syncControl?.current?.()?.resolveAiAction?.(choice);
2368
+ if (!r)
2369
+ return Response.json(
2370
+ { ok: false, error: 'There is no unfinished AI edit.' },
2371
+ { status: 409 }
2372
+ );
2373
+ return Response.json(
2374
+ { ok: r.status !== 'rejected', ...r },
2375
+ { headers: { 'Cache-Control': 'no-store' } }
2376
+ );
2377
+ },
2378
+
2260
2379
  '/_api/annotations': async (req: Request) => {
2261
2380
  // Phase 5 — `<designRoot>/<slug>.annotations.svg` read / overwrite.
2262
2381
  // GET ?file=<repo-relative-canvas-path> → SVG text (empty if absent)
@@ -2275,14 +2394,32 @@ export function createHttp(
2275
2394
  });
2276
2395
  }
2277
2396
  if (req.method === 'PUT' || req.method === 'POST') {
2278
- const body = await readJson<{ file?: string; svg?: string }>(req, 1024 * 1024 + 1024);
2397
+ // `base` (optional) is the SVG this edit was derived from — the hub
2398
+ // merges a concurrent peer's strokes from it (accepted revisions).
2399
+ const body = await readJson<{
2400
+ file?: string;
2401
+ svg?: string;
2402
+ writeId?: unknown;
2403
+ base?: unknown;
2404
+ }>(req, 2 * 1024 * 1024 + 2048);
2279
2405
  if (!body || typeof body.file !== 'string' || !body.file) {
2280
2406
  return new Response('body must include { file, svg }', { status: 400 });
2281
2407
  }
2282
2408
  if (typeof body.svg !== 'string') {
2283
2409
  return new Response('body.svg must be a string', { status: 400 });
2284
2410
  }
2285
- const ok = await api.saveAnnotations(body.file, body.svg);
2411
+ if (body.writeId !== undefined && !validAnnotationWriteId(body.writeId)) {
2412
+ return new Response('invalid annotation writeId', { status: 400 });
2413
+ }
2414
+ if (body.base !== undefined && typeof body.base !== 'string') {
2415
+ return new Response('body.base must be a string', { status: 400 });
2416
+ }
2417
+ const ok = await api.saveAnnotations(
2418
+ body.file,
2419
+ body.svg,
2420
+ body.writeId as string | undefined,
2421
+ body.base as string | undefined
2422
+ );
2286
2423
  if (!ok) return new Response('rejected', { status: 400 });
2287
2424
  return new Response(null, { status: 204 });
2288
2425
  }
@@ -2344,7 +2481,11 @@ export function createHttp(
2344
2481
  64 * 1024
2345
2482
  );
2346
2483
  if (!body) return new Response('body required', { status: 400 });
2347
- const result = await api.createCanvas(body);
2484
+ const duplicateOf = (body as { duplicateOf?: unknown }).duplicateOf;
2485
+ const result =
2486
+ duplicateOf !== undefined
2487
+ ? await api.duplicateCanvas({ file: duplicateOf })
2488
+ : await api.createCanvas(body);
2348
2489
  if (!result.ok) {
2349
2490
  return Response.json(
2350
2491
  { ok: false, error: result.error },
@@ -2371,7 +2512,10 @@ export function createHttp(
2371
2512
  if (!isTrustedRequestHost(req))
2372
2513
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2373
2514
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2374
- const body = await readJson<{ file?: unknown; toDir?: unknown }>(req, 4 * 1024);
2515
+ const body = await readJson<{ file?: unknown; toDir?: unknown; toName?: unknown }>(
2516
+ req,
2517
+ 4 * 1024
2518
+ );
2375
2519
  if (!body) return new Response('body required', { status: 400 });
2376
2520
  const result = await api.moveCanvas(body);
2377
2521
  if (!result.ok) {
@@ -2689,6 +2833,61 @@ export function createHttp(
2689
2833
  // (DDR-054) and must not be able to command the desktop to re-push a whole
2690
2834
  // project — a canvas that could would be an amplification primitive against
2691
2835
  // the person's own hub, and a way to spend their rate-limit budget.
2836
+ // ACCEPTED REVISIONS — the project's logical history (DDR-241, T27/T28).
2837
+ // Main origin only: history names people, restore and undo are writes.
2838
+ '/_api/project/history': async (req: Request) => {
2839
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2840
+ if (!isTrustedRequestHost(req)) return new Response('Forbidden', { status: 403 });
2841
+ const url = new URL(req.url);
2842
+ const runtime = ctx.syncControl?.current?.();
2843
+ const rows = runtime?.acceptedHistory
2844
+ ? await runtime
2845
+ .acceptedHistory({
2846
+ limit: Number(url.searchParams.get('limit') ?? 50) || 50,
2847
+ before: Number(url.searchParams.get('before') ?? 0) || null,
2848
+ path: url.searchParams.get('path'),
2849
+ })
2850
+ .catch(() => undefined)
2851
+ : null;
2852
+ if (rows === undefined) {
2853
+ return gitJson({ status: 200, json: { ok: false, reason: 'unreachable' } });
2854
+ }
2855
+ if (rows === null) return gitJson({ status: 200, json: { ok: false, reason: 'legacy' } });
2856
+ return gitJson({ status: 200, json: { ok: true, history: rows } });
2857
+ },
2858
+ '/_api/project/restore': async (req: Request) => {
2859
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2860
+ if (!sameOriginWrite(req) || !isTrustedRequestHost(req))
2861
+ return new Response('Forbidden', { status: 403 });
2862
+ const body = await readJson<{ path?: unknown; revision?: unknown }>(req, 4096);
2863
+ const revision = Number(body?.revision);
2864
+ if (typeof body?.path !== 'string' || !Number.isSafeInteger(revision) || revision < 0) {
2865
+ return new Response('body must include { path, revision }', { status: 400 });
2866
+ }
2867
+ const r = await ctx.syncControl?.current?.()?.acceptedRestore?.(body.path, revision);
2868
+ if (!r) return gitJson({ status: 200, json: { ok: false, reason: 'legacy' } });
2869
+ return gitJson({
2870
+ status: r.status === 'accepted' ? 200 : 409,
2871
+ json: { ok: r.status === 'accepted', ...r },
2872
+ });
2873
+ },
2874
+ '/_api/project/undo': async (req: Request) => {
2875
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2876
+ if (!sameOriginWrite(req) || !isTrustedRequestHost(req))
2877
+ return new Response('Forbidden', { status: 403 });
2878
+ const body = await readJson<{ actionId?: unknown; redo?: unknown }>(req, 4096);
2879
+ if (typeof body?.actionId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(body.actionId)) {
2880
+ return new Response('body must include { actionId }', { status: 400 });
2881
+ }
2882
+ const r = await ctx.syncControl
2883
+ ?.current?.()
2884
+ ?.acceptedUndo?.(body.actionId, body.redo === true);
2885
+ if (!r) return gitJson({ status: 200, json: { ok: false, reason: 'legacy' } });
2886
+ return gitJson({
2887
+ status: r.status === 'accepted' ? 200 : 409,
2888
+ json: { ok: r.status === 'accepted', ...r },
2889
+ });
2890
+ },
2692
2891
  '/_api/sync/resync': async (req: Request) => {
2693
2892
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2694
2893
  // REFUSALS ANSWER IN JSON, WITH A REASON.
@@ -3133,6 +3332,38 @@ export function createHttp(
3133
3332
  // rather than a token to paste. Same gates as the link route (MAIN ORIGIN
3134
3333
  // only, loopback, POST CSRF) for the same reason — this handler receives a
3135
3334
  // PASSWORD, so the untrusted canvas origin must never be able to reach it.
3335
+ // MANAGED PROJECTS (T21/T22): sign in / open, store the credential and
3336
+ // describe the project, so the native shell can create its own copy.
3337
+ // Main origin only — this mints and stores a credential.
3338
+ '/_api/projects/prepare': async (req: Request) => {
3339
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3340
+ if (!sameOriginWrite(req))
3341
+ return new Response('cross-origin write rejected', { status: 403 });
3342
+ if (!isTrustedRequestHost(req))
3343
+ return new Response('local request required', { status: 403 });
3344
+ const body = (await readJson<Record<string, unknown>>(req, 8 * 1024)) ?? {};
3345
+ const kind = body.kind;
3346
+ const str = (v: unknown) => (typeof v === 'string' ? v : '');
3347
+ const input =
3348
+ kind === 'cloud'
3349
+ ? { kind, projectId: str(body.projectId) }
3350
+ : kind === 'handoff'
3351
+ ? { kind, code: str(body.code), claimedProject: str(body.claimedProject) || undefined }
3352
+ : kind === 'hub'
3353
+ ? { kind, url: str(body.url), email: str(body.email), password: str(body.password) }
3354
+ : kind === 'known-hub'
3355
+ ? { kind, url: str(body.url) }
3356
+ : null;
3357
+ if (!input) return new Response('unknown kind', { status: 400 });
3358
+ const r = await prepareManagedProject(
3359
+ ctx,
3360
+ input as Parameters<typeof prepareManagedProject>[1]
3361
+ );
3362
+ return Response.json(r, {
3363
+ status: r.ok ? 200 : r.status,
3364
+ headers: { 'Cache-Control': 'no-store' },
3365
+ });
3366
+ },
3136
3367
  '/_api/workspace/sign-in': async (req: Request) => {
3137
3368
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3138
3369
  if (!sameOriginWrite(req))
@@ -3192,17 +3423,22 @@ export function createHttp(
3192
3423
  value?: unknown;
3193
3424
  reset?: unknown;
3194
3425
  idIndex?: unknown;
3426
+ expected?: unknown;
3195
3427
  }>(req, 8 * 1024);
3196
3428
  if (!body) return new Response('body required', { status: 400 });
3197
3429
  const result = await api.editCss(body);
3198
3430
  if (!result.ok) {
3199
3431
  return Response.json(
3200
- { ok: false, error: result.error },
3432
+ { ok: false, error: result.error, ...(result.conflict ? { conflict: true } : {}) },
3201
3433
  { status: result.status, headers: { 'Cache-Control': 'no-store' } }
3202
3434
  );
3203
3435
  }
3204
3436
  return Response.json(
3205
- { ok: true, delta: result.delta },
3437
+ {
3438
+ ok: true,
3439
+ delta: result.delta,
3440
+ ...('previous' in result ? { previous: result.previous } : {}),
3441
+ },
3206
3442
  { status: 200, headers: { 'Cache-Control': 'no-store' } }
3207
3443
  );
3208
3444
  },
@@ -3257,17 +3493,23 @@ export function createHttp(
3257
3493
  attr?: unknown;
3258
3494
  value?: unknown;
3259
3495
  reset?: unknown;
3496
+ expected?: unknown;
3260
3497
  }>(req, 8 * 1024);
3261
3498
  if (!body) return new Response('body required', { status: 400 });
3262
3499
  const result = await api.editAttr(body);
3263
3500
  if (!result.ok) {
3264
3501
  return Response.json(
3265
- { ok: false, error: result.error },
3502
+ { ok: false, error: result.error, ...(result.conflict ? { conflict: true } : {}) },
3266
3503
  { status: result.status, headers: { 'Cache-Control': 'no-store' } }
3267
3504
  );
3268
3505
  }
3269
3506
  return Response.json(
3270
- { ok: true, delta: result.delta, seq: result.seq },
3507
+ {
3508
+ ok: true,
3509
+ delta: result.delta,
3510
+ seq: result.seq,
3511
+ ...('previous' in result ? { previous: result.previous } : {}),
3512
+ },
3271
3513
  { status: 200, headers: { 'Cache-Control': 'no-store' } }
3272
3514
  );
3273
3515
  },
@@ -4049,6 +4291,35 @@ export function createHttp(
4049
4291
  );
4050
4292
  },
4051
4293
 
4294
+ '/_api/set-artboard-label': async (req: Request) => {
4295
+ // Plan T25/L08 — rename an artboard. POST { canvas, artboardId, label }
4296
+ // → api.setArtboardLabelOp. The canvas iframe only REQUESTS this over
4297
+ // the dgn bus (double-click the name); the shell writes it. MAIN-ORIGIN
4298
+ // ONLY, same gate pair as set-artboard-kind.
4299
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4300
+ if (!sameOriginWrite(req))
4301
+ return new Response('cross-origin write rejected', { status: 403 });
4302
+ if (!isTrustedRequestHost(req))
4303
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4304
+ const body = await readJson<{
4305
+ canvas?: unknown;
4306
+ artboardId?: unknown;
4307
+ label?: unknown;
4308
+ }>(req, 2 * 1024);
4309
+ if (!body) return new Response('body required', { status: 400 });
4310
+ const result = await api.setArtboardLabelOp(body);
4311
+ if (!result.ok) {
4312
+ return Response.json(
4313
+ { ok: false, error: result.error },
4314
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
4315
+ );
4316
+ }
4317
+ return Response.json(
4318
+ { ok: true, seq: result.seq },
4319
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
4320
+ );
4321
+ },
4322
+
4052
4323
  '/_api/set-artboard-guides': async (req: Request) => {
4053
4324
  // feature-1-artboard-kinds-foundation, T5. POST { canvas, artboardId,
4054
4325
  // 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
  };
@@ -0,0 +1,150 @@
1
+ // Preparing a MANAGED project — plan T19–T22 (DDR-241 follow-on).
2
+ //
3
+ // A designer who was added to a project should be able to open it without
4
+ // choosing a folder, having Git, or pasting a token. The desktop does that in
5
+ // two halves:
6
+ //
7
+ // 1. HERE (the running studio, which holds the Maude Cloud device session or
8
+ // takes a team-server sign-in): obtain and store the hub credential, then
9
+ // ask the project what it looks like (its bootstrap) — name, canvas
10
+ // groups — so the local copy can be declared correctly before it exists.
11
+ // 2. The native shell (`managed_project_open`) creates the project's own
12
+ // copy under the app's data directory from exactly what this returns,
13
+ // and switches to it; the sync runtime then brings everything down.
14
+ //
15
+ // Nothing here writes into the folder this studio is serving.
16
+
17
+ import { createCloudEndpoints } from './cloud/endpoints.ts';
18
+ import type { Context } from './context.ts';
19
+ import { getHubRecord, normalizeUrl } from './sync/hubs-config.ts';
20
+ import { signInToWorkspace } from './sync/workspace-signin.ts';
21
+
22
+ export type PrepareInput =
23
+ | { kind: 'cloud'; projectId: string }
24
+ | { kind: 'handoff'; code: string; claimedProject?: string }
25
+ | { kind: 'hub'; url: string; email: string; password: string }
26
+ /** A team server this machine already holds a credential for. */
27
+ | { kind: 'known-hub'; url: string };
28
+
29
+ export interface PreparedProject {
30
+ ok: true;
31
+ serverUrl: string;
32
+ /** Stable id on that server — the managed copy is keyed by (server, id). */
33
+ projectId: string;
34
+ name: string;
35
+ role: string | null;
36
+ canvasGroups: string[];
37
+ /** The project's save mode, when the server says ('transactions' | 'legacy'). */
38
+ mode: string | null;
39
+ }
40
+
41
+ export type PrepareResult = PreparedProject | { ok: false; error: string; status: number };
42
+
43
+ const GROUP = /^[\p{L}\p{N}][\p{L}\p{N} _-]{0,59}$/u;
44
+
45
+ /** Canvas groups a project uses, from its manifest when it does not declare them. */
46
+ export function groupsFromBootstrap(boot: {
47
+ canvasGroups?: unknown;
48
+ docs?: { path: string | null; retired?: boolean }[];
49
+ dirs?: string[];
50
+ }): string[] {
51
+ const out = new Set<string>();
52
+ const add = (g: unknown) => {
53
+ if (typeof g === 'string' && GROUP.test(g)) out.add(g);
54
+ };
55
+ if (Array.isArray(boot.canvasGroups)) {
56
+ for (const g of boot.canvasGroups)
57
+ add(typeof g === 'string' ? g : (g as { path?: unknown })?.path);
58
+ }
59
+ for (const d of boot.docs ?? []) {
60
+ if (d.retired || !d.path) continue;
61
+ const first = d.path.split('/')[0];
62
+ if (d.path.includes('/')) add(first);
63
+ }
64
+ for (const dir of boot.dirs ?? []) add(dir.split('/')[0]);
65
+ if (out.size === 0) {
66
+ out.add('system');
67
+ out.add('ui');
68
+ }
69
+ return [...out].slice(0, 32);
70
+ }
71
+
72
+ async function bootstrapOf(url: string, token: string): Promise<Record<string, unknown> | null> {
73
+ try {
74
+ const res = await fetch(`${url}/api/projects/current/v1/bootstrap`, {
75
+ headers: { authorization: `Bearer ${token}` },
76
+ signal: AbortSignal.timeout(10_000),
77
+ });
78
+ if (!res.ok) return null;
79
+ return (await res.json()) as Record<string, unknown>;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ export async function prepareManagedProject(
86
+ ctx: Context,
87
+ input: PrepareInput
88
+ ): Promise<PrepareResult> {
89
+ let serverUrl: string;
90
+ let projectId: string | null = null;
91
+ let role: string | null = null;
92
+ let name: string | null = null;
93
+
94
+ if (input.kind === 'cloud' || input.kind === 'handoff') {
95
+ const cloud = createCloudEndpoints(ctx);
96
+ const r =
97
+ input.kind === 'cloud'
98
+ ? await cloud.openManaged(input.projectId)
99
+ : await cloud.openManagedCode(input.code, input.claimedProject);
100
+ const j = r.json as {
101
+ ok?: boolean;
102
+ url?: string;
103
+ role?: string;
104
+ project?: string;
105
+ error?: string;
106
+ };
107
+ if (!j.ok || !j.url)
108
+ return { ok: false, error: j.error ?? 'The project could not be opened.', status: r.status };
109
+ serverUrl = j.url;
110
+ projectId = j.project ?? (input.kind === 'cloud' ? input.projectId : null);
111
+ role = j.role ?? null;
112
+ name = projectId;
113
+ } else if (input.kind === 'hub') {
114
+ const r = await signInToWorkspace({
115
+ url: input.url,
116
+ email: input.email,
117
+ password: input.password,
118
+ });
119
+ if (!r.json.ok) return { ok: false, error: r.json.error, status: r.status };
120
+ serverUrl = r.json.url;
121
+ role = r.json.user.role;
122
+ } else {
123
+ try {
124
+ serverUrl = normalizeUrl(input.url);
125
+ } catch {
126
+ return { ok: false, error: "That doesn't look like a workspace address.", status: 400 };
127
+ }
128
+ if (!getHubRecord(serverUrl)?.token) {
129
+ return { ok: false, error: 'Sign in to that workspace first.', status: 401 };
130
+ }
131
+ }
132
+
133
+ const token = getHubRecord(serverUrl)?.token;
134
+ if (!token)
135
+ return { ok: false, error: 'The sign-in could not be saved on this computer.', status: 500 };
136
+ const boot = await bootstrapOf(serverUrl, token);
137
+ if (boot && typeof boot.projectId === 'string') projectId ??= boot.projectId;
138
+ const host = new URL(serverUrl).host;
139
+ projectId ??= host;
140
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(projectId)) projectId = host.replace(/[^A-Za-z0-9._-]/g, '-');
141
+ return {
142
+ ok: true,
143
+ serverUrl,
144
+ projectId,
145
+ name: name ?? (projectId === 'local' ? host : projectId),
146
+ role,
147
+ canvasGroups: groupsFromBootstrap((boot ?? {}) as Parameters<typeof groupsFromBootstrap>[0]),
148
+ mode: boot && typeof boot.mode === 'string' ? boot.mode : null,
149
+ };
150
+ }