@1agh/maude 0.53.1 → 0.54.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/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +173 -17
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +308 -21
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +75 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +910 -910
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +383 -31
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +216 -53
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +58 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +47 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +74 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
package/apps/studio/http.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// top-level fall-through for paths Bun's `routes` field doesn't cover.
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
|
-
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
8
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync, watch } from 'node:fs';
|
|
9
9
|
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import {
|
|
@@ -22,6 +22,7 @@ import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
|
|
|
22
22
|
import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
|
|
23
23
|
import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
|
|
24
24
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
25
|
+
import { buildCanvasSandboxed } from './canvas-build-sandbox.ts';
|
|
25
26
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
26
27
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
27
28
|
import { createCloudEndpoints } from './cloud/endpoints.ts';
|
|
@@ -80,17 +81,19 @@ import {
|
|
|
80
81
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
81
82
|
import { gitShowFile } from './git/service.ts';
|
|
82
83
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
83
|
-
import type {
|
|
84
|
+
import type { InspectRegistry } from './inspect.ts';
|
|
84
85
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
85
|
-
import { DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
86
|
+
import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
86
87
|
import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
|
|
87
88
|
import { probeReadiness } from './readiness.ts';
|
|
88
89
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
90
|
+
import { currentSession } from './session-scope.ts';
|
|
89
91
|
import { linkHub } from './sync/hub-link.ts';
|
|
90
92
|
import { isHubReadOnly } from './sync/hubs-config.ts';
|
|
91
93
|
import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
|
|
92
94
|
import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
|
|
93
95
|
import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
|
|
96
|
+
import { isWorkspaceMode } from './workspace-mode.ts';
|
|
94
97
|
import { isLoopbackHost } from './ws.ts';
|
|
95
98
|
|
|
96
99
|
// Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
|
|
@@ -178,6 +181,25 @@ function ext(p: string): string {
|
|
|
178
181
|
* do not read it as precedent for adding further hosts without the same
|
|
179
182
|
* scrutiny (exact hostname, no wildcard subdomain, a DDR record).
|
|
180
183
|
*/
|
|
184
|
+
/**
|
|
185
|
+
* A stable, non-revealing name for the tree this process is serving.
|
|
186
|
+
*
|
|
187
|
+
* `realpath` first, so a bind-mount and a symlink to the same checkout are the
|
|
188
|
+
* same identity rather than two — the supervisor computes it from its own
|
|
189
|
+
* configured path and the two must agree. Falls back to the path as given when
|
|
190
|
+
* it cannot be resolved: an unresolvable root is a difference worth reporting,
|
|
191
|
+
* not one worth hiding behind a throw.
|
|
192
|
+
*/
|
|
193
|
+
export function rootIdentity(root: string): string {
|
|
194
|
+
let resolved = root;
|
|
195
|
+
try {
|
|
196
|
+
resolved = realpathSync(root);
|
|
197
|
+
} catch {
|
|
198
|
+
/* not on disk (yet) — hash what we were told */
|
|
199
|
+
}
|
|
200
|
+
return createHash('sha256').update(resolved, 'utf8').digest('hex').slice(0, 12);
|
|
201
|
+
}
|
|
202
|
+
|
|
181
203
|
export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
182
204
|
const hashes: string[] = [];
|
|
183
205
|
// Match inline <script> blocks only (no src=). `[^>]*` excludes any with src.
|
|
@@ -317,17 +339,48 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
|
|
|
317
339
|
'/_api/export-jobs/download',
|
|
318
340
|
'/_api/report', // bug reports are about Maude, not the project
|
|
319
341
|
'/_api/report-fallback',
|
|
342
|
+
// Cloud Phase 27 — COMMENT IS THE ONE WRITE A VIEWER HOLDS. The role matrix
|
|
343
|
+
// has said so since Phase 25 C4 (`viewer.comment === true`), the People page
|
|
344
|
+
// promises it in those words, and the cell's proxy allows it — but this list
|
|
345
|
+
// did not, so a viewer's comment was accepted by the authority and then
|
|
346
|
+
// refused by the defence-in-depth layer behind it. Found by commenting as a
|
|
347
|
+
// viewer against a real cell. A second gate that is STRICTER than the first is
|
|
348
|
+
// still a gate that is wrong.
|
|
349
|
+
'/_comments',
|
|
320
350
|
'/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
|
|
321
351
|
'/_api/workspace/sign-in', // signing in is how the role is (re)learned
|
|
322
352
|
'/_api/workspace/disclosure',
|
|
323
353
|
'/_api/cloud/signin/start', // account session, not project state
|
|
324
354
|
'/_api/cloud/signin/poll',
|
|
325
355
|
'/_api/cloud/signout',
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
'/_api/git/
|
|
356
|
+
// Fetch moves remote-tracking refs and nothing else — no working tree, no
|
|
357
|
+
// index. It stays a read.
|
|
358
|
+
'/_api/git/fetch',
|
|
359
|
+
// `pull` and `checkout` USED TO BE HERE, on the reasoning that "viewing
|
|
360
|
+
// another branch is not changing one". Cloud Phase 27 D2 retired that: both
|
|
361
|
+
// rewrite the working tree, and the tree is SHARED — in a cell by every
|
|
362
|
+
// member, and on a hub-linked desktop by whoever else has that project open.
|
|
363
|
+
// A viewer switching branches replaces the files under someone mid-edit.
|
|
364
|
+
//
|
|
365
|
+
// The proxy's manifest already refuses them, but this gate is the one the
|
|
366
|
+
// hub-linked desktop and self-host path consult (`isHubReadOnly`), where no
|
|
367
|
+
// manifest runs at all — so leaving them here would have left the hole open
|
|
368
|
+
// exactly where the proxy cannot reach. Two gates, and neither is trusted to
|
|
369
|
+
// be the last word.
|
|
329
370
|
]);
|
|
330
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Write paths a read-only session may use whose members are GENERATED rather
|
|
374
|
+
* than declared, so an exact-match set cannot name them.
|
|
375
|
+
*
|
|
376
|
+
* Deliberately one entry. Every regex here is a hole nobody can see by reading
|
|
377
|
+
* the list above, so the bar for adding one is that an exact path genuinely
|
|
378
|
+
* cannot express it.
|
|
379
|
+
*/
|
|
380
|
+
export const READ_ONLY_ALLOWED_WRITE_PATTERNS: RegExp[] = [
|
|
381
|
+
/^\/_api\/comments\/[A-Za-z0-9_-]+\/reply$/,
|
|
382
|
+
];
|
|
383
|
+
|
|
331
384
|
/** The refusal a read-only session gets for a project-mutating write. */
|
|
332
385
|
export function readOnlyRefusalResponse(): Response {
|
|
333
386
|
return Response.json(
|
|
@@ -502,6 +555,40 @@ async function serveCanvasTsx(
|
|
|
502
555
|
const source = await file.text();
|
|
503
556
|
const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
|
|
504
557
|
let result: Awaited<ReturnType<typeof buildCanvasModule>>;
|
|
558
|
+
// DDR-209 A′2 — SAME ENGINE, DIFFERENT HOST. On a desktop the process that
|
|
559
|
+
// parses your canvas is the process you own, so an in-process build costs
|
|
560
|
+
// nothing. In a cell it holds HUB_SECRET and the tenant's storage
|
|
561
|
+
// credentials, and the source is written by somebody who is not us — so the
|
|
562
|
+
// build goes out of process, with an empty environment, an import allowlist
|
|
563
|
+
// and wall-clock + RSS ceilings (Cloud Phase 25 A1's contract, unchanged).
|
|
564
|
+
// This branch is also what `sandboxArmed` in the containment boot-assert
|
|
565
|
+
// attests: the cell may serve the canvas surfaces BECAUSE this exists.
|
|
566
|
+
if (isWorkspaceMode()) {
|
|
567
|
+
const built = await buildCanvasSandboxed({
|
|
568
|
+
designRoot: ctx.paths.designRoot,
|
|
569
|
+
canvasAbs: absPath,
|
|
570
|
+
});
|
|
571
|
+
if (!built.ok) {
|
|
572
|
+
return new Response(`Canvas build error: ${built.error}`, {
|
|
573
|
+
status: built.kind === 'build' ? 422 : 500,
|
|
574
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
result = {
|
|
578
|
+
js: built.js,
|
|
579
|
+
locator: built.locator,
|
|
580
|
+
etag: built.etag,
|
|
581
|
+
} as Awaited<ReturnType<typeof buildCanvasModule>>;
|
|
582
|
+
cached = {
|
|
583
|
+
sig: canvasFreshnessSig(absPath, deps),
|
|
584
|
+
etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
585
|
+
js: result.js,
|
|
586
|
+
deps,
|
|
587
|
+
};
|
|
588
|
+
canvasCache.set(absPath, cached);
|
|
589
|
+
await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
|
|
590
|
+
return respondWithCanvasModule(req, cached);
|
|
591
|
+
}
|
|
505
592
|
try {
|
|
506
593
|
result = await buildCanvasModule(absPath, source, {
|
|
507
594
|
designRoot: ctx.paths.designRoot,
|
|
@@ -538,6 +625,13 @@ async function serveCanvasTsx(
|
|
|
538
625
|
await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
|
|
539
626
|
}
|
|
540
627
|
|
|
628
|
+
return respondWithCanvasModule(req, cached);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** The conditional-GET half of serving a built canvas module. Shared by the
|
|
632
|
+
* in-process (desktop) and sandboxed (cell) build paths so the two cannot
|
|
633
|
+
* disagree about caching semantics. */
|
|
634
|
+
function respondWithCanvasModule(req: Request, cached: { js: string; etag: string }): Response {
|
|
541
635
|
const ifNoneMatch = req.headers.get('if-none-match');
|
|
542
636
|
if (ifNoneMatch === cached.etag) {
|
|
543
637
|
return new Response(null, {
|
|
@@ -650,14 +744,70 @@ async function serveHistoricalCanvas(
|
|
|
650
744
|
});
|
|
651
745
|
}
|
|
652
746
|
|
|
747
|
+
/**
|
|
748
|
+
* How long a served file may be reused — Cloud Phase 27 B2.
|
|
749
|
+
*
|
|
750
|
+
* `no-store` on everything is correct on a laptop, where the round trip is a
|
|
751
|
+
* memcpy and the designer is editing the file you just served. Over the
|
|
752
|
+
* internet it means a teammate re-downloads every photograph on every pan, on
|
|
753
|
+
* a project whose media is 266 MB. So:
|
|
754
|
+
*
|
|
755
|
+
* content-addressed name → immutable, a year. The name IS the content
|
|
756
|
+
* (sha8), so a changed file is a changed URL and
|
|
757
|
+
* a stale answer is impossible by construction.
|
|
758
|
+
* everything else → revalidate (`no-cache` + ETag). A designer
|
|
759
|
+
* editing `hero.png` in place still sees their
|
|
760
|
+
* edit; the wire cost of not having changed it is
|
|
761
|
+
* a 304, not the file.
|
|
762
|
+
*
|
|
763
|
+
* Universal, not cloud-only: the rule is true on all three shells, and a
|
|
764
|
+
* caching policy that differs per shell is a bug report nobody can reproduce.
|
|
765
|
+
*/
|
|
766
|
+
export function cacheControlFor(absPath: string): { cacheControl: string; addEtag: boolean } {
|
|
767
|
+
const name = absPath.slice(absPath.lastIndexOf('/') + 1);
|
|
768
|
+
// The shape every writer emits — timeline insert/replace/assemble, the asset
|
|
769
|
+
// upload route, `maude design fetch-asset`: <sha8>.<ext>, lowercase hex.
|
|
770
|
+
if (/^[0-9a-f]{8,64}\.[A-Za-z0-9]+$/.test(name)) {
|
|
771
|
+
return { cacheControl: 'public, max-age=31536000, immutable', addEtag: false };
|
|
772
|
+
}
|
|
773
|
+
return { cacheControl: 'no-cache', addEtag: true };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** A weak validator from the file's own metadata — no read, no hash. */
|
|
777
|
+
function etagFor(file: { size: number; lastModified: number }): string {
|
|
778
|
+
return `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* An SVG is a DOCUMENT, and a document runs script.
|
|
783
|
+
*
|
|
784
|
+
* `image/svg+xml` is inert as an `<img>`, a CSS `url()` or a `<use>` target —
|
|
785
|
+
* none of those are browsing contexts. Navigate to one directly and it is a
|
|
786
|
+
* page, with `<script>` in it, executing on whatever origin served it. On the
|
|
787
|
+
* canvas origin that is a stored-XSS primitive: canvases carry the tenant's own
|
|
788
|
+
* SVGs, only the shell HTML gets a CSP, and every other static response here
|
|
789
|
+
* has carried `nosniff` and nothing else.
|
|
790
|
+
*
|
|
791
|
+
* `default-src 'none'; sandbox` costs the legitimate uses nothing (an image is
|
|
792
|
+
* not a document, so the policy never applies to them) and makes the
|
|
793
|
+
* navigate-to-it case a blank page instead of a foothold. Found by an
|
|
794
|
+
* adversarial pass as step one of a three-link cross-tenant chain; the other
|
|
795
|
+
* two links are closed in the same change, and this is the cheapest of the
|
|
796
|
+
* three to break.
|
|
797
|
+
*/
|
|
798
|
+
const INERT_DOCUMENT_CSP = "default-src 'none'; sandbox";
|
|
799
|
+
|
|
653
800
|
async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
654
801
|
const file = Bun.file(absPath);
|
|
655
802
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
656
803
|
const e = ext(absPath);
|
|
804
|
+
const policy = cacheControlFor(absPath);
|
|
657
805
|
return new Response(file, {
|
|
658
806
|
headers: {
|
|
659
807
|
'Content-Type': MIME[e] || 'application/octet-stream',
|
|
660
|
-
'Cache-Control':
|
|
808
|
+
'Cache-Control': policy.cacheControl,
|
|
809
|
+
...(policy.addEtag ? { ETag: etagFor(file) } : {}),
|
|
810
|
+
...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
|
|
661
811
|
...headers,
|
|
662
812
|
},
|
|
663
813
|
});
|
|
@@ -685,7 +835,9 @@ async function serveMediaFile(
|
|
|
685
835
|
const size = file.size;
|
|
686
836
|
const base = {
|
|
687
837
|
'Content-Type': MIME[ext(absPath)] || 'application/octet-stream',
|
|
688
|
-
|
|
838
|
+
// B2 — same policy as serveFile. Media is the heaviest thing a member
|
|
839
|
+
// fetches, so the content-addressed case matters most here.
|
|
840
|
+
'Cache-Control': cacheControlFor(absPath).cacheControl,
|
|
689
841
|
'Accept-Ranges': 'bytes',
|
|
690
842
|
...headers,
|
|
691
843
|
};
|
|
@@ -738,11 +890,18 @@ export interface Http {
|
|
|
738
890
|
export function createHttp(
|
|
739
891
|
ctx: Context,
|
|
740
892
|
api: Api,
|
|
741
|
-
inspect
|
|
893
|
+
/** D3 — one inspector per member. `inspect()` resolves the ambient session's
|
|
894
|
+
* instance, which on a desktop is the single one this used to be. */
|
|
895
|
+
inspects: InspectRegistry,
|
|
742
896
|
ai: AiActivity,
|
|
743
897
|
exportJobs: ExportJobQueue,
|
|
744
898
|
generateJobs: GenerationJobQueue
|
|
745
899
|
): Http {
|
|
900
|
+
/** The current request's inspector state (Cloud Phase 27 D3). Resolved per
|
|
901
|
+
* call rather than captured, because "whose" changes per request and a
|
|
902
|
+
* captured instance would hand one member another's open canvas. */
|
|
903
|
+
const inspect = () => inspects.for(currentSession());
|
|
904
|
+
|
|
746
905
|
// Task 2.7 (approach A) — in-flight whisper-model download state (one at a
|
|
747
906
|
// time), polled by the Settings "Download model" card via GET
|
|
748
907
|
// /_api/generate/whisper-model. Closure-scoped: one server, one download.
|
|
@@ -905,7 +1064,7 @@ export function createHttp(
|
|
|
905
1064
|
req: Request,
|
|
906
1065
|
body: { format: Format; scope: Scope; options?: Record<string, unknown> }
|
|
907
1066
|
) {
|
|
908
|
-
const activeJson = inspect.state as unknown as ActiveJsonShape;
|
|
1067
|
+
const activeJson = inspect().state as unknown as ActiveJsonShape;
|
|
909
1068
|
return {
|
|
910
1069
|
format: body.format,
|
|
911
1070
|
scope: body.scope,
|
|
@@ -935,10 +1094,23 @@ export function createHttp(
|
|
|
935
1094
|
ok: true,
|
|
936
1095
|
app: 'design',
|
|
937
1096
|
project: ctx.cfg.name,
|
|
1097
|
+
// WHICH TREE THIS PROCESS IS ACTUALLY SERVING — Cloud Phase 27 D5.
|
|
1098
|
+
//
|
|
1099
|
+
// A supervisor that only asks "did something answer" cannot tell a
|
|
1100
|
+
// studio serving the tenant's checkout from one serving whatever was
|
|
1101
|
+
// left in the working directory, and "boots, looks fine, serves the
|
|
1102
|
+
// wrong root" is exactly the mistake `studioLaunch` warns about one
|
|
1103
|
+
// process up. A tag is not an identity; a hash is.
|
|
1104
|
+
//
|
|
1105
|
+
// The HASH and not the path: this route is on the canvas origin's
|
|
1106
|
+
// allowlist, so the tenant's own untrusted code can read it, and a
|
|
1107
|
+
// server filesystem path is not something it should learn. The
|
|
1108
|
+
// supervisor hashes its own expected root and compares.
|
|
1109
|
+
rootId: rootIdentity(ctx.paths.repoRoot),
|
|
938
1110
|
pid: process.pid,
|
|
939
1111
|
}),
|
|
940
1112
|
|
|
941
|
-
'/_active': () => Response.json(inspect.state),
|
|
1113
|
+
'/_active': () => Response.json(inspect().state),
|
|
942
1114
|
|
|
943
1115
|
// Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
|
|
944
1116
|
// (is the adapter present + is `claude` on PATH); no subprocess spawned.
|
|
@@ -1218,11 +1390,45 @@ export function createHttp(
|
|
|
1218
1390
|
// knows before it draws anything. It decides what the UI OFFERS and is
|
|
1219
1391
|
// never what stops a write — the cell enforces that (Phase 25 C1),
|
|
1220
1392
|
// whatever a patched client believes.
|
|
1221
|
-
'/_config': () =>
|
|
1393
|
+
'/_config': (req: Request) =>
|
|
1222
1394
|
Response.json({
|
|
1223
1395
|
...ctx.cfg,
|
|
1224
1396
|
canvasOrigin: ctx.canvasOrigin,
|
|
1225
|
-
readOnly: projectReadOnly(),
|
|
1397
|
+
readOnly: projectReadOnly(req),
|
|
1398
|
+
// Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
|
|
1399
|
+
// canvas origin, minted per session by the proxy. Absent on a desktop,
|
|
1400
|
+
// where the canvas origin is loopback and needs none. `canvasUrl()`
|
|
1401
|
+
// appends it exactly the way it already appends `readOnly`, which is
|
|
1402
|
+
// what keeps this a FLAG on the shared client rather than a fork of it.
|
|
1403
|
+
...(req.headers.get('x-maude-canvas-token')
|
|
1404
|
+
? { canvasToken: req.headers.get('x-maude-canvas-token') }
|
|
1405
|
+
: {}),
|
|
1406
|
+
// Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared client
|
|
1407
|
+
// takes. Its presence says "you are in a browser tab, on somebody
|
|
1408
|
+
// else's machine", which is what lets the client state the agent's
|
|
1409
|
+
// absence and offer a way back to the dashboard. A flag, not a fork:
|
|
1410
|
+
// every other difference between the three shells stays zero.
|
|
1411
|
+
...(isWorkspaceMode()
|
|
1412
|
+
? {
|
|
1413
|
+
cloud: {
|
|
1414
|
+
dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
|
|
1415
|
+
projectName: process.env.MAUDE_PROJECT_NAME ?? ctx.cfg.name ?? null,
|
|
1416
|
+
// WHO the tab is signed in as, and WHAT that makes them —
|
|
1417
|
+
// per REQUEST, from the proxy's injected headers, never from
|
|
1418
|
+
// this process's environment (one cell serves an owner and a
|
|
1419
|
+
// viewer at the same time).
|
|
1420
|
+
//
|
|
1421
|
+
// The owner who could not edit his own project could not see
|
|
1422
|
+
// why: the stamp said VIEW ONLY and nothing on screen said
|
|
1423
|
+
// which account that verdict was about. Naming the account and
|
|
1424
|
+
// the role turns "this is broken" into "I am signed in as the
|
|
1425
|
+
// wrong person", which is a thing someone can act on — and the
|
|
1426
|
+
// sign-out beside it is how they act on it.
|
|
1427
|
+
user: req.headers.get('x-maude-user') || null,
|
|
1428
|
+
role: req.headers.get('x-maude-role') || null,
|
|
1429
|
+
},
|
|
1430
|
+
}
|
|
1431
|
+
: {}),
|
|
1226
1432
|
}),
|
|
1227
1433
|
|
|
1228
1434
|
// What's New feed (DDR-A) — read-only product-update list surfaced in the
|
|
@@ -1244,13 +1450,95 @@ export function createHttp(
|
|
|
1244
1450
|
buildDebugBundle({
|
|
1245
1451
|
maudeVersion: resolveMaudeVersion(),
|
|
1246
1452
|
projectName: ctx.cfg.name ?? null,
|
|
1247
|
-
activeCanvas: (inspect.state as { active?: string | null }).active ?? null,
|
|
1453
|
+
activeCanvas: (inspect().state as { active?: string | null }).active ?? null,
|
|
1248
1454
|
repoRoot: ctx.paths.repoRoot,
|
|
1249
1455
|
}),
|
|
1250
1456
|
{ headers: { 'Cache-Control': 'no-store' } }
|
|
1251
1457
|
);
|
|
1252
1458
|
},
|
|
1253
1459
|
|
|
1460
|
+
// feature-bug-report-button — capture the STUDIO SHELL (menubar, sidebar,
|
|
1461
|
+
// status bar, toasts) as a PNG. `/_api/export` can only ever render a canvas
|
|
1462
|
+
// headlessly, so Maude's own chrome — where most reported UI bugs actually
|
|
1463
|
+
// live — has never been capturable from inside the app. This shells out to
|
|
1464
|
+
// the same `screenshot.sh` spine every `/design:*` capture uses, in its
|
|
1465
|
+
// `--shell` mode, so it inherits engine resolution (the desktop bundle's
|
|
1466
|
+
// agent-browser via MAUDE_AGENT_BROWSER, DDR-144), one-time browser
|
|
1467
|
+
// provisioning, and the playwright fallback.
|
|
1468
|
+
//
|
|
1469
|
+
// The capture is a SEPARATE headless session, not a mirror of the user's
|
|
1470
|
+
// window: it reproduces the app's chrome and the active canvas, but not
|
|
1471
|
+
// transient state (an open dropdown, a stuck spinner). That's why the
|
|
1472
|
+
// dialog keeps the manual attach/paste lane alongside this.
|
|
1473
|
+
//
|
|
1474
|
+
// PRIVILEGED: main-origin only + double gate — it spawns a process and
|
|
1475
|
+
// renders the project, so the untrusted canvas origin must never reach it
|
|
1476
|
+
// (absent from CANVAS_SAFE_API + startCanvasServer routes, per DDR-088).
|
|
1477
|
+
'/_api/shell-shot': async (req: Request) => {
|
|
1478
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1479
|
+
if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
1480
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1481
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1482
|
+
// Target the very server handling this request — no _server.json read, so
|
|
1483
|
+
// a stale/absent state file can't point the capture at another instance.
|
|
1484
|
+
const port = new URL(req.url).port;
|
|
1485
|
+
if (!/^\d+$/.test(port)) {
|
|
1486
|
+
return Response.json({ error: 'no port on the request URL' }, { status: 500 });
|
|
1487
|
+
}
|
|
1488
|
+
// Which canvas to open is the CLIENT's call, always sent explicitly —
|
|
1489
|
+
// `null` means "open nothing". The server's `_active.json` is global and
|
|
1490
|
+
// sticky (it outlives every closed tab and any session may write it), so
|
|
1491
|
+
// resolving it here would photograph a canvas the reporter never had open.
|
|
1492
|
+
const body = await readJson<{ canvas?: unknown }>(req);
|
|
1493
|
+
const canvas = typeof body?.canvas === 'string' ? body.canvas : '';
|
|
1494
|
+
const out = join(ctx.paths.designRoot, '_reports', `shell-${Date.now()}.png`);
|
|
1495
|
+
try {
|
|
1496
|
+
const proc = Bun.spawn(
|
|
1497
|
+
[
|
|
1498
|
+
'bash',
|
|
1499
|
+
join(BIN_DIR, 'screenshot.sh'),
|
|
1500
|
+
'--shell',
|
|
1501
|
+
'--port',
|
|
1502
|
+
port,
|
|
1503
|
+
'--out',
|
|
1504
|
+
out,
|
|
1505
|
+
'--root',
|
|
1506
|
+
ctx.paths.repoRoot,
|
|
1507
|
+
'--canvas',
|
|
1508
|
+
canvas,
|
|
1509
|
+
],
|
|
1510
|
+
{ stdout: 'ignore', stderr: 'pipe' }
|
|
1511
|
+
);
|
|
1512
|
+
// Booting a browser, loading the studio, opening the active canvas and
|
|
1513
|
+
// painting it runs well past a default fetch timeout on a cold cache;
|
|
1514
|
+
// the dialog shows a pending row meanwhile. Kill rather than hang.
|
|
1515
|
+
const timer = setTimeout(() => proc.kill(), 60_000);
|
|
1516
|
+
const code = await proc.exited;
|
|
1517
|
+
clearTimeout(timer);
|
|
1518
|
+
if (code !== 0 || !existsSync(out)) {
|
|
1519
|
+
const why = (await new Response(proc.stderr).text())
|
|
1520
|
+
.trim()
|
|
1521
|
+
.split('\n')
|
|
1522
|
+
.slice(-3)
|
|
1523
|
+
.join(' ');
|
|
1524
|
+
return Response.json({ error: `shell capture failed: ${why}` }, { status: 502 });
|
|
1525
|
+
}
|
|
1526
|
+
const png = await Bun.file(out).arrayBuffer();
|
|
1527
|
+
// The PNG is handed to the client and never needed again — leaving it on
|
|
1528
|
+
// disk would quietly grow `_reports/` on every dialog open.
|
|
1529
|
+
try {
|
|
1530
|
+
unlinkSync(out);
|
|
1531
|
+
} catch {
|
|
1532
|
+
/* already gone — nothing to clean up */
|
|
1533
|
+
}
|
|
1534
|
+
return new Response(png, {
|
|
1535
|
+
headers: { 'content-type': 'image/png', 'Cache-Control': 'no-store' },
|
|
1536
|
+
});
|
|
1537
|
+
} catch (e) {
|
|
1538
|
+
return Response.json({ error: `shell capture failed: ${String(e)}` }, { status: 502 });
|
|
1539
|
+
}
|
|
1540
|
+
},
|
|
1541
|
+
|
|
1254
1542
|
// feature-bug-report-button — submit proxy. The client never talks to
|
|
1255
1543
|
// cloud.maude.sh directly (no CORS surface to open, and the endpoint can be
|
|
1256
1544
|
// overridden for self-hosters/tests via MAUDE_REPORT_URL). Forwards the
|
|
@@ -1371,7 +1659,7 @@ export function createHttp(
|
|
|
1371
1659
|
// the api layer — DDR-115). The layout lane mutates the versioned
|
|
1372
1660
|
// `.meta.json`, so a read-only session is refused here, in-handler,
|
|
1373
1661
|
// where the two lanes are distinguishable.
|
|
1374
|
-
if (projectReadOnly() && 'layout' in body.patch) {
|
|
1662
|
+
if (projectReadOnly(req) && 'layout' in body.patch) {
|
|
1375
1663
|
return readOnlyRefusalResponse();
|
|
1376
1664
|
}
|
|
1377
1665
|
const next = await api.patchCanvasMeta(body.file, body.patch);
|
|
@@ -1504,6 +1792,17 @@ export function createHttp(
|
|
|
1504
1792
|
// identity. Color-hash derives from this; falls back to anonymous-<pid>
|
|
1505
1793
|
// client-side when empty.
|
|
1506
1794
|
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1795
|
+
// Cloud Phase 27 — IN A CELL, `git config user.name` IS THE MACHINE.
|
|
1796
|
+
//
|
|
1797
|
+
// It is "Maude Workspace", the committer the autosave agent signs with
|
|
1798
|
+
// (sync/autocommit.ts), and it is the right answer for a commit and the
|
|
1799
|
+
// wrong one for a person: a member opened their project and the presence
|
|
1800
|
+
// chip introduced them as the server. The proxy already vouches who this
|
|
1801
|
+
// is per request, so in a cell that is who it is.
|
|
1802
|
+
const vouched = req.headers.get('x-maude-user');
|
|
1803
|
+
if (isWorkspaceMode() && vouched) {
|
|
1804
|
+
return Response.json({ name: vouched }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1805
|
+
}
|
|
1507
1806
|
const name = await api.gitCurrentUser();
|
|
1508
1807
|
return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1509
1808
|
},
|
|
@@ -4072,17 +4371,13 @@ export function createHttp(
|
|
|
4072
4371
|
if (RANGE_MEDIA_EXTS.has(ext(name))) {
|
|
4073
4372
|
return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
|
|
4074
4373
|
}
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
},
|
|
4083
|
-
});
|
|
4084
|
-
}
|
|
4085
|
-
return new Response('Not found', { status: 404 });
|
|
4374
|
+
// B2 — the SAME caching policy as every other static lane. This
|
|
4375
|
+
// route is the one a canvas's photographs actually come down, so
|
|
4376
|
+
// `no-store` here is the difference between a teammate re-fetching
|
|
4377
|
+
// 266 MB of media on every pan and re-fetching none of it. Content-
|
|
4378
|
+
// addressed names (`<sha8>.<ext>`, which is what every writer emits)
|
|
4379
|
+
// are immutable by construction; anything else revalidates.
|
|
4380
|
+
return serveFile(abs, { 'X-Content-Type-Options': 'nosniff' });
|
|
4086
4381
|
}
|
|
4087
4382
|
}
|
|
4088
4383
|
|
|
@@ -4181,15 +4476,38 @@ export function createHttp(
|
|
|
4181
4476
|
return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
|
|
4182
4477
|
}
|
|
4183
4478
|
// Bun.file streams transparently for binary content.
|
|
4479
|
+
//
|
|
4480
|
+
// B2 — THIS is the lane a design system's own photographs and webfonts
|
|
4481
|
+
// come down (`system/<ds>/assets/graphics/camo-bg.png`, 446 kB;
|
|
4482
|
+
// `…/fonts/*.woff2`). `no-store` here meant a teammate re-downloaded all
|
|
4483
|
+
// of it on every pan, across the internet, on a project whose media is
|
|
4484
|
+
// 266 MB. Verified against the real one after the first cloud deploy said
|
|
4485
|
+
// `no-store` on exactly these files.
|
|
4486
|
+
//
|
|
4487
|
+
// `cacheControlFor` gives content-addressed names a year and everything
|
|
4488
|
+
// else a revalidation — so a designer editing `hero.png` in place still
|
|
4489
|
+
// sees the edit, at the cost of a 304 rather than the file.
|
|
4490
|
+
const policy = cacheControlFor(fp);
|
|
4184
4491
|
return new Response(file, {
|
|
4185
4492
|
headers: {
|
|
4186
4493
|
'Content-Type': MIME[e] || 'application/octet-stream',
|
|
4187
|
-
'Cache-Control':
|
|
4494
|
+
'Cache-Control': policy.cacheControl,
|
|
4495
|
+
...(policy.addEtag
|
|
4496
|
+
? {
|
|
4497
|
+
ETag: `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`,
|
|
4498
|
+
}
|
|
4499
|
+
: {}),
|
|
4188
4500
|
// DDR-088 follow-up — never let a browser MIME-sniff a served file
|
|
4189
4501
|
// (e.g. an uploaded GIF/WEBP polyglot) into a richer type. Assets are
|
|
4190
4502
|
// only referenced via <image href> + the canvas CSP blocks script, so
|
|
4191
4503
|
// this is defense-in-depth on the static lane.
|
|
4192
4504
|
'X-Content-Type-Options': 'nosniff',
|
|
4505
|
+
// …and `nosniff` is not enough for the one type that is a DOCUMENT
|
|
4506
|
+
// whether you sniff it or not. See INERT_DOCUMENT_CSP: an SVG
|
|
4507
|
+
// NAVIGATED TO is a page with `<script>` in it, executing on this
|
|
4508
|
+
// origin, and "the canvas CSP blocks script" above is true only of
|
|
4509
|
+
// the shell — not of the asset served on its own.
|
|
4510
|
+
...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
|
|
4193
4511
|
},
|
|
4194
4512
|
});
|
|
4195
4513
|
} catch (err) {
|
|
@@ -4201,7 +4519,7 @@ export function createHttp(
|
|
|
4201
4519
|
async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
|
|
4202
4520
|
const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
|
|
4203
4521
|
// Inject inspector overlay — Cmd+Click selection + add-comment flow.
|
|
4204
|
-
const injected = inspect.injectInspector(shellHtml);
|
|
4522
|
+
const injected = inspect().injectInspector(shellHtml);
|
|
4205
4523
|
const headers: Record<string, string> = {
|
|
4206
4524
|
'Content-Type': 'text/html; charset=utf-8',
|
|
4207
4525
|
'Cache-Control': 'no-store',
|
|
@@ -4304,7 +4622,22 @@ export function createHttp(
|
|
|
4304
4622
|
if (safe.startsWith(designPrefix)) {
|
|
4305
4623
|
const rest = safe.slice(designPrefix.length);
|
|
4306
4624
|
// Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
|
|
4307
|
-
|
|
4625
|
+
//
|
|
4626
|
+
// FIRST SEGMENT ONLY, and that is the taxonomy rather than a relaxation.
|
|
4627
|
+
// Every runtime-state path DDR-115 names lives at the top level of the
|
|
4628
|
+
// designRoot — `_history/`, `_canvas-state/`, `_state/`, `_chat/`,
|
|
4629
|
+
// `_comments/`, `_untrusted/`, `_trash/`, `_draw/`, `_smoke/`,
|
|
4630
|
+
// `_server.json`, `_active.json` — so a first-segment test rejects the
|
|
4631
|
+
// whole of it, including everything nested underneath.
|
|
4632
|
+
//
|
|
4633
|
+
// Testing EVERY segment additionally rejected files that are versioned,
|
|
4634
|
+
// shipped, and required: a design system's `system/<ds>/preview/
|
|
4635
|
+
// _components.css` is the stylesheet the shell itself names in the
|
|
4636
|
+
// iframe URL, and the underscore there is the DS's own convention for
|
|
4637
|
+
// "aggregate, not a specimen". It 403'd, so every canvas rendered with
|
|
4638
|
+
// its component styles missing and looked broken in a way that pointed
|
|
4639
|
+
// nowhere near this line.
|
|
4640
|
+
if (rest.split('/')[0].startsWith('_')) return false;
|
|
4308
4641
|
return CANVAS_ASSET_EXTS.has(ext(safe));
|
|
4309
4642
|
}
|
|
4310
4643
|
// DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
|
|
@@ -4327,13 +4660,28 @@ export function createHttp(
|
|
|
4327
4660
|
// covers all three doors at once: the main-origin `routes` table, the
|
|
4328
4661
|
// canvas-origin `routes` allowlist in server.ts (it references these same
|
|
4329
4662
|
// handlers), and the dynamic-path `fetch` fall-through (comment replies).
|
|
4330
|
-
function projectReadOnly(): boolean {
|
|
4663
|
+
function projectReadOnly(req?: Request): boolean {
|
|
4664
|
+
// ---- Cloud Phase 27 A3/A4 (DDR-209): the role is PER SESSION ----------
|
|
4665
|
+
//
|
|
4666
|
+
// In a cell this process serves an owner and a viewer at the same time, so
|
|
4667
|
+
// the on-disk answer below — one role per hub URL — is not merely stale, it
|
|
4668
|
+
// is the wrong SHAPE. The proxy in front vouches a role per request and
|
|
4669
|
+
// injects the capability it derived from the one role table.
|
|
4670
|
+
//
|
|
4671
|
+
// AND IT DEFAULTS CLOSED. The local path fails OPEN by design: `catch`
|
|
4672
|
+
// returns false, an unset `linkedHub` returns false, and a fully writable
|
|
4673
|
+
// studio is the correct answer for a tool running on your own laptop. On
|
|
4674
|
+
// the internet it is the whole ballgame — so in a cloud build read-only is
|
|
4675
|
+
// the default and an edit role requires positive proof.
|
|
4676
|
+
if (isWorkspaceMode()) {
|
|
4677
|
+
return req?.headers.get('x-maude-readonly') !== '0';
|
|
4678
|
+
}
|
|
4331
4679
|
return ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false;
|
|
4332
4680
|
}
|
|
4333
4681
|
|
|
4334
4682
|
function readOnlyRefusal(req: Request): Response | null {
|
|
4335
4683
|
if (READ_ONLY_SAFE_METHODS.has(req.method)) return null;
|
|
4336
|
-
if (!projectReadOnly()) return null;
|
|
4684
|
+
if (!projectReadOnly(req)) return null;
|
|
4337
4685
|
let pathname: string;
|
|
4338
4686
|
try {
|
|
4339
4687
|
pathname = new URL(req.url).pathname;
|
|
@@ -4341,6 +4689,10 @@ export function createHttp(
|
|
|
4341
4689
|
return readOnlyRefusalResponse();
|
|
4342
4690
|
}
|
|
4343
4691
|
if (READ_ONLY_ALLOWED_WRITES.has(pathname)) return null;
|
|
4692
|
+
// The dynamic half of the comment lane. An exact-match set cannot express
|
|
4693
|
+
// it, and leaving it out would mean a viewer may leave a comment but not
|
|
4694
|
+
// reply to one — a distinction nobody promised and nobody wants.
|
|
4695
|
+
if (READ_ONLY_ALLOWED_WRITE_PATTERNS.some((re) => re.test(pathname))) return null;
|
|
4344
4696
|
return readOnlyRefusalResponse();
|
|
4345
4697
|
}
|
|
4346
4698
|
|