@1agh/maude 0.51.0 → 0.52.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 +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +849 -121
- package/apps/studio/client/github.js +2 -0
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +590 -0
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +1 -0
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/server.ts +7 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +28 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- package/package.json +8 -8
|
@@ -22,13 +22,39 @@ import { dirname, join } from 'node:path';
|
|
|
22
22
|
import { saveHubCredential } from '../sync/hub-link.ts';
|
|
23
23
|
import { normalizeUrl } from '../sync/hubs-config.ts';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Where Maude Cloud lives, resolved PER CALL rather than at module load.
|
|
27
|
+
*
|
|
28
|
+
* A module-level read baked whatever `MAUDE_CLOUD_URL` happened to be at
|
|
29
|
+
* import time — so in a shared test process the first importer decided the
|
|
30
|
+
* address for everyone (the e2e/unit stub silently talked to production and
|
|
31
|
+
* the assertion failed 410), and the desktop shell could not set the env
|
|
32
|
+
* after the module graph was already warm.
|
|
33
|
+
*/
|
|
34
|
+
function cloudUrl(): string {
|
|
35
|
+
return (process.env.MAUDE_CLOUD_URL ?? 'https://cloud.maude.sh').replace(/\/+$/, '');
|
|
36
|
+
}
|
|
26
37
|
|
|
27
38
|
export interface CloudEndpointResult {
|
|
28
39
|
status: number;
|
|
29
40
|
json: unknown;
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
/** Whatever the control plane answered. Read defensively at every use. */
|
|
44
|
+
type CloudBody = Record<string, unknown> & {
|
|
45
|
+
token?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
role?: string;
|
|
48
|
+
project?: string;
|
|
49
|
+
user_code?: string;
|
|
50
|
+
device_code?: string;
|
|
51
|
+
verification_url?: string;
|
|
52
|
+
interval?: number;
|
|
53
|
+
projects?: unknown[];
|
|
54
|
+
account?: { email?: string };
|
|
55
|
+
error?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
32
58
|
interface CloudFile {
|
|
33
59
|
url: string;
|
|
34
60
|
token: string;
|
|
@@ -68,11 +94,11 @@ async function cloudFetch(
|
|
|
68
94
|
path: string,
|
|
69
95
|
init: RequestInit = {},
|
|
70
96
|
{ timeoutMs = 15_000 } = {}
|
|
71
|
-
): Promise<{ status: number; body:
|
|
97
|
+
): Promise<{ status: number; body: CloudBody }> {
|
|
72
98
|
const ctl = new AbortController();
|
|
73
99
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
74
100
|
try {
|
|
75
|
-
const res = await fetch(`${
|
|
101
|
+
const res = await fetch(`${cloudUrl()}${path}`, { ...init, signal: ctl.signal });
|
|
76
102
|
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
77
103
|
} catch (err) {
|
|
78
104
|
return { status: 0, body: { error: (err as Error).message } };
|
|
@@ -94,7 +120,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
94
120
|
status: 200,
|
|
95
121
|
json: file
|
|
96
122
|
? { connected: true, email: file.email ?? null, url: file.url }
|
|
97
|
-
: { connected: false, url:
|
|
123
|
+
: { connected: false, url: cloudUrl() },
|
|
98
124
|
};
|
|
99
125
|
},
|
|
100
126
|
|
|
@@ -106,7 +132,10 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
106
132
|
body: JSON.stringify({ client: `Maude Studio on ${process.platform}` }),
|
|
107
133
|
});
|
|
108
134
|
if (r.status !== 200) {
|
|
109
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
status: 502,
|
|
137
|
+
json: { ok: false, error: 'Maude Cloud could not be reached. Try again in a moment.' },
|
|
138
|
+
};
|
|
110
139
|
}
|
|
111
140
|
return {
|
|
112
141
|
status: 200,
|
|
@@ -130,15 +159,21 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
130
159
|
});
|
|
131
160
|
if (r.status === 202) return { status: 200, json: { ok: true, pending: true } };
|
|
132
161
|
if (r.status !== 200 || !r.body?.token) {
|
|
133
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
status: 410,
|
|
164
|
+
json: { ok: false, error: 'The code expired. Start the sign-in again.' },
|
|
165
|
+
};
|
|
134
166
|
}
|
|
135
167
|
writeCloudFile({
|
|
136
|
-
url:
|
|
168
|
+
url: cloudUrl(),
|
|
137
169
|
token: r.body.token,
|
|
138
170
|
email: r.body.account?.email ?? undefined,
|
|
139
171
|
connectedAt: Date.now(),
|
|
140
172
|
});
|
|
141
|
-
return {
|
|
173
|
+
return {
|
|
174
|
+
status: 200,
|
|
175
|
+
json: { ok: true, pending: false, email: r.body.account?.email ?? null },
|
|
176
|
+
};
|
|
142
177
|
},
|
|
143
178
|
|
|
144
179
|
/** Forget the local credential. (Revocation lives on /account.) */
|
|
@@ -154,14 +189,18 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
154
189
|
/** The signed-in account's projects, states included. */
|
|
155
190
|
async projects(): Promise<CloudEndpointResult> {
|
|
156
191
|
const file = readCloudFile();
|
|
157
|
-
if (!file)
|
|
192
|
+
if (!file)
|
|
193
|
+
return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
|
|
158
194
|
const r = await cloudFetch('/api/projects', {
|
|
159
195
|
headers: { authorization: `Bearer ${file.token}` },
|
|
160
196
|
});
|
|
161
197
|
if (r.status === 401) {
|
|
162
198
|
// Revoked from the dashboard — honor it locally too.
|
|
163
199
|
this.signout();
|
|
164
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
status: 401,
|
|
202
|
+
json: { ok: false, error: 'This device was disconnected. Sign in again.' },
|
|
203
|
+
};
|
|
165
204
|
}
|
|
166
205
|
if (r.status !== 200) {
|
|
167
206
|
return { status: 502, json: { ok: false, error: 'Maude Cloud could not be reached.' } };
|
|
@@ -176,7 +215,8 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
176
215
|
*/
|
|
177
216
|
async attach(projectId: string): Promise<CloudEndpointResult> {
|
|
178
217
|
const file = readCloudFile();
|
|
179
|
-
if (!file)
|
|
218
|
+
if (!file)
|
|
219
|
+
return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
|
|
180
220
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(projectId ?? ''))) {
|
|
181
221
|
return { status: 400, json: { ok: false, error: 'unknown project' } };
|
|
182
222
|
}
|
|
@@ -187,9 +227,16 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
187
227
|
body: JSON.stringify({ project: projectId }),
|
|
188
228
|
});
|
|
189
229
|
if (opened.status !== 200 || !opened.body?.token) {
|
|
190
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
status: 502,
|
|
232
|
+
json: { ok: false, error: 'The project could not be opened with this account.' },
|
|
233
|
+
};
|
|
191
234
|
}
|
|
192
|
-
return linkToWorkspace({
|
|
235
|
+
return linkToWorkspace({
|
|
236
|
+
workspaceUrl: opened.body.url,
|
|
237
|
+
projectToken: opened.body.token,
|
|
238
|
+
role: opened.body.role,
|
|
239
|
+
});
|
|
193
240
|
},
|
|
194
241
|
|
|
195
242
|
/**
|
|
@@ -199,7 +246,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
199
246
|
* workspace we then link is the one THAT exchange returns. Needs no
|
|
200
247
|
* personal token: the code itself proves the dashboard session.
|
|
201
248
|
*/
|
|
202
|
-
async attachCode(code: string): Promise<CloudEndpointResult> {
|
|
249
|
+
async attachCode(code: string, claimedProject?: string): Promise<CloudEndpointResult> {
|
|
203
250
|
if (typeof code !== 'string' || !/^mhc_[0-9a-f]{16,128}$/.test(code)) {
|
|
204
251
|
return { status: 400, json: { ok: false, error: 'That link is not valid.' } };
|
|
205
252
|
}
|
|
@@ -218,6 +265,24 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
218
265
|
},
|
|
219
266
|
};
|
|
220
267
|
}
|
|
268
|
+
|
|
269
|
+
// The link CLAIMED a project name and the person confirmed THAT name.
|
|
270
|
+
// The exchange is the only authority on which project the code really
|
|
271
|
+
// opens, so the two must agree — otherwise a code minted for the
|
|
272
|
+
// attacker's own project, wrapped in a link naming something familiar,
|
|
273
|
+
// would be confirmed by a victim and then linked, sending their local
|
|
274
|
+
// work to the attacker's workspace. Refuse rather than silently link
|
|
275
|
+
// something the person did not agree to.
|
|
276
|
+
if (claimedProject && exchanged.body.project && claimedProject !== exchanged.body.project) {
|
|
277
|
+
return {
|
|
278
|
+
status: 409,
|
|
279
|
+
json: {
|
|
280
|
+
ok: false,
|
|
281
|
+
error: `That link said ${claimedProject} but it opens ${exchanged.body.project}. Nothing was connected — open the project from its own page instead.`,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
221
286
|
return linkToWorkspace({
|
|
222
287
|
workspaceUrl: exchanged.body.url,
|
|
223
288
|
projectToken: exchanged.body.token,
|
|
@@ -247,13 +312,19 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
247
312
|
headers: { 'content-type': 'application/json' },
|
|
248
313
|
body: JSON.stringify({ token: projectToken }),
|
|
249
314
|
});
|
|
250
|
-
const body
|
|
315
|
+
const body = (await res.json().catch(() => ({}))) as CloudBody;
|
|
251
316
|
if (res.ok && body?.token) hubToken = body.token;
|
|
252
317
|
} catch {
|
|
253
318
|
/* handled below */
|
|
254
319
|
}
|
|
255
320
|
if (!hubToken) {
|
|
256
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
status: 502,
|
|
323
|
+
json: {
|
|
324
|
+
ok: false,
|
|
325
|
+
error: 'The workspace did not accept the sign-in. Try again in a minute.',
|
|
326
|
+
},
|
|
327
|
+
};
|
|
257
328
|
}
|
|
258
329
|
|
|
259
330
|
const norm = normalizeUrl(workspaceUrl);
|
|
@@ -261,7 +332,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
261
332
|
|
|
262
333
|
// Project side — linkedHub in .design/config.json (committed; no token).
|
|
263
334
|
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|
|
264
|
-
let cfg:
|
|
335
|
+
let cfg: Record<string, unknown> = {};
|
|
265
336
|
try {
|
|
266
337
|
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
267
338
|
} catch {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Every secret Maude itself mints, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Written after the 2026-07-30 /flow:validate found the diagnostic scrubber
|
|
4
|
+
// redacting every VENDOR's credentials — GitHub, Anthropic, JWTs, Bearer
|
|
5
|
+
// headers — and none of our own. Two independently-correct decisions composed
|
|
6
|
+
// into the leak:
|
|
7
|
+
//
|
|
8
|
+
// • the project token deliberately is NOT a JWT (DDR-204 / cloud-identity.mjs
|
|
9
|
+
// — one algorithm, nothing to downgrade), so it has TWO dot-separated
|
|
10
|
+
// parts, and
|
|
11
|
+
// • the scrubber's JWT rule correctly requires THREE.
|
|
12
|
+
//
|
|
13
|
+
// So a token that looks exactly like a JWT to any reviewer sailed through a
|
|
14
|
+
// bundle labelled "paths & secrets scrubbed". The `\b(token|secret|…)` rule
|
|
15
|
+
// could not catch `HUB_SECRET=…` either, because `_` is a word character and
|
|
16
|
+
// `\b` never fires between `HUB` and `_SECRET`.
|
|
17
|
+
//
|
|
18
|
+
// The fix is not another regex in the scrubber. It is a REGISTRY: whoever adds
|
|
19
|
+
// a new minted grammar adds it here, and every consumer that must recognise a
|
|
20
|
+
// Maude credential imports it instead of guessing. `credential-grammar.test.ts`
|
|
21
|
+
// asserts the registry covers every prefix the codebase actually mints, so a
|
|
22
|
+
// sixth grammar cannot be added without this file noticing.
|
|
23
|
+
|
|
24
|
+
export interface CredentialGrammar {
|
|
25
|
+
/** Short id, used in test names and failure messages. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** What mints it, so a reader can find the source of truth. */
|
|
28
|
+
mintedBy: string;
|
|
29
|
+
/** Matches the credential ANYWHERE in a line of text. */
|
|
30
|
+
pattern: RegExp;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The grammars. Every `pattern` must be global — the scrubber replaces all
|
|
35
|
+
* occurrences in a line, not just the first.
|
|
36
|
+
*/
|
|
37
|
+
export const CREDENTIAL_GRAMMARS: CredentialGrammar[] = [
|
|
38
|
+
{
|
|
39
|
+
id: 'peer-token',
|
|
40
|
+
mintedBy: 'apps/hub/src/tokens.mjs (addToken) — the workspace session token',
|
|
41
|
+
pattern: /\bmau_[A-Za-z0-9]{8,}\b/g,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'personal-token',
|
|
45
|
+
mintedBy: 'apps/cloud/device-auth.mjs — what a signed-in device holds',
|
|
46
|
+
pattern: /\bmpt_[A-Za-z0-9]{8,}\b/g,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'handoff-code',
|
|
50
|
+
mintedBy: 'apps/cloud/handoff.mjs — the one-time browser→app claim ticket',
|
|
51
|
+
pattern: /\bmhc_[A-Za-z0-9]{8,}\b/g,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'device-code',
|
|
55
|
+
mintedBy: 'apps/cloud/device-auth.mjs — the poller’s half of the device flow',
|
|
56
|
+
pattern: /\bmdc_[A-Za-z0-9]{8,}\b/g,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'grant',
|
|
60
|
+
mintedBy: 'apps/cloud/grants.mjs (mintGrant) — the project-grant capability',
|
|
61
|
+
// `mcg_<base64url payload>.<base64url mac>` — like the project token, it
|
|
62
|
+
// is deliberately not a JWT, so the three-part JWT rule cannot see it.
|
|
63
|
+
pattern: /\bmcg_[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'project-token',
|
|
67
|
+
mintedBy: 'apps/cloud/cell-token.mjs (mintProjectToken) — base64url payload.mac',
|
|
68
|
+
// TWO parts, not three. This is the one the JWT rule structurally cannot
|
|
69
|
+
// see. The payload always starts `eyJ` (a base64url-encoded `{"`), which is
|
|
70
|
+
// what keeps this from matching ordinary dotted words.
|
|
71
|
+
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{20,}\b/g,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'derived-cell-secret',
|
|
75
|
+
mintedBy: 'apps/cloud/cell-token.mjs (deriveCellSecret) — 64 hex, HUB_SECRET and friends',
|
|
76
|
+
pattern: /\b[0-9a-f]{64}\b/g,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Redact every Maude-minted credential in a string.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately separate from the vendor rules: those chase other people's
|
|
84
|
+
* formats and change when a vendor changes; this one is OURS and changes when
|
|
85
|
+
* WE mint something new.
|
|
86
|
+
*/
|
|
87
|
+
export function redactMaudeCredentials(text: string, marker = '[redacted]'): string {
|
|
88
|
+
let out = text;
|
|
89
|
+
for (const g of CREDENTIAL_GRAMMARS) out = out.replace(g.pattern, marker);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Environment variable names whose VALUE is a secret regardless of shape.
|
|
95
|
+
*
|
|
96
|
+
* `\b(token|secret)` cannot match inside `HUB_SECRET` — `_` is a word
|
|
97
|
+
* character — so the underscore-joined names need their own rule.
|
|
98
|
+
*/
|
|
99
|
+
export const SECRET_ENV_PATTERN =
|
|
100
|
+
/\b([A-Z][A-Z0-9]*_(?:SECRET|TOKEN|KEY|PASSWORD|MASTER)[A-Z0-9_]*)(\s*[:=]\s*"?)([^\s"',}]+)/g;
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
|
|
19
|
+
import { redactMaudeCredentials, SECRET_ENV_PATTERN } from './credential-grammar.ts';
|
|
20
|
+
|
|
19
21
|
const RING_MAX = 500;
|
|
20
22
|
const ring: string[] = [];
|
|
21
23
|
let tapInstalled = false;
|
|
@@ -84,6 +86,16 @@ export function scrub(text: string, opts: ScrubOptions = {}): string {
|
|
|
84
86
|
const home = (opts.home ?? homedir()).replace(/\/+$/, '');
|
|
85
87
|
if (home) out = out.replaceAll(home, '~');
|
|
86
88
|
|
|
89
|
+
// OURS first — the grammars Maude itself mints (credential-grammar.ts).
|
|
90
|
+
// Kept separate from the vendor rules below because those chase other
|
|
91
|
+
// people's formats and change when a vendor changes; this set changes when
|
|
92
|
+
// WE mint something new, and its test fails if a new prefix is added
|
|
93
|
+
// without registering it.
|
|
94
|
+
out = redactMaudeCredentials(out);
|
|
95
|
+
// Underscore-joined secret env names — `\b(token|secret)` can never match
|
|
96
|
+
// inside `HUB_SECRET`, because `_` is a word character.
|
|
97
|
+
out = out.replace(SECRET_ENV_PATTERN, '$1$2[redacted]');
|
|
98
|
+
|
|
87
99
|
// Known token shapes first (GitHub classic + fine-grained, Anthropic, JWTs).
|
|
88
100
|
out = out.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}\b/g, '[redacted]');
|
|
89
101
|
out = out.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]');
|