@1agh/maude 0.30.0 → 0.32.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 +5 -5
- 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/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +788 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +796 -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 +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -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 +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -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 +11 -1
- 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/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- 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/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- 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 +95 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- 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 +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -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/readiness.test.ts +127 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +82 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- 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 +30 -8
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// github/token.ts — fetch the GitHub access token from the Tauri shell's
|
|
2
|
+
// loopback bridge at request time (Phase 28 / DDR-108).
|
|
3
|
+
//
|
|
4
|
+
// The token lives ONLY in the OS keychain, owned by the Tauri shell. The dev-server
|
|
5
|
+
// is a separate process and can't read the keychain, so it fetches the token over
|
|
6
|
+
// loopback (keychain.rs → GET /_tauri/github-token, 127.0.0.1-only, per-launch key).
|
|
7
|
+
// The endpoint + key arrive as env from the shell at spawn (sidecar.rs):
|
|
8
|
+
// MAUDE_TOKEN_ENDPOINT=http://127.0.0.1:<port>/_tauri/github-token
|
|
9
|
+
// MAUDE_TOKEN_KEY=<per-launch hex>
|
|
10
|
+
//
|
|
11
|
+
// In non-Tauri mode (`maude design serve` in a browser) those envs are absent →
|
|
12
|
+
// this returns null → the /_api/github/* endpoints degrade to "sign in via the
|
|
13
|
+
// desktop app". The token is never logged, persisted, or returned to the canvas.
|
|
14
|
+
|
|
15
|
+
let warnedNoBridge = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the current GitHub token from the loopback bridge, or null when not
|
|
19
|
+
* running under the Tauri shell / not signed in. NEVER cache the token (it can be
|
|
20
|
+
* revoked / rotated; the keychain is the single source of truth).
|
|
21
|
+
*/
|
|
22
|
+
export async function getGithubToken(): Promise<string | null> {
|
|
23
|
+
const endpoint = process.env.MAUDE_TOKEN_ENDPOINT;
|
|
24
|
+
const key = process.env.MAUDE_TOKEN_KEY;
|
|
25
|
+
if (!endpoint || !key) {
|
|
26
|
+
if (!warnedNoBridge) {
|
|
27
|
+
warnedNoBridge = true;
|
|
28
|
+
// One-time, non-secret: explains why github endpoints 401 outside the app.
|
|
29
|
+
console.error(
|
|
30
|
+
'[maude:github] no token bridge (not the desktop app) — GitHub features disabled'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(endpoint, {
|
|
37
|
+
headers: { 'X-Maude-Token-Key': key },
|
|
38
|
+
// The bridge is local + instant; don't hang a request on it.
|
|
39
|
+
signal: AbortSignal.timeout(3000),
|
|
40
|
+
});
|
|
41
|
+
if (res.status !== 200) return null; // 404 = not signed in; 403 = bad key
|
|
42
|
+
const token = (await res.text()).trim();
|
|
43
|
+
return token.length > 0 ? token : null;
|
|
44
|
+
} catch {
|
|
45
|
+
// Bridge unreachable (shell quit mid-request, etc.) — treat as signed out.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether the token bridge is configured at all (i.e. running under the shell). */
|
|
51
|
+
export function tokenBridgeAvailable(): boolean {
|
|
52
|
+
return Boolean(process.env.MAUDE_TOKEN_ENDPOINT && process.env.MAUDE_TOKEN_KEY);
|
|
53
|
+
}
|
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
//
|
|
7
7
|
// - `mode: "css"` → swap <link> href with a cache-bust query (no module
|
|
8
8
|
// reload, no React state loss). Sub-150ms latency.
|
|
9
|
-
// - `mode: "module"` →
|
|
10
|
-
//
|
|
9
|
+
// - `mode: "module"` → in-place hot-swap of the canvas module when the canvas
|
|
10
|
+
// runtime is mounted (re-import + remount only the canvas
|
|
11
|
+
// content; the iframe — and the live CollabProvider +
|
|
12
|
+
// presence — stay mounted, so a cross-peer synced edit
|
|
13
|
+
// updates seamlessly without a presence blink — Phase 30
|
|
14
|
+
// / F4). Falls back to location.reload() with no runtime
|
|
15
|
+
// (gallery thumbnails) or on a re-import with no usable
|
|
16
|
+
// default export. State (scroll / tool / undo) survives
|
|
17
|
+
// the hot-swap path.
|
|
11
18
|
// - `mode: "hard"` → location.reload() (used when canvas-lib.tsx or any
|
|
12
19
|
// `_lib/**` file changes — every open canvas needs to
|
|
13
20
|
// re-bundle).
|
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,17 @@ 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';
|
|
27
|
+
import { probeReadiness } from './readiness.ts';
|
|
22
28
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
29
|
+
import { linkHub } from './sync/hub-link.ts';
|
|
23
30
|
import { loadWhatsNew } from './whats-new.ts';
|
|
31
|
+
import { isLoopbackHost } from './ws.ts';
|
|
24
32
|
|
|
25
33
|
// Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
|
|
26
34
|
// See paths.ts for the resolution logic + Phase 19.1 / v0.18.1 rationale.
|
|
@@ -246,6 +254,15 @@ async function serveCanvasTsx(
|
|
|
246
254
|
ctx: Context,
|
|
247
255
|
locatorAbsPath: string
|
|
248
256
|
): Promise<Response> {
|
|
257
|
+
// Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
|
|
258
|
+
// its source AT a past version (git show) instead of the working-tree file, so
|
|
259
|
+
// the rendered before/after is real. Isolated additive branch — the normal
|
|
260
|
+
// (no-sha) serve below is byte-identical to before. The historical build still
|
|
261
|
+
// resolves sibling imports against the CURRENT on-disk files (an accepted
|
|
262
|
+
// approximation: the canvas code at the sha, with today's DS/lib).
|
|
263
|
+
const shaParam = new URL(req.url).searchParams.get('sha');
|
|
264
|
+
if (shaParam) return serveHistoricalCanvas(absPath, shaParam, req, ctx);
|
|
265
|
+
|
|
249
266
|
const file = Bun.file(absPath);
|
|
250
267
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
251
268
|
|
|
@@ -313,6 +330,101 @@ async function serveCanvasTsx(
|
|
|
313
330
|
});
|
|
314
331
|
}
|
|
315
332
|
|
|
333
|
+
// Phase 27 (E2) — build + serve a canvas at a past git ref. Immutable per
|
|
334
|
+
// (path, sha) — historical content never changes, so the cache lives for the
|
|
335
|
+
// process (folding the boot id + chrome epoch into the etag so a chrome change
|
|
336
|
+
// still busts the browser copy, since the build inlines today's chrome).
|
|
337
|
+
// LRU-capped so a flood of distinct ?sha values (each minting a permanent entry)
|
|
338
|
+
// can't grow the heap unbounded — the route is reachable from the UNTRUSTED
|
|
339
|
+
// canvas origin (it's on the canvas-serve path), so a hub-pushed canvas could
|
|
340
|
+
// otherwise OOM the sidecar (security review — ethical-hacker, Finding 1).
|
|
341
|
+
const historicalCanvasCache = new Map<string, { js: string; etag: string }>();
|
|
342
|
+
const HIST_MAX_CACHE = 96;
|
|
343
|
+
// Rate-limit the EXPENSIVE miss path (git show + Bun.build, or a non-resolving
|
|
344
|
+
// git lookup) so a distinct-sha spray from the canvas origin can't starve the
|
|
345
|
+
// event loop. Cache HITS are free; only a NEW (path,sha) build consumes budget.
|
|
346
|
+
// Legit use (DiffView opens ~1–2 historical iframes per compare) is far under.
|
|
347
|
+
//
|
|
348
|
+
// TWO LOAD-BEARING INVARIANTS (security re-review — do not "optimize" away):
|
|
349
|
+
// 1. The limiter is DELIBERATELY GLOBAL, not per-origin/per-key — the attacker
|
|
350
|
+
// controls the canvas (hence any origin/key), so a keyed limiter is
|
|
351
|
+
// trivially defeated. The cost (a legit user 429s during an active flood) is
|
|
352
|
+
// accepted; it only bites under attack.
|
|
353
|
+
// 2. `historicalBuildAllowed()` MUST stay ABOVE `gitShowFile` so the
|
|
354
|
+
// non-resolving-sha spray (cheap git lookup, no build) is capped too —
|
|
355
|
+
// moving it below would re-open the CPU vector. Guarded by the
|
|
356
|
+
// "rate-limited — DoS guard" test in test/git-api.test.ts.
|
|
357
|
+
const HIST_WINDOW_MS = 10_000;
|
|
358
|
+
const HIST_MAX_BUILDS = 24;
|
|
359
|
+
let histWindowStart = Date.now();
|
|
360
|
+
let histBuilds = 0;
|
|
361
|
+
function historicalBuildAllowed(): boolean {
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
if (now - histWindowStart >= HIST_WINDOW_MS) {
|
|
364
|
+
histWindowStart = now;
|
|
365
|
+
histBuilds = 0;
|
|
366
|
+
}
|
|
367
|
+
if (histBuilds >= HIST_MAX_BUILDS) return false;
|
|
368
|
+
histBuilds++;
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
async function serveHistoricalCanvas(
|
|
372
|
+
absPath: string,
|
|
373
|
+
sha: string,
|
|
374
|
+
req: Request,
|
|
375
|
+
ctx: Context
|
|
376
|
+
): Promise<Response> {
|
|
377
|
+
const repoRel = relative(ctx.paths.repoRoot, absPath).replace(/\\/g, '/');
|
|
378
|
+
const key = `${absPath}\0${sha}\0${RUNTIME_BOOT_ID}\0${CHROME_EPOCH}`;
|
|
379
|
+
let cached = historicalCanvasCache.get(key);
|
|
380
|
+
if (cached) {
|
|
381
|
+
// LRU touch — re-insert so the hot entry isn't the next eviction victim.
|
|
382
|
+
historicalCanvasCache.delete(key);
|
|
383
|
+
historicalCanvasCache.set(key, cached);
|
|
384
|
+
} else {
|
|
385
|
+
if (!historicalBuildAllowed()) {
|
|
386
|
+
return new Response('Too many version previews — try again in a moment.', {
|
|
387
|
+
status: 429,
|
|
388
|
+
headers: { 'Retry-After': '10', 'Cache-Control': 'no-store' },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
|
|
392
|
+
if (source == null) return new Response('No saved version of this canvas', { status: 404 });
|
|
393
|
+
try {
|
|
394
|
+
const result = await buildCanvasModule(absPath, source, { designRoot: ctx.paths.designRoot });
|
|
395
|
+
cached = {
|
|
396
|
+
js: result.js,
|
|
397
|
+
etag: `${result.etag}-${sha}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
398
|
+
};
|
|
399
|
+
historicalCanvasCache.set(key, cached);
|
|
400
|
+
if (historicalCanvasCache.size > HIST_MAX_CACHE) {
|
|
401
|
+
const oldest = historicalCanvasCache.keys().next().value;
|
|
402
|
+
if (oldest !== undefined) historicalCanvasCache.delete(oldest);
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
406
|
+
return new Response(`Canvas build error: ${msg}`, {
|
|
407
|
+
status: 500,
|
|
408
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (req.headers.get('if-none-match') === cached.etag) {
|
|
413
|
+
return new Response(null, {
|
|
414
|
+
status: 304,
|
|
415
|
+
headers: { ETag: cached.etag, 'Cache-Control': 'no-cache' },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return new Response(cached.js, {
|
|
419
|
+
status: 200,
|
|
420
|
+
headers: {
|
|
421
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
422
|
+
ETag: cached.etag,
|
|
423
|
+
'Cache-Control': 'no-cache',
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
316
428
|
async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
317
429
|
const file = Bun.file(absPath);
|
|
318
430
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
@@ -427,6 +539,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
427
539
|
}
|
|
428
540
|
}
|
|
429
541
|
|
|
542
|
+
// Phase 27 (E2) — `/_api/git/*` orchestration. MAIN-ORIGIN ONLY: every git
|
|
543
|
+
// route is intentionally absent from CANVAS_SAFE_API + startCanvasServer's
|
|
544
|
+
// `routes` map (the dual-allowlist rule), so the untrusted canvas iframe origin
|
|
545
|
+
// can never reach status/commit/publish/get-latest. http.ts owns the gating;
|
|
546
|
+
// git/endpoints.ts owns the orchestration.
|
|
547
|
+
const gitApi = createGitEndpoints(ctx);
|
|
548
|
+
// Phase 28 (E3) — `/_api/github/*`. Same dual-allowlist rule: main-origin only,
|
|
549
|
+
// and every route is token-bearing (server-held keychain token via the loopback
|
|
550
|
+
// bridge), so all four also carry a loopback-Host check.
|
|
551
|
+
const githubApi = createGitHubEndpoints(ctx);
|
|
552
|
+
const gitJson = (r: { status: number; json: unknown }) =>
|
|
553
|
+
Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
|
|
554
|
+
|
|
430
555
|
const routes = {
|
|
431
556
|
'/_health': () =>
|
|
432
557
|
Response.json({
|
|
@@ -438,6 +563,61 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
438
563
|
|
|
439
564
|
'/_active': () => Response.json(inspect.state),
|
|
440
565
|
|
|
566
|
+
// Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
|
|
567
|
+
// (is the adapter present + is `claude` on PATH); no subprocess spawned.
|
|
568
|
+
// MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
569
|
+
// so the untrusted canvas iframe is 403'd. The native shell reads this to
|
|
570
|
+
// decide between the enabled panel and the not-connected explainer.
|
|
571
|
+
'/_api/acp/status': () =>
|
|
572
|
+
Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
|
|
573
|
+
|
|
574
|
+
// DDR-128 — first-open AI-editing readiness. Read-only probe of the AI-editing
|
|
575
|
+
// dependency chain (claude CLI · maude CLI · maude marketplace + plugins in the
|
|
576
|
+
// paired Claude Code · optional agent-browser) with per-item remediation; it
|
|
577
|
+
// installs/mutates nothing. MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API +
|
|
578
|
+
// startCanvasServer routes, so the untrusted canvas iframe is 403'd. The probe
|
|
579
|
+
// can shell out (login-shell fallback), so a cross-origin Origin-reject also
|
|
580
|
+
// fronts it — a drive-by page can't fire the loopback endpoint to spawn-storm
|
|
581
|
+
// the dev-server (DDR-128 hardening). The onboarding readiness card + the
|
|
582
|
+
// ChatPanel not-connected explainer + the Help-menu modal read this.
|
|
583
|
+
'/_api/preflight': async (req: Request) => {
|
|
584
|
+
if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
585
|
+
return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
// Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
|
|
589
|
+
// POSTs here; we emit a bus event the shell turns into "open the Assistant
|
|
590
|
+
// panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
|
|
591
|
+
'/_api/acp/focus': (req: Request) => {
|
|
592
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
593
|
+
// CSRF parity with the other POST routes — the loopback `maude design
|
|
594
|
+
// chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
|
|
595
|
+
if (!sameOriginWrite(req))
|
|
596
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
597
|
+
ctx.bus.emit('acp-focus', {});
|
|
598
|
+
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
// Phase 31 — repo-level chat list + history (for the chat switcher +
|
|
602
|
+
// hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
|
|
603
|
+
'/_api/acp/chats': () =>
|
|
604
|
+
Response.json(listChats(ctx.paths.designRoot), {
|
|
605
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
606
|
+
}),
|
|
607
|
+
'/_api/acp/chat': (req: Request) => {
|
|
608
|
+
const id = (new URL(req.url).searchParams.get('id') ?? '')
|
|
609
|
+
.replace(/[^a-z0-9_-]/gi, '')
|
|
610
|
+
.slice(0, 64);
|
|
611
|
+
if (req.method === 'DELETE') {
|
|
612
|
+
const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
|
|
613
|
+
return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
|
|
614
|
+
}
|
|
615
|
+
if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
|
|
616
|
+
return Response.json(readChatMessages(ctx.paths.designRoot, id), {
|
|
617
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
618
|
+
});
|
|
619
|
+
},
|
|
620
|
+
|
|
441
621
|
// Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
|
|
442
622
|
// runtime writes `_sync.json`; browser tabs also get live pushes over the
|
|
443
623
|
// WS ('sync:status'). Returns `{ linked: false }` in solo mode.
|
|
@@ -550,6 +730,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
550
730
|
// Claude work) POSTs here when work begins. body = { file, author }.
|
|
551
731
|
// Replaces any prior entry for the file.
|
|
552
732
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
733
|
+
// CSRF guard: phase-30 bridges ai-activity onto room awareness, which
|
|
734
|
+
// crosses the hub — a forged cross-origin POST would inject a fake
|
|
735
|
+
// "<x> is editing <file>" presence to every connected peer. The loopback
|
|
736
|
+
// slash-command driver omits Origin (→ allowed); a browser drive-by can't.
|
|
737
|
+
if (!sameOriginWrite(req))
|
|
738
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
553
739
|
const body = await readJson<{ file?: string; author?: string }>(req);
|
|
554
740
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
555
741
|
return new Response('body.file required', { status: 400 });
|
|
@@ -566,6 +752,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
566
752
|
// Refresh the lastHeartbeat. Returns 404 if no entry — slash command
|
|
567
753
|
// can treat that as "the server bounced; re-issue /start".
|
|
568
754
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
755
|
+
if (!sameOriginWrite(req))
|
|
756
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
569
757
|
const body = await readJson<{ file?: string }>(req);
|
|
570
758
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
571
759
|
return new Response('body.file required', { status: 400 });
|
|
@@ -578,6 +766,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
578
766
|
'/_api/ai/end': async (req: Request) => {
|
|
579
767
|
// Explicit completion (normal or error). Banner clears immediately.
|
|
580
768
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
769
|
+
if (!sameOriginWrite(req))
|
|
770
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
581
771
|
const body = await readJson<{ file?: string }>(req);
|
|
582
772
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
583
773
|
return new Response('body.file required', { status: 400 });
|
|
@@ -668,6 +858,214 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
668
858
|
);
|
|
669
859
|
},
|
|
670
860
|
|
|
861
|
+
// ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
|
|
862
|
+
// History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
|
|
863
|
+
// POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
|
|
864
|
+
// the token-bearing publish/get-latest routes add a loopback Host check so a
|
|
865
|
+
// request carrying a GitHub token can only originate on this machine.
|
|
866
|
+
'/_api/git/status': async (req: Request) => {
|
|
867
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
868
|
+
// Local status only — fast, no network, no credentials. The remote
|
|
869
|
+
// ahead/behind probe (the "Get latest" nudge) needs a token, which a GET
|
|
870
|
+
// query string must NOT carry (it leaks via logs / Referer / history —
|
|
871
|
+
// security review A3). That probe lands in phase-28 over the server-held
|
|
872
|
+
// keychain token (server-side, never client-supplied), not here.
|
|
873
|
+
const checkRemote = new URL(req.url).searchParams.get('remote') === '1';
|
|
874
|
+
return gitJson(await gitApi.status({ checkRemote }));
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
'/_api/git/log': async (req: Request) => {
|
|
878
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
879
|
+
// Optional `?path=` scopes History to one canvas (phase-27.1). MAIN-ORIGIN
|
|
880
|
+
// ONLY (this route is absent from CANVAS_SAFE_API) — the path is
|
|
881
|
+
// containment-validated in the endpoint before it reaches git.
|
|
882
|
+
const u = new URL(req.url).searchParams;
|
|
883
|
+
return gitJson(await gitApi.log(u.get('limit'), u.get('path')));
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
'/_api/git/diff': async (req: Request) => {
|
|
887
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
888
|
+
return gitJson(await gitApi.diff(new URL(req.url).searchParams.get('sha')));
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
// ── Phase 29 (E4) — drafts (branches). MAIN-ORIGIN ONLY (absent from
|
|
892
|
+
// CANVAS_SAFE_API + startCanvasServer routes); branch + checkout are POST/CSRF-
|
|
893
|
+
// gated source mutations. Switching a draft moves HEAD, which the git-lifecycle
|
|
894
|
+
// watcher turns into a Yjs flush + reload (DDR-051) — no logic duplicated here.
|
|
895
|
+
'/_api/git/branches': async (req: Request) => {
|
|
896
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
897
|
+
return gitJson(await gitApi.branches());
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
'/_api/git/branch': async (req: Request) => {
|
|
901
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
902
|
+
if (!sameOriginWrite(req))
|
|
903
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
904
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
905
|
+
return gitJson(await gitApi.createBranch(body));
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
'/_api/git/checkout': async (req: Request) => {
|
|
909
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
910
|
+
if (!sameOriginWrite(req))
|
|
911
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
912
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
913
|
+
return gitJson(await gitApi.checkout(body));
|
|
914
|
+
},
|
|
915
|
+
|
|
916
|
+
// "Add this draft to the Shared version" — merges + PUBLISHES, so it is token-
|
|
917
|
+
// bearing: same main-origin + loopback gate as /_api/git/push.
|
|
918
|
+
'/_api/git/fold': async (req: Request) => {
|
|
919
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
920
|
+
if (!sameOriginWrite(req))
|
|
921
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
922
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
923
|
+
return new Response('adding a draft requires a local request', { status: 403 });
|
|
924
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
925
|
+
return gitJson(await gitApi.fold(body));
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
'/_api/git/commit': async (req: Request) => {
|
|
929
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
930
|
+
if (!sameOriginWrite(req))
|
|
931
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
932
|
+
const body = await readJson<unknown>(req, 256 * 1024);
|
|
933
|
+
return gitJson(await gitApi.commit(body));
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
'/_api/git/discard': async (req: Request) => {
|
|
937
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
938
|
+
if (!sameOriginWrite(req))
|
|
939
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
940
|
+
const body = await readJson<unknown>(req, 256 * 1024);
|
|
941
|
+
return gitJson(await gitApi.discard(body));
|
|
942
|
+
},
|
|
943
|
+
|
|
944
|
+
'/_api/git/push': async (req: Request) => {
|
|
945
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
946
|
+
if (!sameOriginWrite(req))
|
|
947
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
948
|
+
// Token-bearing: refuse anything not from a loopback Host (the server binds
|
|
949
|
+
// 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
|
|
950
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
951
|
+
return new Response('publish requires a local request', { status: 403 });
|
|
952
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
953
|
+
return gitJson(await gitApi.push(body));
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
'/_api/git/pull': async (req: Request) => {
|
|
957
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
958
|
+
if (!sameOriginWrite(req))
|
|
959
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
960
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
961
|
+
return new Response('get latest requires a local request', { status: 403 });
|
|
962
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
963
|
+
return gitJson(await gitApi.pull(body));
|
|
964
|
+
},
|
|
965
|
+
|
|
966
|
+
// Finish a Get-latest merge that hit a conflict (DiffView "Keep mine/theirs/
|
|
967
|
+
// both"). Token-bearing (server-held; resolve completes the two-parent merge
|
|
968
|
+
// commit) → same main-origin + loopback gate as pull. MAIN-ORIGIN ONLY:
|
|
969
|
+
// absent from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist).
|
|
970
|
+
'/_api/git/resolve': async (req: Request) => {
|
|
971
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
972
|
+
if (!sameOriginWrite(req))
|
|
973
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
974
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
975
|
+
return new Response('resolve requires a local request', { status: 403 });
|
|
976
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
977
|
+
return gitJson(await gitApi.resolve(body));
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
// ── Phase 28 (E3) — GitHub identity & remote. Sign-in/out + keychain live in
|
|
981
|
+
// the Tauri shell (oauth.rs/keychain.rs commands); these endpoints use the
|
|
982
|
+
// server-held token (loopback bridge → token.ts) for the REST calls. MAIN-ORIGIN
|
|
983
|
+
// ONLY (absent from CANVAS_SAFE_API + startCanvasServer) and loopback-Host gated
|
|
984
|
+
// since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
|
|
985
|
+
// command — the dev-server can't touch the OS keychain — so there is no
|
|
986
|
+
// DELETE /_api/github/identity here; see DDR-114.)
|
|
987
|
+
'/_api/github/identity': async (req: Request) => {
|
|
988
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
989
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
990
|
+
return new Response('local request required', { status: 403 });
|
|
991
|
+
return gitJson(await githubApi.identity());
|
|
992
|
+
},
|
|
993
|
+
|
|
994
|
+
'/_api/github/repos': async (req: Request) => {
|
|
995
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
996
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
997
|
+
return new Response('local request required', { status: 403 });
|
|
998
|
+
return gitJson(await githubApi.repos());
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
'/_api/github/create-repo': async (req: Request) => {
|
|
1002
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1003
|
+
if (!sameOriginWrite(req))
|
|
1004
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1005
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1006
|
+
return new Response('local request required', { status: 403 });
|
|
1007
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1008
|
+
return gitJson(await githubApi.createRepo(body));
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
'/_api/github/invite': async (req: Request) => {
|
|
1012
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1013
|
+
if (!sameOriginWrite(req))
|
|
1014
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1015
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1016
|
+
return new Response('local request required', { status: 403 });
|
|
1017
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1018
|
+
return gitJson(await githubApi.invite(body));
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
'/_api/github/clone': async (req: Request) => {
|
|
1022
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1023
|
+
if (!sameOriginWrite(req))
|
|
1024
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1025
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1026
|
+
return new Response('local request required', { status: 403 });
|
|
1027
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1028
|
+
return gitJson(await githubApi.clone(body));
|
|
1029
|
+
},
|
|
1030
|
+
|
|
1031
|
+
'/_api/github/create-project': async (req: Request) => {
|
|
1032
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1033
|
+
if (!sameOriginWrite(req))
|
|
1034
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1035
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1036
|
+
return new Response('local request required', { status: 403 });
|
|
1037
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1038
|
+
return gitJson(await githubApi.createProject(body));
|
|
1039
|
+
},
|
|
1040
|
+
|
|
1041
|
+
// Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
|
|
1042
|
+
// repo → set it up?" fallback). No token / no GitHub — local FS only, but still
|
|
1043
|
+
// main-origin + loopback gated (it writes to disk).
|
|
1044
|
+
'/_api/design/init': 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 githubApi.initDesign(body));
|
|
1052
|
+
},
|
|
1053
|
+
|
|
1054
|
+
// Phase 29 (E4) Door C — connect to a team hub: validate + probe + save the hub
|
|
1055
|
+
// credential to the global ~/.config/maude/hubs.json (sync/hub-link.ts). MAIN
|
|
1056
|
+
// ORIGIN ONLY (omitted from CANVAS_SAFE_API + startCanvasServer routes) + loopback
|
|
1057
|
+
// + POST CSRF, mirroring /_api/github/*. The lean in-app counterpart to the CLI
|
|
1058
|
+
// `maude design link`; the in-UI Connect is the explicit trust grant (DDR-054 F2).
|
|
1059
|
+
'/_api/hub/link': async (req: Request) => {
|
|
1060
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1061
|
+
if (!sameOriginWrite(req))
|
|
1062
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1063
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1064
|
+
return new Response('local request required', { status: 403 });
|
|
1065
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1066
|
+
return gitJson(await linkHub(body));
|
|
1067
|
+
},
|
|
1068
|
+
|
|
671
1069
|
'/_api/edit-css': async (req: Request) => {
|
|
672
1070
|
// Phase 12 (DDR-103) — single-property inline CSS edit. POST body
|
|
673
1071
|
// { canvas, id, property, value } → writes one key into the element's
|