@1agh/maude 0.29.0 → 0.31.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 +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
package/apps/studio/http.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
9
|
-
import { dirname, join, posix, resolve, sep } from 'node:path';
|
|
9
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { probeAcpAvailability } from './acp/probe.ts';
|
|
12
|
+
import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
|
|
11
13
|
import type { Api } from './api.ts';
|
|
12
14
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
13
15
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
@@ -16,11 +18,16 @@ import type { AiActivity } from './collab/ai-activity.ts';
|
|
|
16
18
|
import type { Context } from './context.ts';
|
|
17
19
|
import { isFormat, isScope, runExport } from './exporters/index.ts';
|
|
18
20
|
import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
21
|
+
import { createGitEndpoints } from './git/endpoints.ts';
|
|
22
|
+
import { gitShowFile } from './git/service.ts';
|
|
23
|
+
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
19
24
|
import type { Inspect } from './inspect.ts';
|
|
20
25
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
21
26
|
import { DEV_SERVER_ROOT } from './paths.ts';
|
|
22
|
-
import { getRuntimeBundle, packageForSlug
|
|
27
|
+
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
28
|
+
import { linkHub } from './sync/hub-link.ts';
|
|
23
29
|
import { loadWhatsNew } from './whats-new.ts';
|
|
30
|
+
import { isLoopbackHost } from './ws.ts';
|
|
24
31
|
|
|
25
32
|
// Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
|
|
26
33
|
// See paths.ts for the resolution logic + Phase 19.1 / v0.18.1 rationale.
|
|
@@ -81,10 +88,11 @@ function ext(p: string): string {
|
|
|
81
88
|
*
|
|
82
89
|
* `frame-ancestors` (A6) — restricts who may embed the canvas document. The
|
|
83
90
|
* legit embedder is the main dev-server origin, so we allowlist exactly that
|
|
84
|
-
* (`mainOrigin`
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
91
|
+
* (`mainOrigin` — a space-separated source list; server.ts advertises both
|
|
92
|
+
* loopback spellings, `localhost` + `127.0.0.1`) plus `'self'`; an arbitrary
|
|
93
|
+
* external page can no longer reframe the canvas. When `mainOrigin` is unknown
|
|
94
|
+
* (tests / pre-boot) the directive is OMITTED rather than set to `'self'` —
|
|
95
|
+
* `'self'` alone would forbid the legit cross-origin embed and blank the canvas.
|
|
88
96
|
*/
|
|
89
97
|
export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
90
98
|
const hashes: string[] = [];
|
|
@@ -115,6 +123,34 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
|
115
123
|
return directives.join('; ');
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/**
|
|
127
|
+
* CSRF guard for the main-origin source-write routes (edit-css / edit-text /
|
|
128
|
+
* edit-attr). Those routes are reachable only from the shell, which is
|
|
129
|
+
* same-origin — but `readJson` enforces no `Content-Type`, so a cross-site page
|
|
130
|
+
* could otherwise forge a `text/plain` CORS *simple-request* POST to
|
|
131
|
+
* `http://localhost:<port>/_api/edit-*` (no preflight) and drive a write into
|
|
132
|
+
* the user's source `.tsx`. The browser stamps every cross-origin POST with an
|
|
133
|
+
* unspoofable `Origin` header, so we reject any request whose Origin is PRESENT
|
|
134
|
+
* and ≠ the server's own origin. A request with NO Origin (bun:test, curl,
|
|
135
|
+
* non-browser programmatic clients — none of which are the CSRF threat, which
|
|
136
|
+
* requires a browser executing attacker markup that always sends Origin on a
|
|
137
|
+
* cross-origin POST) is allowed through. This is layered on top of the DDR-054
|
|
138
|
+
* origin-split (which blocks the untrusted canvas *iframe*); it closes the
|
|
139
|
+
* *other* untrusted origin — a malicious top-level page in another tab.
|
|
140
|
+
* Deliberately NOT applied to `/_api/asset` (that route is canvas-origin
|
|
141
|
+
* reachable by design — drag-drop/paste upload runs inside the iframe). See
|
|
142
|
+
* DDR-105. Exported for unit testing (the decision is a pure function of `req`).
|
|
143
|
+
*/
|
|
144
|
+
export function sameOriginWrite(req: Request): boolean {
|
|
145
|
+
const origin = req.headers.get('origin');
|
|
146
|
+
if (!origin) return true; // non-browser / same-origin omitting Origin → allow
|
|
147
|
+
try {
|
|
148
|
+
return origin === new URL(req.url).origin;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
|
|
119
155
|
let pathname: string;
|
|
120
156
|
try {
|
|
@@ -217,6 +253,15 @@ async function serveCanvasTsx(
|
|
|
217
253
|
ctx: Context,
|
|
218
254
|
locatorAbsPath: string
|
|
219
255
|
): Promise<Response> {
|
|
256
|
+
// Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
|
|
257
|
+
// its source AT a past version (git show) instead of the working-tree file, so
|
|
258
|
+
// the rendered before/after is real. Isolated additive branch — the normal
|
|
259
|
+
// (no-sha) serve below is byte-identical to before. The historical build still
|
|
260
|
+
// resolves sibling imports against the CURRENT on-disk files (an accepted
|
|
261
|
+
// approximation: the canvas code at the sha, with today's DS/lib).
|
|
262
|
+
const shaParam = new URL(req.url).searchParams.get('sha');
|
|
263
|
+
if (shaParam) return serveHistoricalCanvas(absPath, shaParam, req, ctx);
|
|
264
|
+
|
|
220
265
|
const file = Bun.file(absPath);
|
|
221
266
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
222
267
|
|
|
@@ -284,6 +329,101 @@ async function serveCanvasTsx(
|
|
|
284
329
|
});
|
|
285
330
|
}
|
|
286
331
|
|
|
332
|
+
// Phase 27 (E2) — build + serve a canvas at a past git ref. Immutable per
|
|
333
|
+
// (path, sha) — historical content never changes, so the cache lives for the
|
|
334
|
+
// process (folding the boot id + chrome epoch into the etag so a chrome change
|
|
335
|
+
// still busts the browser copy, since the build inlines today's chrome).
|
|
336
|
+
// LRU-capped so a flood of distinct ?sha values (each minting a permanent entry)
|
|
337
|
+
// can't grow the heap unbounded — the route is reachable from the UNTRUSTED
|
|
338
|
+
// canvas origin (it's on the canvas-serve path), so a hub-pushed canvas could
|
|
339
|
+
// otherwise OOM the sidecar (security review — ethical-hacker, Finding 1).
|
|
340
|
+
const historicalCanvasCache = new Map<string, { js: string; etag: string }>();
|
|
341
|
+
const HIST_MAX_CACHE = 96;
|
|
342
|
+
// Rate-limit the EXPENSIVE miss path (git show + Bun.build, or a non-resolving
|
|
343
|
+
// git lookup) so a distinct-sha spray from the canvas origin can't starve the
|
|
344
|
+
// event loop. Cache HITS are free; only a NEW (path,sha) build consumes budget.
|
|
345
|
+
// Legit use (DiffView opens ~1–2 historical iframes per compare) is far under.
|
|
346
|
+
//
|
|
347
|
+
// TWO LOAD-BEARING INVARIANTS (security re-review — do not "optimize" away):
|
|
348
|
+
// 1. The limiter is DELIBERATELY GLOBAL, not per-origin/per-key — the attacker
|
|
349
|
+
// controls the canvas (hence any origin/key), so a keyed limiter is
|
|
350
|
+
// trivially defeated. The cost (a legit user 429s during an active flood) is
|
|
351
|
+
// accepted; it only bites under attack.
|
|
352
|
+
// 2. `historicalBuildAllowed()` MUST stay ABOVE `gitShowFile` so the
|
|
353
|
+
// non-resolving-sha spray (cheap git lookup, no build) is capped too —
|
|
354
|
+
// moving it below would re-open the CPU vector. Guarded by the
|
|
355
|
+
// "rate-limited — DoS guard" test in test/git-api.test.ts.
|
|
356
|
+
const HIST_WINDOW_MS = 10_000;
|
|
357
|
+
const HIST_MAX_BUILDS = 24;
|
|
358
|
+
let histWindowStart = Date.now();
|
|
359
|
+
let histBuilds = 0;
|
|
360
|
+
function historicalBuildAllowed(): boolean {
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
if (now - histWindowStart >= HIST_WINDOW_MS) {
|
|
363
|
+
histWindowStart = now;
|
|
364
|
+
histBuilds = 0;
|
|
365
|
+
}
|
|
366
|
+
if (histBuilds >= HIST_MAX_BUILDS) return false;
|
|
367
|
+
histBuilds++;
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
async function serveHistoricalCanvas(
|
|
371
|
+
absPath: string,
|
|
372
|
+
sha: string,
|
|
373
|
+
req: Request,
|
|
374
|
+
ctx: Context
|
|
375
|
+
): Promise<Response> {
|
|
376
|
+
const repoRel = relative(ctx.paths.repoRoot, absPath).replace(/\\/g, '/');
|
|
377
|
+
const key = `${absPath}\0${sha}\0${RUNTIME_BOOT_ID}\0${CHROME_EPOCH}`;
|
|
378
|
+
let cached = historicalCanvasCache.get(key);
|
|
379
|
+
if (cached) {
|
|
380
|
+
// LRU touch — re-insert so the hot entry isn't the next eviction victim.
|
|
381
|
+
historicalCanvasCache.delete(key);
|
|
382
|
+
historicalCanvasCache.set(key, cached);
|
|
383
|
+
} else {
|
|
384
|
+
if (!historicalBuildAllowed()) {
|
|
385
|
+
return new Response('Too many version previews — try again in a moment.', {
|
|
386
|
+
status: 429,
|
|
387
|
+
headers: { 'Retry-After': '10', 'Cache-Control': 'no-store' },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
|
|
391
|
+
if (source == null) return new Response('No saved version of this canvas', { status: 404 });
|
|
392
|
+
try {
|
|
393
|
+
const result = await buildCanvasModule(absPath, source, { designRoot: ctx.paths.designRoot });
|
|
394
|
+
cached = {
|
|
395
|
+
js: result.js,
|
|
396
|
+
etag: `${result.etag}-${sha}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
397
|
+
};
|
|
398
|
+
historicalCanvasCache.set(key, cached);
|
|
399
|
+
if (historicalCanvasCache.size > HIST_MAX_CACHE) {
|
|
400
|
+
const oldest = historicalCanvasCache.keys().next().value;
|
|
401
|
+
if (oldest !== undefined) historicalCanvasCache.delete(oldest);
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
405
|
+
return new Response(`Canvas build error: ${msg}`, {
|
|
406
|
+
status: 500,
|
|
407
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (req.headers.get('if-none-match') === cached.etag) {
|
|
412
|
+
return new Response(null, {
|
|
413
|
+
status: 304,
|
|
414
|
+
headers: { ETag: cached.etag, 'Cache-Control': 'no-cache' },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return new Response(cached.js, {
|
|
418
|
+
status: 200,
|
|
419
|
+
headers: {
|
|
420
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
421
|
+
ETag: cached.etag,
|
|
422
|
+
'Cache-Control': 'no-cache',
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
287
427
|
async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
288
428
|
const file = Bun.file(absPath);
|
|
289
429
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
@@ -398,6 +538,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
398
538
|
}
|
|
399
539
|
}
|
|
400
540
|
|
|
541
|
+
// Phase 27 (E2) — `/_api/git/*` orchestration. MAIN-ORIGIN ONLY: every git
|
|
542
|
+
// route is intentionally absent from CANVAS_SAFE_API + startCanvasServer's
|
|
543
|
+
// `routes` map (the dual-allowlist rule), so the untrusted canvas iframe origin
|
|
544
|
+
// can never reach status/commit/publish/get-latest. http.ts owns the gating;
|
|
545
|
+
// git/endpoints.ts owns the orchestration.
|
|
546
|
+
const gitApi = createGitEndpoints(ctx);
|
|
547
|
+
// Phase 28 (E3) — `/_api/github/*`. Same dual-allowlist rule: main-origin only,
|
|
548
|
+
// and every route is token-bearing (server-held keychain token via the loopback
|
|
549
|
+
// bridge), so all four also carry a loopback-Host check.
|
|
550
|
+
const githubApi = createGitHubEndpoints(ctx);
|
|
551
|
+
const gitJson = (r: { status: number; json: unknown }) =>
|
|
552
|
+
Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
|
|
553
|
+
|
|
401
554
|
const routes = {
|
|
402
555
|
'/_health': () =>
|
|
403
556
|
Response.json({
|
|
@@ -409,6 +562,47 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
409
562
|
|
|
410
563
|
'/_active': () => Response.json(inspect.state),
|
|
411
564
|
|
|
565
|
+
// Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
|
|
566
|
+
// (is the adapter present + is `claude` on PATH); no subprocess spawned.
|
|
567
|
+
// MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
568
|
+
// so the untrusted canvas iframe is 403'd. The native shell reads this to
|
|
569
|
+
// decide between the enabled panel and the not-connected explainer.
|
|
570
|
+
'/_api/acp/status': () =>
|
|
571
|
+
Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
|
|
572
|
+
|
|
573
|
+
// Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
|
|
574
|
+
// POSTs here; we emit a bus event the shell turns into "open the Assistant
|
|
575
|
+
// panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
|
|
576
|
+
'/_api/acp/focus': (req: Request) => {
|
|
577
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
578
|
+
// CSRF parity with the other POST routes — the loopback `maude design
|
|
579
|
+
// chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
|
|
580
|
+
if (!sameOriginWrite(req))
|
|
581
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
582
|
+
ctx.bus.emit('acp-focus', {});
|
|
583
|
+
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// Phase 31 — repo-level chat list + history (for the chat switcher +
|
|
587
|
+
// hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
|
|
588
|
+
'/_api/acp/chats': () =>
|
|
589
|
+
Response.json(listChats(ctx.paths.designRoot), {
|
|
590
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
591
|
+
}),
|
|
592
|
+
'/_api/acp/chat': (req: Request) => {
|
|
593
|
+
const id = (new URL(req.url).searchParams.get('id') ?? '')
|
|
594
|
+
.replace(/[^a-z0-9_-]/gi, '')
|
|
595
|
+
.slice(0, 64);
|
|
596
|
+
if (req.method === 'DELETE') {
|
|
597
|
+
const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
|
|
598
|
+
return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
|
|
599
|
+
}
|
|
600
|
+
if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
|
|
601
|
+
return Response.json(readChatMessages(ctx.paths.designRoot, id), {
|
|
602
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
|
|
412
606
|
// Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
|
|
413
607
|
// runtime writes `_sync.json`; browser tabs also get live pushes over the
|
|
414
608
|
// WS ('sync:status'). Returns `{ linked: false }` in solo mode.
|
|
@@ -521,6 +715,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
521
715
|
// Claude work) POSTs here when work begins. body = { file, author }.
|
|
522
716
|
// Replaces any prior entry for the file.
|
|
523
717
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
718
|
+
// CSRF guard: phase-30 bridges ai-activity onto room awareness, which
|
|
719
|
+
// crosses the hub — a forged cross-origin POST would inject a fake
|
|
720
|
+
// "<x> is editing <file>" presence to every connected peer. The loopback
|
|
721
|
+
// slash-command driver omits Origin (→ allowed); a browser drive-by can't.
|
|
722
|
+
if (!sameOriginWrite(req))
|
|
723
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
524
724
|
const body = await readJson<{ file?: string; author?: string }>(req);
|
|
525
725
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
526
726
|
return new Response('body.file required', { status: 400 });
|
|
@@ -537,6 +737,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
537
737
|
// Refresh the lastHeartbeat. Returns 404 if no entry — slash command
|
|
538
738
|
// can treat that as "the server bounced; re-issue /start".
|
|
539
739
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
740
|
+
if (!sameOriginWrite(req))
|
|
741
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
540
742
|
const body = await readJson<{ file?: string }>(req);
|
|
541
743
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
542
744
|
return new Response('body.file required', { status: 400 });
|
|
@@ -549,6 +751,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
549
751
|
'/_api/ai/end': async (req: Request) => {
|
|
550
752
|
// Explicit completion (normal or error). Banner clears immediately.
|
|
551
753
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
754
|
+
if (!sameOriginWrite(req))
|
|
755
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
552
756
|
const body = await readJson<{ file?: string }>(req);
|
|
553
757
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
554
758
|
return new Response('body.file required', { status: 400 });
|
|
@@ -639,6 +843,304 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
639
843
|
);
|
|
640
844
|
},
|
|
641
845
|
|
|
846
|
+
// ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
|
|
847
|
+
// History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
|
|
848
|
+
// POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
|
|
849
|
+
// the token-bearing publish/get-latest routes add a loopback Host check so a
|
|
850
|
+
// request carrying a GitHub token can only originate on this machine.
|
|
851
|
+
'/_api/git/status': async (req: Request) => {
|
|
852
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
853
|
+
// Local status only — fast, no network, no credentials. The remote
|
|
854
|
+
// ahead/behind probe (the "Get latest" nudge) needs a token, which a GET
|
|
855
|
+
// query string must NOT carry (it leaks via logs / Referer / history —
|
|
856
|
+
// security review A3). That probe lands in phase-28 over the server-held
|
|
857
|
+
// keychain token (server-side, never client-supplied), not here.
|
|
858
|
+
const checkRemote = new URL(req.url).searchParams.get('remote') === '1';
|
|
859
|
+
return gitJson(await gitApi.status({ checkRemote }));
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
'/_api/git/log': async (req: Request) => {
|
|
863
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
864
|
+
// Optional `?path=` scopes History to one canvas (phase-27.1). MAIN-ORIGIN
|
|
865
|
+
// ONLY (this route is absent from CANVAS_SAFE_API) — the path is
|
|
866
|
+
// containment-validated in the endpoint before it reaches git.
|
|
867
|
+
const u = new URL(req.url).searchParams;
|
|
868
|
+
return gitJson(await gitApi.log(u.get('limit'), u.get('path')));
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
'/_api/git/diff': async (req: Request) => {
|
|
872
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
873
|
+
return gitJson(await gitApi.diff(new URL(req.url).searchParams.get('sha')));
|
|
874
|
+
},
|
|
875
|
+
|
|
876
|
+
// ── Phase 29 (E4) — drafts (branches). MAIN-ORIGIN ONLY (absent from
|
|
877
|
+
// CANVAS_SAFE_API + startCanvasServer routes); branch + checkout are POST/CSRF-
|
|
878
|
+
// gated source mutations. Switching a draft moves HEAD, which the git-lifecycle
|
|
879
|
+
// watcher turns into a Yjs flush + reload (DDR-051) — no logic duplicated here.
|
|
880
|
+
'/_api/git/branches': async (req: Request) => {
|
|
881
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
882
|
+
return gitJson(await gitApi.branches());
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
'/_api/git/branch': async (req: Request) => {
|
|
886
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
887
|
+
if (!sameOriginWrite(req))
|
|
888
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
889
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
890
|
+
return gitJson(await gitApi.createBranch(body));
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
'/_api/git/checkout': async (req: Request) => {
|
|
894
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
895
|
+
if (!sameOriginWrite(req))
|
|
896
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
897
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
898
|
+
return gitJson(await gitApi.checkout(body));
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
// "Add this draft to the Shared version" — merges + PUBLISHES, so it is token-
|
|
902
|
+
// bearing: same main-origin + loopback gate as /_api/git/push.
|
|
903
|
+
'/_api/git/fold': async (req: Request) => {
|
|
904
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
905
|
+
if (!sameOriginWrite(req))
|
|
906
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
907
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
908
|
+
return new Response('adding a draft requires a local request', { status: 403 });
|
|
909
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
910
|
+
return gitJson(await gitApi.fold(body));
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
'/_api/git/commit': async (req: Request) => {
|
|
914
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
915
|
+
if (!sameOriginWrite(req))
|
|
916
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
917
|
+
const body = await readJson<unknown>(req, 256 * 1024);
|
|
918
|
+
return gitJson(await gitApi.commit(body));
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
'/_api/git/discard': async (req: Request) => {
|
|
922
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
923
|
+
if (!sameOriginWrite(req))
|
|
924
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
925
|
+
const body = await readJson<unknown>(req, 256 * 1024);
|
|
926
|
+
return gitJson(await gitApi.discard(body));
|
|
927
|
+
},
|
|
928
|
+
|
|
929
|
+
'/_api/git/push': async (req: Request) => {
|
|
930
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
931
|
+
if (!sameOriginWrite(req))
|
|
932
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
933
|
+
// Token-bearing: refuse anything not from a loopback Host (the server binds
|
|
934
|
+
// 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
|
|
935
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
936
|
+
return new Response('publish requires a local request', { status: 403 });
|
|
937
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
938
|
+
return gitJson(await gitApi.push(body));
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
'/_api/git/pull': async (req: Request) => {
|
|
942
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
943
|
+
if (!sameOriginWrite(req))
|
|
944
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
945
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
946
|
+
return new Response('get latest requires a local request', { status: 403 });
|
|
947
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
948
|
+
return gitJson(await gitApi.pull(body));
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
// Finish a Get-latest merge that hit a conflict (DiffView "Keep mine/theirs/
|
|
952
|
+
// both"). Token-bearing (server-held; resolve completes the two-parent merge
|
|
953
|
+
// commit) → same main-origin + loopback gate as pull. MAIN-ORIGIN ONLY:
|
|
954
|
+
// absent from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist).
|
|
955
|
+
'/_api/git/resolve': async (req: Request) => {
|
|
956
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
957
|
+
if (!sameOriginWrite(req))
|
|
958
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
959
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
960
|
+
return new Response('resolve requires a local request', { status: 403 });
|
|
961
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
962
|
+
return gitJson(await gitApi.resolve(body));
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
// ── Phase 28 (E3) — GitHub identity & remote. Sign-in/out + keychain live in
|
|
966
|
+
// the Tauri shell (oauth.rs/keychain.rs commands); these endpoints use the
|
|
967
|
+
// server-held token (loopback bridge → token.ts) for the REST calls. MAIN-ORIGIN
|
|
968
|
+
// ONLY (absent from CANVAS_SAFE_API + startCanvasServer) and loopback-Host gated
|
|
969
|
+
// since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
|
|
970
|
+
// command — the dev-server can't touch the OS keychain — so there is no
|
|
971
|
+
// DELETE /_api/github/identity here; see DDR-114.)
|
|
972
|
+
'/_api/github/identity': async (req: Request) => {
|
|
973
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
974
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
975
|
+
return new Response('local request required', { status: 403 });
|
|
976
|
+
return gitJson(await githubApi.identity());
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
'/_api/github/repos': async (req: Request) => {
|
|
980
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
981
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
982
|
+
return new Response('local request required', { status: 403 });
|
|
983
|
+
return gitJson(await githubApi.repos());
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
'/_api/github/create-repo': async (req: Request) => {
|
|
987
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
988
|
+
if (!sameOriginWrite(req))
|
|
989
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
990
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
991
|
+
return new Response('local request required', { status: 403 });
|
|
992
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
993
|
+
return gitJson(await githubApi.createRepo(body));
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
'/_api/github/invite': async (req: Request) => {
|
|
997
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
998
|
+
if (!sameOriginWrite(req))
|
|
999
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1000
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1001
|
+
return new Response('local request required', { status: 403 });
|
|
1002
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1003
|
+
return gitJson(await githubApi.invite(body));
|
|
1004
|
+
},
|
|
1005
|
+
|
|
1006
|
+
'/_api/github/clone': async (req: Request) => {
|
|
1007
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1008
|
+
if (!sameOriginWrite(req))
|
|
1009
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1010
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1011
|
+
return new Response('local request required', { status: 403 });
|
|
1012
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1013
|
+
return gitJson(await githubApi.clone(body));
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
'/_api/github/create-project': async (req: Request) => {
|
|
1017
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1018
|
+
if (!sameOriginWrite(req))
|
|
1019
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1020
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1021
|
+
return new Response('local request required', { status: 403 });
|
|
1022
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1023
|
+
return gitJson(await githubApi.createProject(body));
|
|
1024
|
+
},
|
|
1025
|
+
|
|
1026
|
+
// Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
|
|
1027
|
+
// repo → set it up?" fallback). No token / no GitHub — local FS only, but still
|
|
1028
|
+
// main-origin + loopback gated (it writes to disk).
|
|
1029
|
+
'/_api/design/init': async (req: Request) => {
|
|
1030
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1031
|
+
if (!sameOriginWrite(req))
|
|
1032
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1033
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1034
|
+
return new Response('local request required', { status: 403 });
|
|
1035
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1036
|
+
return gitJson(await githubApi.initDesign(body));
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
// Phase 29 (E4) Door C — connect to a team hub: validate + probe + save the hub
|
|
1040
|
+
// credential to the global ~/.config/maude/hubs.json (sync/hub-link.ts). MAIN
|
|
1041
|
+
// ORIGIN ONLY (omitted from CANVAS_SAFE_API + startCanvasServer routes) + loopback
|
|
1042
|
+
// + POST CSRF, mirroring /_api/github/*. The lean in-app counterpart to the CLI
|
|
1043
|
+
// `maude design link`; the in-UI Connect is the explicit trust grant (DDR-054 F2).
|
|
1044
|
+
'/_api/hub/link': async (req: Request) => {
|
|
1045
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1046
|
+
if (!sameOriginWrite(req))
|
|
1047
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1048
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1049
|
+
return new Response('local request required', { status: 403 });
|
|
1050
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1051
|
+
return gitJson(await linkHub(body));
|
|
1052
|
+
},
|
|
1053
|
+
|
|
1054
|
+
'/_api/edit-css': async (req: Request) => {
|
|
1055
|
+
// Phase 12 (DDR-103) — single-property inline CSS edit. POST body
|
|
1056
|
+
// { canvas, id, property, value } → writes one key into the element's
|
|
1057
|
+
// inline `style={{}}` object via api.editCss → editAttribute. MAIN ORIGIN
|
|
1058
|
+
// ONLY: intentionally absent from CANVAS_SAFE_API + startCanvasServer's
|
|
1059
|
+
// route allowlist (DDR-054) — the untrusted canvas iframe origin must never
|
|
1060
|
+
// reach a source-write endpoint. The CSS-knob UI lives in the shell (main
|
|
1061
|
+
// origin) and calls it directly.
|
|
1062
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1063
|
+
if (!sameOriginWrite(req))
|
|
1064
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1065
|
+
const body = await readJson<{
|
|
1066
|
+
canvas?: unknown;
|
|
1067
|
+
id?: unknown;
|
|
1068
|
+
property?: unknown;
|
|
1069
|
+
value?: unknown;
|
|
1070
|
+
reset?: unknown;
|
|
1071
|
+
}>(req, 8 * 1024);
|
|
1072
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1073
|
+
const result = await api.editCss(body);
|
|
1074
|
+
if (!result.ok) {
|
|
1075
|
+
return Response.json(
|
|
1076
|
+
{ ok: false, error: result.error },
|
|
1077
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
return Response.json(
|
|
1081
|
+
{ ok: true, delta: result.delta },
|
|
1082
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1083
|
+
);
|
|
1084
|
+
},
|
|
1085
|
+
|
|
1086
|
+
'/_api/edit-text': async (req: Request) => {
|
|
1087
|
+
// Phase 12 (DDR-103) — inline element text-content edit. POST body
|
|
1088
|
+
// { canvas, id, text } → overwrites the element's single JSXText child
|
|
1089
|
+
// (JSX-escaped) via api.editText → editText. Same MAIN-ORIGIN-ONLY trust
|
|
1090
|
+
// boundary as /_api/edit-css + /_api/canvas. The inline contenteditable
|
|
1091
|
+
// editor reaches it via the dgn:* bus → shell relay (never the iframe).
|
|
1092
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1093
|
+
if (!sameOriginWrite(req))
|
|
1094
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1095
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
|
|
1096
|
+
req,
|
|
1097
|
+
16 * 1024
|
|
1098
|
+
);
|
|
1099
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1100
|
+
const result = await api.editText(body);
|
|
1101
|
+
if (!result.ok) {
|
|
1102
|
+
return Response.json(
|
|
1103
|
+
{ ok: false, error: result.error },
|
|
1104
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
return Response.json(
|
|
1108
|
+
{ ok: true, delta: result.delta },
|
|
1109
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1110
|
+
);
|
|
1111
|
+
},
|
|
1112
|
+
|
|
1113
|
+
'/_api/edit-attr': async (req: Request) => {
|
|
1114
|
+
// Phase 12.2 (DDR-104) — the CSS panel's "custom HTML attribute" escape
|
|
1115
|
+
// hatch. POST body { canvas, id, attr, value } → writes a plain JSX
|
|
1116
|
+
// attribute (data-*, aria-*, role, …) via api.editAttr → editAttribute's
|
|
1117
|
+
// non-`style.` path. Same MAIN-ORIGIN-ONLY trust boundary as
|
|
1118
|
+
// /_api/edit-css + /_api/edit-text (absent from CANVAS_SAFE_API +
|
|
1119
|
+
// startCanvasServer's route map; the untrusted iframe can never reach it).
|
|
1120
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1121
|
+
if (!sameOriginWrite(req))
|
|
1122
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1123
|
+
const body = await readJson<{
|
|
1124
|
+
canvas?: unknown;
|
|
1125
|
+
id?: unknown;
|
|
1126
|
+
attr?: unknown;
|
|
1127
|
+
value?: unknown;
|
|
1128
|
+
reset?: unknown;
|
|
1129
|
+
}>(req, 8 * 1024);
|
|
1130
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1131
|
+
const result = await api.editAttr(body);
|
|
1132
|
+
if (!result.ok) {
|
|
1133
|
+
return Response.json(
|
|
1134
|
+
{ ok: false, error: result.error },
|
|
1135
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
return Response.json(
|
|
1139
|
+
{ ok: true, delta: result.delta },
|
|
1140
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1141
|
+
);
|
|
1142
|
+
},
|
|
1143
|
+
|
|
642
1144
|
'/_api/asset': async (req: Request) => {
|
|
643
1145
|
// Phase 23 — binary image upload from the canvas (drag-drop / paste / the
|
|
644
1146
|
// a11y file picker). POST raw image bytes → content-addressed write under
|
|
@@ -82,6 +82,8 @@ export type Tool =
|
|
|
82
82
|
| 'arrow'
|
|
83
83
|
| 'sticky'
|
|
84
84
|
| 'text'
|
|
85
|
+
// FigJam v3 — labelled organizing container (Shift+S).
|
|
86
|
+
| 'section'
|
|
85
87
|
| 'eraser';
|
|
86
88
|
|
|
87
89
|
const ANNOTATION_TOOLS = new Set<Tool>([
|
|
@@ -93,6 +95,7 @@ const ANNOTATION_TOOLS = new Set<Tool>([
|
|
|
93
95
|
'arrow',
|
|
94
96
|
'sticky',
|
|
95
97
|
'text',
|
|
98
|
+
'section',
|
|
96
99
|
'eraser',
|
|
97
100
|
]);
|
|
98
101
|
|
|
@@ -183,6 +186,10 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
183
186
|
// yields when focus is inside the canvas iframe (app.jsx onKey bail).
|
|
184
187
|
if (k === 'n') return { kind: 'tool', tool: 'sticky' };
|
|
185
188
|
if (k === 't') return { kind: 'tool', tool: 'text' };
|
|
189
|
+
// FigJam v3 — Shift+S arms the Section tool (FigJam's own binding; bare S
|
|
190
|
+
// stays with the shell's Design-system view). Checked here because the
|
|
191
|
+
// modifier guard above only filters Cmd/Ctrl/Alt.
|
|
192
|
+
if (k === 's' && input.shiftKey) return { kind: 'tool', tool: 'section' };
|
|
186
193
|
if (k === 'e') return { kind: 'tool', tool: 'eraser' };
|
|
187
194
|
if (input.key === 'Escape') return { kind: 'escape' };
|
|
188
195
|
return { kind: 'no-op' };
|