@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.
- 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 +824 -121
- 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 +15 -15
- 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 +0 -83
- 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/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -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 +20 -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/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
|
@@ -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:
|
|
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
|
|
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 <
|
|
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
|
|
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('
|
|
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(
|
|
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
|
-
// …
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
});
|