@1agh/maude 0.51.1 → 0.52.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.
Files changed (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. 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
- const CLOUD_URL = (process.env.MAUDE_CLOUD_URL ?? 'https://cloud.maude.sh').replace(/\/+$/, '');
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: any }> {
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(`${CLOUD_URL}${path}`, { ...init, signal: ctl.signal });
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: CLOUD_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 { status: 502, json: { ok: false, error: 'Maude Cloud could not be reached. Try again in a moment.' } };
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 { status: 410, json: { ok: false, error: 'The code expired. Start the sign-in again.' } };
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: CLOUD_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 { status: 200, json: { ok: true, pending: false, email: r.body.account?.email ?? null } };
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) return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
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 { status: 401, json: { ok: false, error: 'This device was disconnected. Sign in again.' } };
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) return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
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 { status: 502, json: { ok: false, error: 'The project could not be opened with this account.' } };
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({ workspaceUrl: opened.body.url, projectToken: opened.body.token, role: opened.body.role });
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: any = await res.json().catch(() => ({}));
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 { status: 502, json: { ok: false, error: 'The workspace did not accept the sign-in. Try again in a minute.' } };
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: any = {};
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]');