@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.
- 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 +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- 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 +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- 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 +35 -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/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 +1586 -1554
- 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/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -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 +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -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 +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -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/whats-new.json +101 -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 +47 -0
- package/plugins/flow/dependencies.json +3 -3
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
|
|
@@ -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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 }>(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
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
|
};
|
|
@@ -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
|
+
}
|