@1agh/maude 0.53.2 → 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 +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -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 +1007 -1007
- 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 +145 -12
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- 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-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -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/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +44 -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, unlinkSync, 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 {
|
|
@@ -81,12 +81,13 @@ import {
|
|
|
81
81
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
82
82
|
import { gitShowFile } from './git/service.ts';
|
|
83
83
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
84
|
-
import type {
|
|
84
|
+
import type { InspectRegistry } from './inspect.ts';
|
|
85
85
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
86
86
|
import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
87
87
|
import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
|
|
88
88
|
import { probeReadiness } from './readiness.ts';
|
|
89
89
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
90
|
+
import { currentSession } from './session-scope.ts';
|
|
90
91
|
import { linkHub } from './sync/hub-link.ts';
|
|
91
92
|
import { isHubReadOnly } from './sync/hubs-config.ts';
|
|
92
93
|
import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
|
|
@@ -180,6 +181,25 @@ function ext(p: string): string {
|
|
|
180
181
|
* do not read it as precedent for adding further hosts without the same
|
|
181
182
|
* scrutiny (exact hostname, no wildcard subdomain, a DDR record).
|
|
182
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
|
+
|
|
183
203
|
export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
184
204
|
const hashes: string[] = [];
|
|
185
205
|
// Match inline <script> blocks only (no src=). `[^>]*` excludes any with src.
|
|
@@ -333,9 +353,20 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
|
|
|
333
353
|
'/_api/cloud/signin/start', // account session, not project state
|
|
334
354
|
'/_api/cloud/signin/poll',
|
|
335
355
|
'/_api/cloud/signout',
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
'/_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.
|
|
339
370
|
]);
|
|
340
371
|
|
|
341
372
|
/**
|
|
@@ -747,6 +778,25 @@ function etagFor(file: { size: number; lastModified: number }): string {
|
|
|
747
778
|
return `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`;
|
|
748
779
|
}
|
|
749
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
|
+
|
|
750
800
|
async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
751
801
|
const file = Bun.file(absPath);
|
|
752
802
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
@@ -757,6 +807,7 @@ async function serveFile(absPath: string, headers: Record<string, string> = {}):
|
|
|
757
807
|
'Content-Type': MIME[e] || 'application/octet-stream',
|
|
758
808
|
'Cache-Control': policy.cacheControl,
|
|
759
809
|
...(policy.addEtag ? { ETag: etagFor(file) } : {}),
|
|
810
|
+
...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
|
|
760
811
|
...headers,
|
|
761
812
|
},
|
|
762
813
|
});
|
|
@@ -839,11 +890,18 @@ export interface Http {
|
|
|
839
890
|
export function createHttp(
|
|
840
891
|
ctx: Context,
|
|
841
892
|
api: Api,
|
|
842
|
-
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,
|
|
843
896
|
ai: AiActivity,
|
|
844
897
|
exportJobs: ExportJobQueue,
|
|
845
898
|
generateJobs: GenerationJobQueue
|
|
846
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
|
+
|
|
847
905
|
// Task 2.7 (approach A) — in-flight whisper-model download state (one at a
|
|
848
906
|
// time), polled by the Settings "Download model" card via GET
|
|
849
907
|
// /_api/generate/whisper-model. Closure-scoped: one server, one download.
|
|
@@ -1006,7 +1064,7 @@ export function createHttp(
|
|
|
1006
1064
|
req: Request,
|
|
1007
1065
|
body: { format: Format; scope: Scope; options?: Record<string, unknown> }
|
|
1008
1066
|
) {
|
|
1009
|
-
const activeJson = inspect.state as unknown as ActiveJsonShape;
|
|
1067
|
+
const activeJson = inspect().state as unknown as ActiveJsonShape;
|
|
1010
1068
|
return {
|
|
1011
1069
|
format: body.format,
|
|
1012
1070
|
scope: body.scope,
|
|
@@ -1036,10 +1094,23 @@ export function createHttp(
|
|
|
1036
1094
|
ok: true,
|
|
1037
1095
|
app: 'design',
|
|
1038
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),
|
|
1039
1110
|
pid: process.pid,
|
|
1040
1111
|
}),
|
|
1041
1112
|
|
|
1042
|
-
'/_active': () => Response.json(inspect.state),
|
|
1113
|
+
'/_active': () => Response.json(inspect().state),
|
|
1043
1114
|
|
|
1044
1115
|
// Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
|
|
1045
1116
|
// (is the adapter present + is `claude` on PATH); no subprocess spawned.
|
|
@@ -1342,6 +1413,19 @@ export function createHttp(
|
|
|
1342
1413
|
cloud: {
|
|
1343
1414
|
dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
|
|
1344
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,
|
|
1345
1429
|
},
|
|
1346
1430
|
}
|
|
1347
1431
|
: {}),
|
|
@@ -1366,7 +1450,7 @@ export function createHttp(
|
|
|
1366
1450
|
buildDebugBundle({
|
|
1367
1451
|
maudeVersion: resolveMaudeVersion(),
|
|
1368
1452
|
projectName: ctx.cfg.name ?? null,
|
|
1369
|
-
activeCanvas: (inspect.state as { active?: string | null }).active ?? null,
|
|
1453
|
+
activeCanvas: (inspect().state as { active?: string | null }).active ?? null,
|
|
1370
1454
|
repoRoot: ctx.paths.repoRoot,
|
|
1371
1455
|
}),
|
|
1372
1456
|
{ headers: { 'Cache-Control': 'no-store' } }
|
|
@@ -1708,6 +1792,17 @@ export function createHttp(
|
|
|
1708
1792
|
// identity. Color-hash derives from this; falls back to anonymous-<pid>
|
|
1709
1793
|
// client-side when empty.
|
|
1710
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
|
+
}
|
|
1711
1806
|
const name = await api.gitCurrentUser();
|
|
1712
1807
|
return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1713
1808
|
},
|
|
@@ -4381,15 +4476,38 @@ export function createHttp(
|
|
|
4381
4476
|
return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
|
|
4382
4477
|
}
|
|
4383
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);
|
|
4384
4491
|
return new Response(file, {
|
|
4385
4492
|
headers: {
|
|
4386
4493
|
'Content-Type': MIME[e] || 'application/octet-stream',
|
|
4387
|
-
'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
|
+
: {}),
|
|
4388
4500
|
// DDR-088 follow-up — never let a browser MIME-sniff a served file
|
|
4389
4501
|
// (e.g. an uploaded GIF/WEBP polyglot) into a richer type. Assets are
|
|
4390
4502
|
// only referenced via <image href> + the canvas CSP blocks script, so
|
|
4391
4503
|
// this is defense-in-depth on the static lane.
|
|
4392
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 } : {}),
|
|
4393
4511
|
},
|
|
4394
4512
|
});
|
|
4395
4513
|
} catch (err) {
|
|
@@ -4401,7 +4519,7 @@ export function createHttp(
|
|
|
4401
4519
|
async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
|
|
4402
4520
|
const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
|
|
4403
4521
|
// Inject inspector overlay — Cmd+Click selection + add-comment flow.
|
|
4404
|
-
const injected = inspect.injectInspector(shellHtml);
|
|
4522
|
+
const injected = inspect().injectInspector(shellHtml);
|
|
4405
4523
|
const headers: Record<string, string> = {
|
|
4406
4524
|
'Content-Type': 'text/html; charset=utf-8',
|
|
4407
4525
|
'Cache-Control': 'no-store',
|
|
@@ -4504,7 +4622,22 @@ export function createHttp(
|
|
|
4504
4622
|
if (safe.startsWith(designPrefix)) {
|
|
4505
4623
|
const rest = safe.slice(designPrefix.length);
|
|
4506
4624
|
// Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
|
|
4507
|
-
|
|
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;
|
|
4508
4641
|
return CANVAS_ASSET_EXTS.has(ext(safe));
|
|
4509
4642
|
}
|
|
4510
4643
|
// DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
|
package/apps/studio/inspect.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
import type { Context } from './context.ts';
|
|
7
|
+
import { normalizeSessionKey, sessionFile } from './session-scope.ts';
|
|
7
8
|
|
|
8
9
|
export interface SelectedElement {
|
|
9
10
|
file: string;
|
|
@@ -96,6 +97,8 @@ type SetSelectedInput =
|
|
|
96
97
|
| null;
|
|
97
98
|
|
|
98
99
|
export interface Inspect {
|
|
100
|
+
/** Whose state this is — `''` for the shared singleton (D3). */
|
|
101
|
+
sessionKey: string;
|
|
99
102
|
state: ActiveState;
|
|
100
103
|
load(): Promise<void>;
|
|
101
104
|
setActive(file: string): void;
|
|
@@ -126,9 +129,16 @@ const NEW = (): ActiveState => ({
|
|
|
126
129
|
|
|
127
130
|
export function createInspect(
|
|
128
131
|
ctx: Context,
|
|
129
|
-
loadActiveComments: (file: string) => Promise<unknown[]
|
|
132
|
+
loadActiveComments: (file: string) => Promise<unknown[]>,
|
|
133
|
+
/**
|
|
134
|
+
* Whose state this is — Cloud Phase 27 D3. `''` is the shared singleton every
|
|
135
|
+
* desktop has always had; a non-empty key gives one member of a cell their
|
|
136
|
+
* own selection, their own open tab, and their own `_active.<key>.json`.
|
|
137
|
+
*/
|
|
138
|
+
sessionKey = ''
|
|
130
139
|
): Inspect {
|
|
131
140
|
const state: ActiveState = NEW();
|
|
141
|
+
const activeFile = sessionFile(ctx.paths.activeFile, sessionKey);
|
|
132
142
|
let saveQueued = false;
|
|
133
143
|
|
|
134
144
|
async function save() {
|
|
@@ -144,7 +154,7 @@ export function createInspect(
|
|
|
144
154
|
}
|
|
145
155
|
}
|
|
146
156
|
const enriched = { ...state, active_comments };
|
|
147
|
-
await Bun.write(
|
|
157
|
+
await Bun.write(activeFile, JSON.stringify(enriched, null, 2));
|
|
148
158
|
} catch (e) {
|
|
149
159
|
const msg = e instanceof Error ? e.message : String(e);
|
|
150
160
|
console.error(' warn: failed to save _active.json:', msg);
|
|
@@ -159,7 +169,7 @@ export function createInspect(
|
|
|
159
169
|
|
|
160
170
|
async function load() {
|
|
161
171
|
try {
|
|
162
|
-
const raw = await Bun.file(
|
|
172
|
+
const raw = await Bun.file(activeFile).text();
|
|
163
173
|
const prev = JSON.parse(raw);
|
|
164
174
|
Object.assign(state, prev, { session_started: new Date().toISOString() });
|
|
165
175
|
// Pre-selections _active.json (or a hand-edited one) → keep the invariant.
|
|
@@ -222,10 +232,10 @@ export function createInspect(
|
|
|
222
232
|
state.selected = file ? restoreFor(file) : null;
|
|
223
233
|
state.last_change = new Date().toISOString();
|
|
224
234
|
scheduleSave();
|
|
225
|
-
ctx.bus.emit('active', state.active);
|
|
235
|
+
ctx.bus.emit('active', state.active, { session: sessionKey });
|
|
226
236
|
// Clients (StatusBar, shell halo, chat context chip) must see the restored
|
|
227
237
|
// selection, not assume the pre-switch null.
|
|
228
|
-
ctx.bus.emit('selected', state.selected);
|
|
238
|
+
ctx.bus.emit('selected', state.selected, { session: sessionKey });
|
|
229
239
|
}
|
|
230
240
|
|
|
231
241
|
function setOpenTabs(tabs: string[]) {
|
|
@@ -320,7 +330,7 @@ export function createInspect(
|
|
|
320
330
|
}
|
|
321
331
|
state.last_change = new Date().toISOString();
|
|
322
332
|
scheduleSave();
|
|
323
|
-
ctx.bus.emit('selected', state.selected);
|
|
333
|
+
ctx.bus.emit('selected', state.selected, { session: sessionKey });
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
function retarget(fromFile: string, toFile: string): boolean {
|
|
@@ -379,6 +389,7 @@ export function createInspect(
|
|
|
379
389
|
}
|
|
380
390
|
|
|
381
391
|
return {
|
|
392
|
+
sessionKey,
|
|
382
393
|
state,
|
|
383
394
|
load,
|
|
384
395
|
setActive,
|
|
@@ -390,6 +401,55 @@ export function createInspect(
|
|
|
390
401
|
};
|
|
391
402
|
}
|
|
392
403
|
|
|
404
|
+
/**
|
|
405
|
+
* One `Inspect` per member — Cloud Phase 27 D3.
|
|
406
|
+
*
|
|
407
|
+
* A desktop asks for `for('')` forever and gets the single instance it always
|
|
408
|
+
* had. A cell asks with the proxy's vouched session key and gets one per
|
|
409
|
+
* member, so an owner and a viewer stop overwriting each other's open tab and
|
|
410
|
+
* selection.
|
|
411
|
+
*
|
|
412
|
+
* Instances are created on demand and kept: they are small (one state object),
|
|
413
|
+
* a member reconnects to the same key across reloads, and evicting one would
|
|
414
|
+
* lose exactly the state this exists to preserve. The key space is bounded by
|
|
415
|
+
* the project's membership, not by anything a client can invent —
|
|
416
|
+
* `normalizeSessionKey` rejects a value that is not proxy-shaped, and the proxy
|
|
417
|
+
* strips inbound `x-maude-*` before injecting its own.
|
|
418
|
+
*/
|
|
419
|
+
export interface InspectRegistry {
|
|
420
|
+
/** The instance for this session key, created on first use. */
|
|
421
|
+
for(sessionKey?: string | null): Inspect;
|
|
422
|
+
/** Every live instance. */
|
|
423
|
+
all(): Inspect[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function createInspectRegistry(
|
|
427
|
+
ctx: Context,
|
|
428
|
+
loadActiveComments: (file: string) => Promise<unknown[]>
|
|
429
|
+
): InspectRegistry {
|
|
430
|
+
const instances = new Map<string, Inspect>();
|
|
431
|
+
|
|
432
|
+
function get(sessionKey?: string | null): Inspect {
|
|
433
|
+
const key = normalizeSessionKey(sessionKey);
|
|
434
|
+
let found = instances.get(key);
|
|
435
|
+
if (!found) {
|
|
436
|
+
found = createInspect(ctx, loadActiveComments, key);
|
|
437
|
+
instances.set(key, found);
|
|
438
|
+
// A member returning after a reload picks their own place back up. Fire
|
|
439
|
+
// and forget: `load()` is a best-effort read of a file that usually does
|
|
440
|
+
// not exist yet, and blocking a request on it would trade a correctness
|
|
441
|
+
// nicety for latency on every first touch.
|
|
442
|
+
if (key) void found.load();
|
|
443
|
+
}
|
|
444
|
+
return found;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
for: get,
|
|
449
|
+
all: () => [...instances.values()],
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
393
453
|
// ---------- Inspector script injection ----------
|
|
394
454
|
|
|
395
455
|
function injectInspector(html: string): string {
|
package/apps/studio/server.ts
CHANGED
|
@@ -31,8 +31,9 @@ import { createFsWatch } from './fs-watch.ts';
|
|
|
31
31
|
import { createGenerationJobQueue } from './generation/jobs.ts';
|
|
32
32
|
import { createGitWatch } from './git/watch.ts';
|
|
33
33
|
import { createHttp } from './http.ts';
|
|
34
|
-
import {
|
|
34
|
+
import { createInspectRegistry } from './inspect.ts';
|
|
35
35
|
import { startHeapWatch } from './mem.ts';
|
|
36
|
+
import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
|
|
36
37
|
import { createSyncRuntime } from './sync/index.ts';
|
|
37
38
|
import {
|
|
38
39
|
assertContainment,
|
|
@@ -71,7 +72,7 @@ let collab: ReturnType<typeof createCollab> | null = null;
|
|
|
71
72
|
// Forward-declared for the same reason — moveCanvas (feature-file-tree-
|
|
72
73
|
// drag-drop-folders, Task 3) retargets `_active.json` through the live
|
|
73
74
|
// Inspect instance, which is constructed after `api`.
|
|
74
|
-
let inspectHandle: ReturnType<typeof
|
|
75
|
+
let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
|
|
75
76
|
|
|
76
77
|
const api = createApi(ctx, {
|
|
77
78
|
onCommentsChanged: async (file) => {
|
|
@@ -100,13 +101,17 @@ const api = createApi(ctx, {
|
|
|
100
101
|
if (collab) await collab.registry.forceDrop(slug);
|
|
101
102
|
},
|
|
102
103
|
retargetActive: (fromFile, toFile) => {
|
|
103
|
-
|
|
104
|
+
// D3 — a canvas moved on disk moved for EVERY member, so every member's
|
|
105
|
+
// state is retargeted, not just the one whose request did it.
|
|
106
|
+
for (const one of inspectHandle?.all() ?? []) one.retarget(fromFile, toFile);
|
|
104
107
|
},
|
|
105
108
|
});
|
|
106
109
|
|
|
107
|
-
const
|
|
108
|
-
inspectHandle =
|
|
109
|
-
|
|
110
|
+
const inspects = createInspectRegistry(ctx, (file) => api.loadCommentsForFile(file));
|
|
111
|
+
inspectHandle = inspects;
|
|
112
|
+
// The shared instance is the desktop's, and a cell's fallback for anything that
|
|
113
|
+
// arrives without a vouched session.
|
|
114
|
+
await inspects.for('').load();
|
|
110
115
|
|
|
111
116
|
collab = createCollab(ctx, api);
|
|
112
117
|
const aiActivity = createAiActivity(ctx);
|
|
@@ -134,11 +139,11 @@ const activity = createActivity(ctx);
|
|
|
134
139
|
// editing" banner + presence as /design:edit (RC5,
|
|
135
140
|
// rca/issue-canvas-hmr-optimistic-update-consistency).
|
|
136
141
|
const acp = createAcp(ctx, aiActivity);
|
|
137
|
-
const ws = createWs(ctx, api,
|
|
142
|
+
const ws = createWs(ctx, api, inspects, collab, activity, acp);
|
|
138
143
|
const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
139
144
|
// feature-ai-media-generation (DDR-16x) — background AI-media generation queue.
|
|
140
145
|
const generateJobs = createGenerationJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
141
|
-
const http = createHttp(ctx, api,
|
|
146
|
+
const http = createHttp(ctx, api, inspects, aiActivity, exportJobs, generateJobs);
|
|
142
147
|
const fsWatch = createFsWatch(ctx);
|
|
143
148
|
|
|
144
149
|
// Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
|
|
@@ -196,7 +201,51 @@ const SANDBOX_ARMED = isSandboxArmed();
|
|
|
196
201
|
// post-condition on the pruning rather than a second, driftable opinion. A
|
|
197
202
|
// prefix added to the vocabulary then both prunes and is verified, together.
|
|
198
203
|
const pruned = WORKSPACE ? pruneForWorkspace(http.routes) : { routes: http.routes, removed: [] };
|
|
199
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Establish WHOSE request this is, once, at the only place every request passes
|
|
206
|
+
* through — Cloud Phase 27 D3.
|
|
207
|
+
*
|
|
208
|
+
* Bun matches `routes` BEFORE `fetch`, so a wrapper on the fall-through alone
|
|
209
|
+
* would miss the entire route table (the same asymmetry that made a
|
|
210
|
+
* canvas-origin route 404 in Phase 23). Wrapping here covers both halves, and
|
|
211
|
+
* the leaves — `canvasViewPath`, `canvasStatePath` — read the ambient key
|
|
212
|
+
* instead of every function between here and there growing a parameter.
|
|
213
|
+
*
|
|
214
|
+
* Outside workspace mode this resolves to `''` on every request, which
|
|
215
|
+
* `runInSession` treats as "no scope at all" and skips.
|
|
216
|
+
*/
|
|
217
|
+
function withSession<T extends unknown[]>(
|
|
218
|
+
handler: (req: Request, ...rest: T) => Response | Promise<Response>
|
|
219
|
+
): (req: Request, ...rest: T) => Response | Promise<Response> {
|
|
220
|
+
return (req, ...rest) =>
|
|
221
|
+
runInSession(WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '', () =>
|
|
222
|
+
handler(req, ...rest)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Bun route entries are either a handler or a `{ GET, POST, … }` map. */
|
|
227
|
+
function scopeRoutes<R extends Record<string, unknown>>(routes: R): R {
|
|
228
|
+
const out: Record<string, unknown> = {};
|
|
229
|
+
for (const [path, entry] of Object.entries(routes)) {
|
|
230
|
+
if (typeof entry === 'function') {
|
|
231
|
+
out[path] = withSession(entry as (req: Request) => Response | Promise<Response>);
|
|
232
|
+
} else if (entry && typeof entry === 'object') {
|
|
233
|
+
const byMethod: Record<string, unknown> = {};
|
|
234
|
+
for (const [method, fn] of Object.entries(entry as Record<string, unknown>)) {
|
|
235
|
+
byMethod[method] =
|
|
236
|
+
typeof fn === 'function'
|
|
237
|
+
? withSession(fn as (req: Request) => Response | Promise<Response>)
|
|
238
|
+
: fn;
|
|
239
|
+
}
|
|
240
|
+
out[path] = byMethod;
|
|
241
|
+
} else {
|
|
242
|
+
out[path] = entry;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out as R;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const SERVER_ROUTES = scopeRoutes(pruned.routes) as typeof http.routes;
|
|
200
249
|
if (WORKSPACE && pruned.removed.length > 0) {
|
|
201
250
|
console.log(
|
|
202
251
|
`[studio] workspace mode — withheld ${pruned.removed.length} route(s) that would evaluate ` +
|
|
@@ -265,9 +314,20 @@ function startServer(port: number): BunServer {
|
|
|
265
314
|
// Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
|
|
266
315
|
// DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
|
|
267
316
|
// a `--bind 0.0.0.0` flag on this server.
|
|
317
|
+
//
|
|
318
|
+
// IN A CELL the loopback gate can never pass — the hub's authenticating
|
|
319
|
+
// proxy rewrites Host to the public URL (D4) on purpose — and it must not
|
|
320
|
+
// have to: the proxy already terminated the session and vouches the role
|
|
321
|
+
// per request with injected `x-maude-*` headers (stripped-then-injected,
|
|
322
|
+
// the Cloud Phase 27 model the inspector branch below already trusts).
|
|
323
|
+
// Without this vouched path every cloud collab upgrade 403'd here and
|
|
324
|
+
// cursors/annotations/live-sync were dead in the browser door — RCA
|
|
325
|
+
// issue-cloud-live-collaboration-dead. Outside workspace mode the DDR-047
|
|
326
|
+
// gate is verbatim unchanged.
|
|
268
327
|
const collabSlug = parseCollabSlug(pathname);
|
|
269
328
|
if (collabSlug !== null) {
|
|
270
|
-
|
|
329
|
+
const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
|
|
330
|
+
if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
|
|
271
331
|
return new Response('cross-machine collab requires Phase 9 hub deploy', {
|
|
272
332
|
status: 403,
|
|
273
333
|
});
|
|
@@ -278,8 +338,15 @@ function startServer(port: number): BunServer {
|
|
|
278
338
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
279
339
|
kind: 'collab',
|
|
280
340
|
slug: collabSlug,
|
|
281
|
-
// Privileged shell origin — ungated (see collab/origins.ts)
|
|
282
|
-
realm
|
|
341
|
+
// Privileged shell origin — ungated (see collab/origins.ts) —
|
|
342
|
+
// UNLESS the proxy marked this socket as canvas-realm (a cell's
|
|
343
|
+
// canvas lane forwards here when the canvas listener is the same
|
|
344
|
+
// process; defense in depth either way).
|
|
345
|
+
realm:
|
|
346
|
+
WORKSPACE && req.headers.get('x-maude-collab-realm') === 'canvas' ? 'canvas' : 'main',
|
|
347
|
+
// Same fail-closed posture as the inspector branch: in a cell an
|
|
348
|
+
// absent header is an unproven session.
|
|
349
|
+
readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
|
|
283
350
|
},
|
|
284
351
|
});
|
|
285
352
|
if (ok) return undefined as unknown as Response;
|
|
@@ -319,6 +386,9 @@ function startServer(port: number): BunServer {
|
|
|
319
386
|
id: crypto.randomUUID(),
|
|
320
387
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
321
388
|
kind: 'inspector',
|
|
389
|
+
// D3 — whose socket this is, from the proxy's vouched header. Same
|
|
390
|
+
// handshake-time reasoning as `readOnly` below; `''` on a desktop.
|
|
391
|
+
session: WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '',
|
|
322
392
|
// Cloud Phase 27 — stamp the role onto the socket at the handshake,
|
|
323
393
|
// the one moment the session is unambiguous. Fails CLOSED in a cell
|
|
324
394
|
// for the same reason the HTTP gate does: an absent header is an
|
|
@@ -330,7 +400,8 @@ function startServer(port: number): BunServer {
|
|
|
330
400
|
if (ok) return undefined as unknown as Response;
|
|
331
401
|
return new Response('Upgrade failed', { status: 400 });
|
|
332
402
|
}
|
|
333
|
-
|
|
403
|
+
// D3 — the fall-through half of the same scope the route table gets above.
|
|
404
|
+
return withSession(http.fetch)(req);
|
|
334
405
|
},
|
|
335
406
|
websocket: ws.handler,
|
|
336
407
|
error(e) {
|
|
@@ -382,10 +453,15 @@ function startCanvasServer(port: number): BunServer {
|
|
|
382
453
|
async fetch(req, srv) {
|
|
383
454
|
const pathname = new URL(req.url).pathname;
|
|
384
455
|
|
|
385
|
-
// Collab WS — shared registry, loopback-only (same gate as the main
|
|
456
|
+
// Collab WS — shared registry, loopback-only (same gate as the main
|
|
457
|
+
// origin), with the same workspace-mode vouched path: in a cell the hub's
|
|
458
|
+
// canvas lane forwards the iframe's collab socket HERE, capability-
|
|
459
|
+
// authenticated, with Host rewritten to the public canvas origin (D4) —
|
|
460
|
+
// RCA issue-cloud-live-collaboration-dead.
|
|
386
461
|
const collabSlug = parseCollabSlug(pathname);
|
|
387
462
|
if (collabSlug !== null) {
|
|
388
|
-
|
|
463
|
+
const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
|
|
464
|
+
if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
|
|
389
465
|
return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
|
|
390
466
|
}
|
|
391
467
|
const ok = srv.upgrade(req, {
|
|
@@ -396,8 +472,13 @@ function startCanvasServer(port: number): BunServer {
|
|
|
396
472
|
slug: collabSlug,
|
|
397
473
|
// UNTRUSTED canvas iframe origin (DDR-063 split). Every sync frame
|
|
398
474
|
// from here goes through the origin gate and may never write a
|
|
399
|
-
// body lane — DDR-122 follow-up, collab/origins.ts.
|
|
475
|
+
// body lane — DDR-122 follow-up, collab/origins.ts. ALWAYS
|
|
476
|
+
// 'canvas' on this listener — the proxy's realm marker is not
|
|
477
|
+
// consulted, so a forged header cannot promote the socket.
|
|
400
478
|
realm: 'canvas',
|
|
479
|
+
// Fail closed in a cell (absent header = unproven session);
|
|
480
|
+
// loopback desktop keeps full capability as before.
|
|
481
|
+
readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
|
|
401
482
|
},
|
|
402
483
|
});
|
|
403
484
|
if (ok) return undefined as unknown as Response;
|
|
@@ -483,7 +564,23 @@ let server: BunServer;
|
|
|
483
564
|
// listed: the server binds 127.0.0.1, so a user who opens the printed URL as
|
|
484
565
|
// `127.0.0.1:<port>` is the same legit embedder — with only `localhost` allowed
|
|
485
566
|
// the canvas iframe was silently refused (blank sad-page, no error anywhere).
|
|
486
|
-
|
|
567
|
+
//
|
|
568
|
+
// D4 AGAIN — the PUBLIC name, not the bound one. In a cell the parent frame is
|
|
569
|
+
// `https://<project>.cloud.maude.sh`; listing only the loopback origins made the
|
|
570
|
+
// browser refuse to render the canvas iframe at all ("refused to connect"),
|
|
571
|
+
// which looks like a network failure and is a CSP one. The loopback spellings
|
|
572
|
+
// stay for local tooling (screenshots, smoke) — a cell simply has one more legit
|
|
573
|
+
// embedder, and naming it is configuration, never a Host header.
|
|
574
|
+
const publicShellOrigin = (() => {
|
|
575
|
+
const url = process.env.HUB_PUBLIC_URL;
|
|
576
|
+
if (!url) return '';
|
|
577
|
+
try {
|
|
578
|
+
return ` ${new URL(url).origin}`;
|
|
579
|
+
} catch {
|
|
580
|
+
return '';
|
|
581
|
+
}
|
|
582
|
+
})();
|
|
583
|
+
ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}`;
|
|
487
584
|
|
|
488
585
|
// T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
|
|
489
586
|
// phase-9.1: a second listener binds an OS-assigned free port, advertised as
|