@1agh/maude 0.52.2 → 0.53.1
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/bin/_import-asset.mjs +1 -6
- package/apps/studio/bin/_import-brand.mjs +1 -1
- package/apps/studio/bin/_video-playwright.mjs +5 -1
- package/apps/studio/canvas-build.ts +91 -0
- package/apps/studio/canvas-lib.tsx +12 -1
- package/apps/studio/canvas-shell.tsx +60 -11
- package/apps/studio/client/app.jsx +183 -98
- package/apps/studio/client/canvas-url.js +6 -0
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/comments-overlay.tsx +59 -51
- package/apps/studio/dist/client.bundle.js +1266 -1266
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +105 -4
- package/apps/studio/read-only-mode.ts +36 -0
- package/apps/studio/test/canvas-url.test.ts +13 -0
- package/apps/studio/test/read-only-gate.test.ts +130 -0
- package/apps/studio/test/report-proxy.test.ts +125 -0
- package/apps/studio/test/use-tool-mode.test.tsx +23 -0
- package/apps/studio/tool-palette.tsx +65 -51
- package/apps/studio/use-keyboard-discipline.tsx +6 -0
- package/apps/studio/use-tool-mode.tsx +37 -7
- package/apps/studio/whats-new.json +28 -1
- package/cli/bin/maude.mjs +0 -1
- package/package.json +8 -8
- package/cli/commands/share.mjs +0 -188
- package/cli/lib/share-plan.mjs +0 -100
- package/cli/lib/share-plan.test.mjs +0 -66
package/apps/studio/http.ts
CHANGED
|
@@ -291,6 +291,55 @@ export function sameOriginRead(req: Request): boolean {
|
|
|
291
291
|
return site === 'same-origin' || site === 'none';
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
// Cloud Phase 25 C2 — the local half of the read-only gate.
|
|
296
|
+
//
|
|
297
|
+
// When the project is linked to a hub that vouched a `viewer` role at sign-in
|
|
298
|
+
// (isHubReadOnly), the LOCAL dev-server refuses project-mutating writes too.
|
|
299
|
+
// The cell is the authority (Phase 25 C1 — it refuses whatever a patched
|
|
300
|
+
// client sends); this gate exists so a viewer's local clone never DIVERGES
|
|
301
|
+
// from the hub: a local write the cell would refuse to sync is a silent fork,
|
|
302
|
+
// which is worse than a refusal. Mirrors C1's posture — writes are
|
|
303
|
+
// default-denied with a short, explicit allowlist.
|
|
304
|
+
//
|
|
305
|
+
// What a read-only session may still write (per-user runtime state per
|
|
306
|
+
// DDR-115, plus the two role-granted verbs the cell itself allows —
|
|
307
|
+
// export ≈ `/api/export`, session management ≈ `/auth/logout`):
|
|
308
|
+
const READ_ONLY_SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
309
|
+
export const READ_ONLY_ALLOWED_WRITES = new Set([
|
|
310
|
+
'/_hmr', // build-watch rebuild hint — dev plumbing, not project state
|
|
311
|
+
'/_canvas-state', // per-user camera / view state (DDR-115: never versioned)
|
|
312
|
+
'/_api/canvas-meta', // viewport lane only — the layout lane is refused in-handler
|
|
313
|
+
'/_api/ui-prefs', // per-user UI preferences
|
|
314
|
+
'/_api/timeline-media', // per-user runtime media cache (scrub read path)
|
|
315
|
+
'/_api/export', // "look, comment and download" — the cell allows /api/export too
|
|
316
|
+
'/_api/export-jobs',
|
|
317
|
+
'/_api/export-jobs/download',
|
|
318
|
+
'/_api/report', // bug reports are about Maude, not the project
|
|
319
|
+
'/_api/report-fallback',
|
|
320
|
+
'/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
|
|
321
|
+
'/_api/workspace/sign-in', // signing in is how the role is (re)learned
|
|
322
|
+
'/_api/workspace/disclosure',
|
|
323
|
+
'/_api/cloud/signin/start', // account session, not project state
|
|
324
|
+
'/_api/cloud/signin/poll',
|
|
325
|
+
'/_api/cloud/signout',
|
|
326
|
+
'/_api/git/fetch', // syncing the project DOWN is a read of the project
|
|
327
|
+
'/_api/git/pull',
|
|
328
|
+
'/_api/git/checkout', // viewing another branch, not changing one
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
/** The refusal a read-only session gets for a project-mutating write. */
|
|
332
|
+
export function readOnlyRefusalResponse(): Response {
|
|
333
|
+
return Response.json(
|
|
334
|
+
{
|
|
335
|
+
error: 'read-only',
|
|
336
|
+
reason: 'read-only',
|
|
337
|
+
message: 'Your role in this project is viewer — editing is disabled.',
|
|
338
|
+
},
|
|
339
|
+
{ status: 403, headers: { 'Cache-Control': 'no-store' } }
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
294
343
|
function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
|
|
295
344
|
let pathname: string;
|
|
296
345
|
try {
|
|
@@ -1173,7 +1222,7 @@ export function createHttp(
|
|
|
1173
1222
|
Response.json({
|
|
1174
1223
|
...ctx.cfg,
|
|
1175
1224
|
canvasOrigin: ctx.canvasOrigin,
|
|
1176
|
-
readOnly:
|
|
1225
|
+
readOnly: projectReadOnly(),
|
|
1177
1226
|
}),
|
|
1178
1227
|
|
|
1179
1228
|
// What's New feed (DDR-A) — read-only product-update list surfaced in the
|
|
@@ -1214,7 +1263,13 @@ export function createHttp(
|
|
|
1214
1263
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1215
1264
|
const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
|
|
1216
1265
|
try {
|
|
1217
|
-
|
|
1266
|
+
// `globalThis.fetch` — NOT bare `fetch`: this module declares its own
|
|
1267
|
+
// `async function fetch(req: Request)` (the server fall-through handler)
|
|
1268
|
+
// in the same scope, which shadows the global. A bare call re-entered the
|
|
1269
|
+
// fall-through with a URL string, so `new URL(req.url)` threw
|
|
1270
|
+
// `"undefined" cannot be parsed as a URL` and every report died with a
|
|
1271
|
+
// plain-text HTTP 500 without ever leaving the machine.
|
|
1272
|
+
const upstream = await globalThis.fetch(endpoint, {
|
|
1218
1273
|
method: 'POST',
|
|
1219
1274
|
body: await req.formData(),
|
|
1220
1275
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -1311,6 +1366,14 @@ export function createHttp(
|
|
|
1311
1366
|
if (!body.patch || typeof body.patch !== 'object') {
|
|
1312
1367
|
return new Response('body.patch must be an object', { status: 400 });
|
|
1313
1368
|
}
|
|
1369
|
+
// Cloud Phase 25 C2 — this route is on READ_ONLY_ALLOWED_WRITES for its
|
|
1370
|
+
// VIEWPORT lane only (per-user camera, split into `_canvas-state/` by
|
|
1371
|
+
// the api layer — DDR-115). The layout lane mutates the versioned
|
|
1372
|
+
// `.meta.json`, so a read-only session is refused here, in-handler,
|
|
1373
|
+
// where the two lanes are distinguishable.
|
|
1374
|
+
if (projectReadOnly() && 'layout' in body.patch) {
|
|
1375
|
+
return readOnlyRefusalResponse();
|
|
1376
|
+
}
|
|
1314
1377
|
const next = await api.patchCanvasMeta(body.file, body.patch);
|
|
1315
1378
|
if (!next) return new Response('Not found or rejected', { status: 404 });
|
|
1316
1379
|
return Response.json(next, { headers: { 'Cache-Control': 'no-store' } });
|
|
@@ -3891,7 +3954,11 @@ export function createHttp(
|
|
|
3891
3954
|
'/index.html': () => serveFile(join(CLIENT_DIR, 'index.html')),
|
|
3892
3955
|
} satisfies Record<string, (req: Request) => Response | Promise<Response>>;
|
|
3893
3956
|
|
|
3894
|
-
|
|
3957
|
+
// Named `handleFallthrough`, not `fetch` — a same-named local function shadows
|
|
3958
|
+
// the global `fetch` for every call site in this module's scope, which is how
|
|
3959
|
+
// the `/_api/report` route ended up calling itself instead of the network (see
|
|
3960
|
+
// the `globalThis.fetch` comment on that route; RCA issue-report-a-bug-http-500).
|
|
3961
|
+
async function handleFallthrough(req: Request): Promise<Response> {
|
|
3895
3962
|
try {
|
|
3896
3963
|
const url = new URL(req.url);
|
|
3897
3964
|
const pathname = url.pathname;
|
|
@@ -4254,5 +4321,39 @@ export function createHttp(
|
|
|
4254
4321
|
return false;
|
|
4255
4322
|
}
|
|
4256
4323
|
|
|
4257
|
-
|
|
4324
|
+
// Cloud Phase 25 C2 — read-only gate over every write surface (~42 mutating
|
|
4325
|
+
// routes). Computed per-request so a role change lands on the next write
|
|
4326
|
+
// (workspace sign-in rewrites hubs.json; no restart needed). Wrapping here
|
|
4327
|
+
// covers all three doors at once: the main-origin `routes` table, the
|
|
4328
|
+
// canvas-origin `routes` allowlist in server.ts (it references these same
|
|
4329
|
+
// handlers), and the dynamic-path `fetch` fall-through (comment replies).
|
|
4330
|
+
function projectReadOnly(): boolean {
|
|
4331
|
+
return ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
function readOnlyRefusal(req: Request): Response | null {
|
|
4335
|
+
if (READ_ONLY_SAFE_METHODS.has(req.method)) return null;
|
|
4336
|
+
if (!projectReadOnly()) return null;
|
|
4337
|
+
let pathname: string;
|
|
4338
|
+
try {
|
|
4339
|
+
pathname = new URL(req.url).pathname;
|
|
4340
|
+
} catch {
|
|
4341
|
+
return readOnlyRefusalResponse();
|
|
4342
|
+
}
|
|
4343
|
+
if (READ_ONLY_ALLOWED_WRITES.has(pathname)) return null;
|
|
4344
|
+
return readOnlyRefusalResponse();
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
const guardedRoutes = Object.fromEntries(
|
|
4348
|
+
Object.entries(routes).map(([path, handler]) => [
|
|
4349
|
+
path,
|
|
4350
|
+
(req: Request) => readOnlyRefusal(req) ?? handler(req),
|
|
4351
|
+
])
|
|
4352
|
+
) as typeof routes;
|
|
4353
|
+
|
|
4354
|
+
async function guardedFetch(req: Request): Promise<Response> {
|
|
4355
|
+
return readOnlyRefusal(req) ?? handleFallthrough(req);
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
return { routes: guardedRoutes, fetch: guardedFetch, serveCanvasShell, isCanvasSafeRoute };
|
|
4258
4359
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file read-only-mode.ts — Cloud Phase 25 C2
|
|
3
|
+
* @scope apps/studio/read-only-mode.ts
|
|
4
|
+
* @purpose One boot-static answer to "is this canvas read-only?" for every
|
|
5
|
+
* module that runs inside the canvas iframe.
|
|
6
|
+
*
|
|
7
|
+
* The shell appends `?ro=1` to the canvas URL (client/canvas-url.js) when
|
|
8
|
+
* `/_config.readOnly` is true — i.e. the linked hub vouched a `viewer` role at
|
|
9
|
+
* workspace sign-in (sync/hubs-config.ts). Boot-static on purpose: a role is
|
|
10
|
+
* per-session, and reading the URL can't race the way a post-load postMessage
|
|
11
|
+
* could (during which a viewer could still drag something).
|
|
12
|
+
*
|
|
13
|
+
* This flag decides what the canvas chrome OFFERS — tools, drag handles,
|
|
14
|
+
* inline edit, context menus. It never STOPS a write; that is the cell
|
|
15
|
+
* (Phase 25 C1) and the dev-server's read-only gate (http.ts), both of which
|
|
16
|
+
* hold whatever this returns.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let cached: boolean | null = null;
|
|
20
|
+
|
|
21
|
+
export function isReadOnlyCanvas(): boolean {
|
|
22
|
+
if (typeof window === 'undefined') return false;
|
|
23
|
+
if (cached === null) {
|
|
24
|
+
try {
|
|
25
|
+
cached = new URLSearchParams(window.location.search).get('ro') === '1';
|
|
26
|
+
} catch {
|
|
27
|
+
cached = false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test seam — reset the module cache (bun:test re-uses the module registry). */
|
|
34
|
+
export function _resetReadOnlyCache(): void {
|
|
35
|
+
cached = null;
|
|
36
|
+
}
|
|
@@ -96,4 +96,17 @@ describe('canvasUrl — per-canvas design-system token resolution', () => {
|
|
|
96
96
|
test('non-tsx paths bypass the shell and return a direct URL', () => {
|
|
97
97
|
expect(canvasUrl('.design/ui/legacy.html', cfg)).toBe(urlOf('.design/ui/legacy.html'));
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
// Cloud Phase 25 C2 — the viewer role rides into the canvas as ?ro=1 so the
|
|
101
|
+
// iframe chrome is read-only from its very first paint (boot-static, no
|
|
102
|
+
// postMessage race).
|
|
103
|
+
test('cfg.readOnly stamps ro=1; absent/false omits it', () => {
|
|
104
|
+
expect(paramsOf(canvasUrl('.design/ui/Studio.tsx', { ...cfg, readOnly: true })).get('ro')).toBe(
|
|
105
|
+
'1'
|
|
106
|
+
);
|
|
107
|
+
expect(paramsOf(canvasUrl('.design/ui/Studio.tsx', cfg)).get('ro')).toBeNull();
|
|
108
|
+
expect(
|
|
109
|
+
paramsOf(canvasUrl('.design/ui/Studio.tsx', { ...cfg, readOnly: false })).get('ro')
|
|
110
|
+
).toBeNull();
|
|
111
|
+
});
|
|
99
112
|
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Cloud Phase 25 C2 — the local half of the read-only gate.
|
|
2
|
+
//
|
|
3
|
+
// When the linked hub's credential carries the `viewer` role (vouched at
|
|
4
|
+
// workspace sign-in, stored in hubs.json), the LOCAL dev-server refuses
|
|
5
|
+
// project-mutating writes with a legible 403 — so a viewer's clone can never
|
|
6
|
+
// silently diverge from the cell (which refuses the same writes at sync,
|
|
7
|
+
// Phase 25 C1). Writes are default-denied with a short allowlist
|
|
8
|
+
// (READ_ONLY_ALLOWED_WRITES in http.ts): per-user runtime state (camera,
|
|
9
|
+
// ui-prefs) plus the two role-granted verbs the cell itself allows (export,
|
|
10
|
+
// session management).
|
|
11
|
+
|
|
12
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import type { Subprocess } from 'bun';
|
|
16
|
+
|
|
17
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
18
|
+
|
|
19
|
+
const HUB_URL = 'http://hub.invalid:9';
|
|
20
|
+
|
|
21
|
+
/** Sandbox linked to a hub whose credential vouches `role`. */
|
|
22
|
+
function makeLinkedSandbox(role?: string) {
|
|
23
|
+
const { root, designRoot } = makeSandbox();
|
|
24
|
+
const cfgPath = join(designRoot, 'config.json');
|
|
25
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
26
|
+
cfg.linkedHub = { url: HUB_URL, linkedAt: Date.now() };
|
|
27
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
28
|
+
// The hubs.json credential store the role is read from (isHubReadOnly).
|
|
29
|
+
const hubsPath = join(root, 'hubs.json');
|
|
30
|
+
writeFileSync(
|
|
31
|
+
hubsPath,
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
hubs: { [HUB_URL]: { token: 't', linkedAt: Date.now(), ...(role ? { role } : {}) } },
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
return { root, designRoot, hubsPath };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const procs: Subprocess[] = [];
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
for (const p of procs) await killProc(p);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function boot(role?: string) {
|
|
45
|
+
const { root, hubsPath } = makeLinkedSandbox(role);
|
|
46
|
+
const port = nextPort();
|
|
47
|
+
const proc = await bootServer(root, port, { HUBS_CONFIG_PATH: hubsPath });
|
|
48
|
+
procs.push(proc);
|
|
49
|
+
return { base: `http://localhost:${port}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('read-only gate — viewer role refuses project writes locally', () => {
|
|
53
|
+
test('viewer: writes 403 read-only, per-user + role-granted writes pass, /_config says so', async () => {
|
|
54
|
+
const { base } = await boot('viewer');
|
|
55
|
+
|
|
56
|
+
// /_config tells the client at boot.
|
|
57
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { readOnly?: boolean };
|
|
58
|
+
expect(cfg.readOnly).toBe(true);
|
|
59
|
+
|
|
60
|
+
// Project-mutating writes: refused with the legible read-only shape.
|
|
61
|
+
const editText = await fetch(`${base}/_api/edit-text`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ file: 'ui/fixture.html', text: 'x' }),
|
|
65
|
+
});
|
|
66
|
+
expect(editText.status).toBe(403);
|
|
67
|
+
const body = (await editText.json()) as { error?: string; message?: string };
|
|
68
|
+
expect(body.error).toBe('read-only');
|
|
69
|
+
expect(body.message).toContain('viewer');
|
|
70
|
+
|
|
71
|
+
// Canvas create + delete-element + git commit: same refusal.
|
|
72
|
+
for (const [path, method] of [
|
|
73
|
+
['/_api/canvas', 'POST'],
|
|
74
|
+
['/_api/delete-element', 'POST'],
|
|
75
|
+
['/_api/git/commit', 'POST'],
|
|
76
|
+
['/_api/fs-mkdir', 'POST'],
|
|
77
|
+
['/_api/annotations', 'PUT'],
|
|
78
|
+
['/_api/acp/chat', 'POST'],
|
|
79
|
+
] as const) {
|
|
80
|
+
const r = await fetch(base + path, { method, body: '{}' });
|
|
81
|
+
expect(`${path} ${r.status}`).toBe(`${path} 403`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// canvas-meta: the LAYOUT lane is refused…
|
|
85
|
+
const layout = await fetch(`${base}/_api/canvas-meta`, {
|
|
86
|
+
method: 'PATCH',
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
file: '.design/ui/fixture.html',
|
|
90
|
+
patch: { layout: { artboards: [] } },
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
expect(layout.status).toBe(403);
|
|
94
|
+
// …the VIEWPORT lane (per-user camera) is not.
|
|
95
|
+
const viewport = await fetch(`${base}/_api/canvas-meta`, {
|
|
96
|
+
method: 'PATCH',
|
|
97
|
+
headers: { 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
file: '.design/ui/fixture.html',
|
|
100
|
+
patch: { viewport: { x: 0, y: 0, zoom: 1 } },
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
expect(viewport.status).not.toBe(403);
|
|
104
|
+
|
|
105
|
+
// Per-user runtime state stays writable (DDR-115 taxonomy).
|
|
106
|
+
const camera = await fetch(`${base}/_canvas-state`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ file: '.design/ui/fixture.html', viewport: { x: 1, y: 2, zoom: 1 } }),
|
|
110
|
+
});
|
|
111
|
+
expect(camera.status).toBe(204);
|
|
112
|
+
|
|
113
|
+
// Role-granted verb: export reaches its handler (may 400 on shape, never 403).
|
|
114
|
+
const exp = await fetch(`${base}/_api/export`, { method: 'POST', body: '{}' });
|
|
115
|
+
expect(exp.status).not.toBe(403);
|
|
116
|
+
|
|
117
|
+
// Reads are untouched.
|
|
118
|
+
expect((await fetch(`${base}/_index-data`)).status).toBe(200);
|
|
119
|
+
expect((await fetch(`${base}/_comments-all`)).status).toBe(200);
|
|
120
|
+
}, 30000);
|
|
121
|
+
|
|
122
|
+
test('member (no role on the credential): writes are NOT gated', async () => {
|
|
123
|
+
const { base } = await boot(undefined);
|
|
124
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { readOnly?: boolean };
|
|
125
|
+
expect(cfg.readOnly).toBe(false);
|
|
126
|
+
// A write reaches its handler (400 on bad shape — not the 403 refusal).
|
|
127
|
+
const r = await fetch(`${base}/_api/edit-text`, { method: 'POST', body: '{}' });
|
|
128
|
+
expect(r.status).not.toBe(403);
|
|
129
|
+
}, 30000);
|
|
130
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Regression test for issue-report-a-bug-http-500 (RCA:
|
|
2
|
+
// .ai/logs/rca/issue-report-a-bug-http-500.md). `/_api/report` proxies the
|
|
3
|
+
// dialog's multipart body to `MAUDE_REPORT_URL`. A bare `fetch(endpoint, …)`
|
|
4
|
+
// call inside http.ts silently resolved to this module's OWN same-named
|
|
5
|
+
// fall-through handler instead of the global `fetch` — the request never left
|
|
6
|
+
// the machine, and the route mirrored a bogus local 500 back to the client.
|
|
7
|
+
// These tests boot a real server pointed at a real stub upstream, so a
|
|
8
|
+
// reintroduced shadow (or any other break in the proxy hop) fails loudly
|
|
9
|
+
// instead of only being caught by a mocked-upstream unit test.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
15
|
+
|
|
16
|
+
async function bootStubUpstream(
|
|
17
|
+
handler: (req: Request) => Response | Promise<Response>
|
|
18
|
+
): Promise<{ url: string; stop: () => void; received: () => number }> {
|
|
19
|
+
let received = 0;
|
|
20
|
+
const server = Bun.serve({
|
|
21
|
+
port: 0,
|
|
22
|
+
fetch: async (req) => {
|
|
23
|
+
received += 1;
|
|
24
|
+
return handler(req);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
29
|
+
stop: () => server.stop(true),
|
|
30
|
+
received: () => received,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('/_api/report proxy hop', () => {
|
|
35
|
+
test('forwards the multipart body verbatim and mirrors a successful upstream response', async () => {
|
|
36
|
+
const { root } = makeSandbox();
|
|
37
|
+
let capturedBytes = 0;
|
|
38
|
+
let capturedHasReportPart = false;
|
|
39
|
+
const upstream = await bootStubUpstream(async (req) => {
|
|
40
|
+
const form = await req.formData();
|
|
41
|
+
capturedHasReportPart = typeof form.get('report') === 'string';
|
|
42
|
+
// Multipart parsing consumed the body — approximate "did real bytes
|
|
43
|
+
// arrive" by re-serializing what FormData kept.
|
|
44
|
+
capturedBytes = [...form.values()].reduce(
|
|
45
|
+
(n, v) => n + (typeof v === 'string' ? v.length : (v as Blob).size),
|
|
46
|
+
0
|
|
47
|
+
);
|
|
48
|
+
return Response.json({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const port = nextPort();
|
|
52
|
+
const proc = await bootServer(root, port, { MAUDE_REPORT_URL: `${upstream.url}/report` });
|
|
53
|
+
try {
|
|
54
|
+
const form = new FormData();
|
|
55
|
+
form.set(
|
|
56
|
+
'report',
|
|
57
|
+
JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
|
|
58
|
+
);
|
|
59
|
+
form.append('screenshot', new Blob([new Uint8Array([1, 2, 3, 4])]), 'shot.png');
|
|
60
|
+
|
|
61
|
+
const res = await fetch(`http://localhost:${port}/_api/report`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
body: form,
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
expect(json).toEqual({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
|
|
68
|
+
|
|
69
|
+
expect(upstream.received()).toBe(1);
|
|
70
|
+
expect(capturedHasReportPart).toBe(true);
|
|
71
|
+
expect(capturedBytes).toBeGreaterThan(0);
|
|
72
|
+
} finally {
|
|
73
|
+
await killProc(proc);
|
|
74
|
+
upstream.stop();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('an unreachable upstream yields 502 with a JSON error, not a bare 500', async () => {
|
|
79
|
+
const { root } = makeSandbox();
|
|
80
|
+
const port = nextPort();
|
|
81
|
+
// Nothing listens on this port — the fetch itself must fail/timeout.
|
|
82
|
+
const proc = await bootServer(root, port, { MAUDE_REPORT_URL: 'http://127.0.0.1:1/report' });
|
|
83
|
+
try {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
form.set(
|
|
86
|
+
'report',
|
|
87
|
+
JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
|
|
88
|
+
);
|
|
89
|
+
const res = await fetch(`http://localhost:${port}/_api/report`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: form,
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(502);
|
|
94
|
+
const json = await res.json();
|
|
95
|
+
expect(json).toEqual({ error: 'report service unreachable' });
|
|
96
|
+
} finally {
|
|
97
|
+
await killProc(proc);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('no bare `fetch(` call sites in http.ts', () => {
|
|
103
|
+
test('every fetch call is either the global fetch (qualified) or a local handler name', () => {
|
|
104
|
+
const src = readFileSync(new URL('../http.ts', import.meta.url), 'utf8');
|
|
105
|
+
// A bare `fetch(` immediately preceded by a word-boundary and NOT preceded
|
|
106
|
+
// by a `.` (member access) or `function ` (declaration) would silently
|
|
107
|
+
// resolve to this file's own same-named fall-through handler instead of
|
|
108
|
+
// the global — exactly the bug this test guards against. `handleFallthrough`
|
|
109
|
+
// is the current, unambiguous name; this assertion also fails if the
|
|
110
|
+
// handler is ever renamed back to something call-site-ambiguous.
|
|
111
|
+
const bareFetchCalls = [...src.matchAll(/(?<![.\w])fetch\(/g)];
|
|
112
|
+
const lines = src.split('\n');
|
|
113
|
+
// Skip: comment lines (mentioning `fetch(` in prose is fine) and the one
|
|
114
|
+
// legitimate bare occurrence — the type-signature parameter name
|
|
115
|
+
// `fetch(req: Request): Promise<Response>;` in the `Api`-shaped interface,
|
|
116
|
+
// which is a declaration, not a call.
|
|
117
|
+
const offendingLines = bareFetchCalls
|
|
118
|
+
.map((m) => src.slice(0, m.index).split('\n').length)
|
|
119
|
+
.map((line) => ({ line, text: lines[line - 1] ?? '' }))
|
|
120
|
+
.filter(({ text }) => !/^\s*(\/\/|\*)/.test(text))
|
|
121
|
+
.filter(({ text }) => !/^\s*fetch\(req: Request\): Promise<Response>;/.test(text));
|
|
122
|
+
|
|
123
|
+
expect(offendingLines).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_TOOLS,
|
|
9
|
+
filterToolsForReadOnly,
|
|
9
10
|
ToolProvider,
|
|
10
11
|
useToolMode,
|
|
11
12
|
useToolModeOptional,
|
|
@@ -131,3 +132,25 @@ describe('use-tool-mode / SSR render', () => {
|
|
|
131
132
|
);
|
|
132
133
|
});
|
|
133
134
|
});
|
|
135
|
+
|
|
136
|
+
// Cloud Phase 25 C2 — read-only tool filtering. The provider applies this to
|
|
137
|
+
// its `tools` when the canvas booted with ?ro=1; the pure function is what the
|
|
138
|
+
// contract pins: navigate/inspect only, every write tool absent.
|
|
139
|
+
describe('use-tool-mode / read-only filter', () => {
|
|
140
|
+
test('read-only keeps exactly browse + move + hand, in order', () => {
|
|
141
|
+
expect(filterToolsForReadOnly(DEFAULT_TOOLS, true).map((t) => t.id)).toEqual([
|
|
142
|
+
'browse',
|
|
143
|
+
'move',
|
|
144
|
+
'hand',
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('comment is filtered too — the cell refuses viewer comments until C3', () => {
|
|
149
|
+
const ids = filterToolsForReadOnly(DEFAULT_TOOLS, true).map((t) => t.id);
|
|
150
|
+
expect(ids).not.toContain('comment');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('readOnly=false is identity', () => {
|
|
154
|
+
expect(filterToolsForReadOnly(DEFAULT_TOOLS, false)).toEqual([...DEFAULT_TOOLS]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
SHAPE_KIND_ICONS,
|
|
23
23
|
TOOL_ICONS,
|
|
24
24
|
} from './canvas-icons.tsx';
|
|
25
|
+
import { isReadOnlyCanvas } from './read-only-mode.ts';
|
|
25
26
|
import { useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
26
27
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
27
28
|
|
|
@@ -395,6 +396,11 @@ export function ToolPalette() {
|
|
|
395
396
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
|
396
397
|
const navList = NAV_TOOLS.map((id) => byId.get(id)).filter(Boolean);
|
|
397
398
|
const drawList = DRAW_TOOLS.map((id) => byId.get(id)).filter(Boolean);
|
|
399
|
+
// Cloud Phase 25 C2 — read-only canvas: the draw group is already empty
|
|
400
|
+
// (ToolProvider filters `tools`), and the hardcoded write affordances
|
|
401
|
+
// (Insert +, Stickers) must be ABSENT too. Export + Presentation stay —
|
|
402
|
+
// both are reads of the project.
|
|
403
|
+
const readOnly = isReadOnlyCanvas();
|
|
398
404
|
|
|
399
405
|
const renderToolButton = (id: string, label: string, shortcut: string) => {
|
|
400
406
|
const Icon = TOOL_ICONS[id];
|
|
@@ -483,66 +489,74 @@ export function ToolPalette() {
|
|
|
483
489
|
<div className="dc-tp-group">
|
|
484
490
|
{navList.map((t) => (t ? renderToolButton(t.id, t.label, t.shortcut) : null))}
|
|
485
491
|
</div>
|
|
492
|
+
{drawList.length > 0 ? (
|
|
493
|
+
<>
|
|
494
|
+
<div className="dc-tp-sep" />
|
|
495
|
+
<div className="dc-tp-group">
|
|
496
|
+
{drawList.map((t) =>
|
|
497
|
+
t
|
|
498
|
+
? t.id === 'shape'
|
|
499
|
+
? renderShapeButton()
|
|
500
|
+
: renderToolButton(t.id, t.label, t.shortcut)
|
|
501
|
+
: null
|
|
502
|
+
)}
|
|
503
|
+
</div>
|
|
504
|
+
</>
|
|
505
|
+
) : null}
|
|
486
506
|
<div className="dc-tp-sep" />
|
|
487
507
|
<div className="dc-tp-group">
|
|
488
|
-
{
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
508
|
+
{!readOnly && (
|
|
509
|
+
<span className="dc-tp-insert">
|
|
510
|
+
<button
|
|
511
|
+
type="button"
|
|
512
|
+
aria-label="Insert element — Div, Text, or Image"
|
|
513
|
+
aria-haspopup="menu"
|
|
514
|
+
aria-expanded={insertOpen}
|
|
515
|
+
title="Insert element (appends to the active artboard)"
|
|
516
|
+
onClick={() => setInsertOpen((o) => !o)}
|
|
517
|
+
>
|
|
518
|
+
<span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
|
|
519
|
+
+
|
|
520
|
+
</span>
|
|
521
|
+
</button>
|
|
522
|
+
{insertOpen ? (
|
|
523
|
+
<div className="dc-tp-insert-popover" role="menu" aria-label="Insert element">
|
|
524
|
+
<button type="button" role="menuitem" onClick={() => insertViaPalette('div')}>
|
|
525
|
+
<IconSquare />
|
|
526
|
+
<span className="dc-tp-insert-label">Div</span>
|
|
527
|
+
</button>
|
|
528
|
+
<button type="button" role="menuitem" onClick={() => insertViaPalette('text')}>
|
|
529
|
+
<IconLetterA />
|
|
530
|
+
<span className="dc-tp-insert-label">Text</span>
|
|
531
|
+
</button>
|
|
532
|
+
<button type="button" role="menuitem" onClick={() => insertViaPalette('image')}>
|
|
533
|
+
<span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>
|
|
534
|
+
▧
|
|
535
|
+
</span>
|
|
536
|
+
<span className="dc-tp-insert-label">Image</span>
|
|
537
|
+
</button>
|
|
538
|
+
</div>
|
|
539
|
+
) : null}
|
|
540
|
+
</span>
|
|
494
541
|
)}
|
|
495
|
-
|
|
496
|
-
<div className="dc-tp-sep" />
|
|
497
|
-
<div className="dc-tp-group">
|
|
498
|
-
<span className="dc-tp-insert">
|
|
542
|
+
{!readOnly && (
|
|
499
543
|
<button
|
|
500
544
|
type="button"
|
|
501
|
-
aria-label="
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
545
|
+
aria-label="Stickers"
|
|
546
|
+
title="Stickers — searchable sticker picker"
|
|
547
|
+
onClick={() => {
|
|
548
|
+
try {
|
|
549
|
+
window.parent.postMessage({ dgn: 'open-sticker-picker' }, '*');
|
|
550
|
+
} catch {
|
|
551
|
+
/* detached / cross-origin teardown */
|
|
552
|
+
}
|
|
553
|
+
}}
|
|
506
554
|
>
|
|
507
555
|
<span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
|
|
508
|
-
|
|
556
|
+
🙂
|
|
509
557
|
</span>
|
|
510
558
|
</button>
|
|
511
|
-
|
|
512
|
-
<div className="dc-tp-insert-popover" role="menu" aria-label="Insert element">
|
|
513
|
-
<button type="button" role="menuitem" onClick={() => insertViaPalette('div')}>
|
|
514
|
-
<IconSquare />
|
|
515
|
-
<span className="dc-tp-insert-label">Div</span>
|
|
516
|
-
</button>
|
|
517
|
-
<button type="button" role="menuitem" onClick={() => insertViaPalette('text')}>
|
|
518
|
-
<IconLetterA />
|
|
519
|
-
<span className="dc-tp-insert-label">Text</span>
|
|
520
|
-
</button>
|
|
521
|
-
<button type="button" role="menuitem" onClick={() => insertViaPalette('image')}>
|
|
522
|
-
<span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>
|
|
523
|
-
▧
|
|
524
|
-
</span>
|
|
525
|
-
<span className="dc-tp-insert-label">Image</span>
|
|
526
|
-
</button>
|
|
527
|
-
</div>
|
|
528
|
-
) : null}
|
|
529
|
-
</span>
|
|
530
|
-
<button
|
|
531
|
-
type="button"
|
|
532
|
-
aria-label="Stickers"
|
|
533
|
-
title="Stickers — searchable sticker picker"
|
|
534
|
-
onClick={() => {
|
|
535
|
-
try {
|
|
536
|
-
window.parent.postMessage({ dgn: 'open-sticker-picker' }, '*');
|
|
537
|
-
} catch {
|
|
538
|
-
/* detached / cross-origin teardown */
|
|
539
|
-
}
|
|
540
|
-
}}
|
|
541
|
-
>
|
|
542
|
-
<span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
|
|
543
|
-
🙂
|
|
544
|
-
</span>
|
|
545
|
-
</button>
|
|
559
|
+
)}
|
|
546
560
|
<button
|
|
547
561
|
type="button"
|
|
548
562
|
aria-label="Export (⌘E)"
|