@1agh/maude 0.51.1 → 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.
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
@@ -12,6 +12,17 @@ import { join } from 'node:path';
12
12
  export interface HubRecord {
13
13
  token: string;
14
14
  linkedAt: number;
15
+ /**
16
+ * The role the workspace vouched for at sign-in (Cloud Phase 25 C2).
17
+ *
18
+ * Stored so a viewer is known to be a viewer at BOOT, before any request is
19
+ * made — the alternative is discovering it from the first refusal, which
20
+ * means showing somebody an editor and taking it away.
21
+ *
22
+ * Absent means `member`: every credential written before this shipped was
23
+ * write-capable, and nobody is demoted by an upgrade.
24
+ */
25
+ role?: string;
15
26
  }
16
27
 
17
28
  export interface HubsConfig {
@@ -74,6 +85,27 @@ function warnIfWorldOrGroupReadable(path: string): void {
74
85
  }
75
86
  }
76
87
 
88
+ /**
89
+ * Is the credential for `url` a READ-ONLY one? (Cloud Phase 25 C2)
90
+ *
91
+ * Answered from the role the workspace vouched for at sign-in, so the app
92
+ * knows at BOOT rather than discovering it from the first refused write —
93
+ * which would mean showing somebody an editor and then taking it away.
94
+ *
95
+ * Unknown, unreadable, or absent all answer FALSE, matching every credential
96
+ * written before this shipped. This flag decides what the UI OFFERS; it is
97
+ * never the thing that stops a write. That is the cell (Phase 25 C1) and the
98
+ * gate in http.ts, both of which hold whatever this returns.
99
+ */
100
+ export function isHubReadOnly(url: string): boolean {
101
+ try {
102
+ const record = loadHubsConfig().hubs[normalizeUrl(url)];
103
+ return record?.role === 'viewer';
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
77
109
  /** Look up a token for `url`. Returns null when no entry exists. */
78
110
  export function getHubToken(url: string): string | null {
79
111
  try {
@@ -67,7 +67,7 @@ export interface SignInInput {
67
67
  /** Injected in tests. */
68
68
  export interface SignInDeps {
69
69
  fetchImpl?: typeof fetch;
70
- save?: (normUrl: string, token: string) => void;
70
+ save?: (normUrl: string, token: string, role?: string) => void;
71
71
  }
72
72
 
73
73
  /**
@@ -181,9 +181,13 @@ export async function signInToWorkspace(
181
181
  return fail(502, 'The workspace sent an unexpected reply.', 'server-error');
182
182
  }
183
183
 
184
- // 3. Persist ONLY the minted token, 0600, in the shared credential store.
184
+ // 3. Persist ONLY the minted token, 0600, in the shared credential store —
185
+ // plus the role the workspace vouched for (Cloud Phase 25 C2), so a
186
+ // viewer is known to be one at BOOT. Learning it from the first refusal
187
+ // instead means showing somebody an editor and then taking it away.
188
+ const vouchedRole = typeof body.user?.role === 'string' ? body.user.role : 'member';
185
189
  try {
186
- save(norm, body.token);
190
+ save(norm, body.token, vouchedRole);
187
191
  } catch {
188
192
  return fail(500, "Couldn't save the workspace connection on this computer.", 'save-failed');
189
193
  }
@@ -195,7 +199,7 @@ export async function signInToWorkspace(
195
199
  url: norm,
196
200
  user: {
197
201
  email: typeof body.user?.email === 'string' ? body.user.email : String(input.email).trim(),
198
- role: typeof body.user?.role === 'string' ? body.user.role : 'member',
202
+ role: vouchedRole,
199
203
  },
200
204
  expiresAt: typeof body.expiresAt === 'number' ? body.expiresAt : null,
201
205
  version: probe.version,
@@ -63,9 +63,11 @@ export async function bootServer(
63
63
  stdout: 'pipe',
64
64
  stderr: 'pipe',
65
65
  });
66
- // Wait up to 3 s for the server to bind.
66
+ // Wait up to 10 s for the server to bind — the first spawn cold-compiles the
67
+ // whole studio TS, which under parallel test load regularly blows a 3 s
68
+ // budget (the flaky "server did not start" class of failure).
67
69
  const start = Date.now();
68
- while (Date.now() - start < 3000) {
70
+ while (Date.now() - start < 10000) {
69
71
  try {
70
72
  const r = await fetch(`http://localhost:${port}/_health`, {
71
73
  signal: AbortSignal.timeout(200),
@@ -85,7 +87,7 @@ export async function bootServer(
85
87
  await Bun.sleep(50);
86
88
  }
87
89
  proc.kill();
88
- throw new Error(`server did not start on port ${port} within 3 s`);
90
+ throw new Error(`server did not start on port ${port} within 10 s`);
89
91
  }
90
92
 
91
93
  export async function killProc(proc: Subprocess) {
@@ -163,17 +163,23 @@ describe('/_api/canvas — POST round-trip', () => {
163
163
  }
164
164
  });
165
165
 
166
- test('rejects a video-comp with an empty clips[] (400) and a traversal src (400)', async () => {
166
+ test('accepts an empty clips[] as the greenfield "New video" (201) and rejects a traversal src (400)', async () => {
167
167
  const { root } = makeSandbox();
168
168
  const port = nextPort();
169
169
  const proc = await bootServer(root, port);
170
170
  try {
171
+ // enhanced-video-editing Task 20 — empty clips[] scaffolds an editable
172
+ // empty comp (drop-first cut building), no longer a 400.
171
173
  const empty = await fetch(`http://localhost:${port}/_api/canvas`, {
172
174
  method: 'POST',
173
175
  headers: { 'Content-Type': 'application/json' },
174
176
  body: JSON.stringify({ name: 'Empty', kind: 'video-comp', clips: [] }),
175
177
  });
176
- expect(empty.status).toBe(400);
178
+ expect(empty.status).toBe(201);
179
+ const created = (await empty.json()) as { file?: string };
180
+ const tsx = await Bun.file(`${root}/${created.file}`).text();
181
+ expect(tsx).toContain('Drop clips on the timeline');
182
+ expect(tsx).toContain('<VideoComp');
177
183
  const bad = await fetch(`http://localhost:${port}/_api/canvas`, {
178
184
  method: 'POST',
179
185
  headers: { 'Content-Type': 'application/json' },
@@ -88,6 +88,18 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
88
88
  '/_api/edit-attr',
89
89
  // DDR-148 — raw canvas source for the Timeline parser is MAIN-ORIGIN
90
90
  // ONLY (the untrusted canvas iframe must never read project source).
91
+ // Cloud Phase 23 C3 — the Maude Cloud lane holds the PERSONAL TOKEN
92
+ // and can write linkedHub + a hub credential. Absent from
93
+ // CANVAS_SAFE_API and from startCanvasServer's routes; the security
94
+ // pass confirmed that by reading, which is exactly the kind of fact
95
+ // that should be pinned rather than re-read (validate 2026-07-30).
96
+ '/_api/cloud/status',
97
+ '/_api/cloud/projects',
98
+ '/_api/cloud/signin/start',
99
+ '/_api/cloud/signin/poll',
100
+ '/_api/cloud/signout',
101
+ '/_api/cloud/attach',
102
+ '/_api/cloud/attach/code',
91
103
  '/_api/canvas-source',
92
104
  // DDR-148 — Timeline drag-to-retime is a source-write, MAIN-ORIGIN ONLY.
93
105
  '/_api/retime-sequence',
@@ -101,6 +113,14 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
101
113
  '/_api/insert-sequence',
102
114
  // DDR-150 P5 — z-order reorder is a source-write, MAIN-ORIGIN ONLY.
103
115
  '/_api/reorder-sequence',
116
+ // enhanced-video-editing Phase 2 — the parametric clip-verb door (speed ·
117
+ // trim · split · transition · layer-order · set-text · to-overlay · …) is
118
+ // a source-write, MAIN-ORIGIN ONLY; the layer-order verb rides this route
119
+ // and does NOT widen the canvas surface (security review 2026-07-30).
120
+ '/_api/clip-edit',
121
+ // enhanced-video-editing Task 7 — the filmstrip/waveform cache holds
122
+ // dataURL frames of local footage; MAIN-ORIGIN ONLY + DNS-rebinding guard.
123
+ '/_api/timeline-media',
104
124
  // DDR-150 dogfood — array-src replace is a source-write, MAIN-ORIGIN ONLY.
105
125
  '/_api/edit-array-src',
106
126
  // DDR-150 dogfood — clip hide/show is a source-write, MAIN-ORIGIN ONLY.
@@ -687,9 +687,11 @@ describe('assembleCompSource — refs → comp (DDR-150 P4 Task 12)', () => {
687
687
  const { clips, compName, durationInFrames } = enumerateClips('/abs/MyReel.tsx', tsx, 'reel');
688
688
  expect(compName).toBe('Comp');
689
689
  expect(clips.map((c) => c.mediaSrc)).toEqual(['assets/a.mp4', 'assets/b.mp4']);
690
- // …laid back-to-back (cursor advances by each duration)…
691
- expect(clips[0]!.from).toBe(0);
692
- expect(clips[1]!.from).toBe(60);
690
+ // …as series beats butted back-to-back (enhanced-video-editing Task 20:
691
+ // the storyline is a <TransitionSeries> now — beats carry no `from`, the
692
+ // series computes their offsets)…
693
+ expect(clips[0]!.tag).toBe('TransitionSeries.Sequence');
694
+ expect(clips[0]!.durationInFrames).toBe(60);
693
695
  expect(clips[1]!.durationInFrames).toBe(40);
694
696
  // …with durable <Sequence name> identity so hand-edits land right.
695
697
  expect(clips.map((c) => c.stableId)).toEqual(['name:clip-1', 'name:clip-2']);
@@ -705,9 +707,8 @@ describe('assembleCompSource — refs → comp (DDR-150 P4 Task 12)', () => {
705
707
  { src: 'assets/music.mp3', mediaKind: 'audio' },
706
708
  ]);
707
709
  expect(tsx).toContain('<Audio src="assets/music.mp3" />');
708
- expect(tsx).toContain(
709
- `import { AbsoluteFill, Sequence, OffthreadVideo, Audio } from 'remotion';`
710
- );
710
+ expect(tsx).toContain(`import { TransitionSeries } from '@remotion/transitions';`);
711
+ expect(tsx).toContain(`import { AbsoluteFill, OffthreadVideo, Audio } from 'remotion';`);
711
712
  // total driven by the video clip's duration (audio doesn't extend it).
712
713
  expect(enumerateClips('/abs/R.tsx', tsx, 'reel').durationInFrames).toBe(90);
713
714
  });