@1agh/maude 0.58.1 → 0.58.3
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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1491 -1491
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -30,11 +30,13 @@ import { createConnectionMonitor } from '../sync/connection-state.ts';
|
|
|
30
30
|
import {
|
|
31
31
|
type AwarenessRegistry,
|
|
32
32
|
buildNoSyncablePayload,
|
|
33
|
+
classifyAuthFailure,
|
|
33
34
|
createSyncRuntime,
|
|
34
35
|
discoverCanvases,
|
|
35
36
|
type SyncProvider,
|
|
36
37
|
scanCanvases,
|
|
37
38
|
toWsUrl,
|
|
39
|
+
validExpiry,
|
|
38
40
|
} from '../sync/index.ts';
|
|
39
41
|
import { createSyncStatusStore } from '../sync/status.ts';
|
|
40
42
|
|
|
@@ -1029,6 +1031,49 @@ describe('createDefaultProviderFactory — shared socket multiplexing', () => {
|
|
|
1029
1031
|
|
|
1030
1032
|
// DDR-102 — auth-failure intelligence (classification, aggregation,
|
|
1031
1033
|
// destroy-on-permanent + re-probe) and the honest settled boot summary.
|
|
1034
|
+
describe('validExpiry — a hub-reported deadline sane enough to schedule against', () => {
|
|
1035
|
+
const NOW = 1_000_000_000_000;
|
|
1036
|
+
test('accepts a plausible future integer', () => {
|
|
1037
|
+
expect(validExpiry(NOW + 12 * 60 * 60 * 1000, NOW)).toBe(NOW + 12 * 60 * 60 * 1000);
|
|
1038
|
+
});
|
|
1039
|
+
test('rejects a past or present stamp (falls to the invalid-token path instead)', () => {
|
|
1040
|
+
expect(validExpiry(NOW - 1, NOW)).toBeNull();
|
|
1041
|
+
expect(validExpiry(NOW, NOW)).toBeNull();
|
|
1042
|
+
});
|
|
1043
|
+
test('rejects an absurdly far-future stamp (the setTimeout-overflow trap)', () => {
|
|
1044
|
+
expect(validExpiry(NOW + 60 * 24 * 60 * 60 * 1000, NOW)).toBeNull(); // 60 days
|
|
1045
|
+
});
|
|
1046
|
+
test('rejects non-integers and non-numbers', () => {
|
|
1047
|
+
expect(validExpiry(NOW + 1000.5, NOW)).toBeNull();
|
|
1048
|
+
expect(validExpiry('soon', NOW)).toBeNull();
|
|
1049
|
+
expect(validExpiry(Number.NaN, NOW)).toBeNull();
|
|
1050
|
+
expect(validExpiry(Number.POSITIVE_INFINITY, NOW)).toBeNull();
|
|
1051
|
+
expect(validExpiry(undefined, NOW)).toBeNull();
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
describe('classifyAuthFailure — permanent classes outrank the transient ones', () => {
|
|
1056
|
+
test('the invalid-token bucket refusal classifies as invalid-token, not rate-limit', () => {
|
|
1057
|
+
// The hub's invalid-token bucket says BOTH things. Filing it under
|
|
1058
|
+
// rate-limit (transient) made the runtime retry into the very bucket
|
|
1059
|
+
// refusing it — the alligators incident's 1840-vs-138 log ratio.
|
|
1060
|
+
expect(classifyAuthFailure('invalid token — rate limited, retry in up to 60s')).toBe(
|
|
1061
|
+
'invalid-token'
|
|
1062
|
+
);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test('plain reasons still land in their own classes', () => {
|
|
1066
|
+
expect(classifyAuthFailure('invalid token')).toBe('invalid-token');
|
|
1067
|
+
expect(classifyAuthFailure('token not authorized for this documentName')).toBe(
|
|
1068
|
+
'not-authorized'
|
|
1069
|
+
);
|
|
1070
|
+
expect(classifyAuthFailure('rate limit exceeded for this token — retry in up to 60s')).toBe(
|
|
1071
|
+
'rate-limit'
|
|
1072
|
+
);
|
|
1073
|
+
expect(classifyAuthFailure('permission-denied')).toBe('generic');
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1032
1077
|
describe('DDR-102 — auth-failure intelligence + boot summary', () => {
|
|
1033
1078
|
function fakeTimerQueue() {
|
|
1034
1079
|
let nextId = 1;
|
|
@@ -1059,15 +1104,17 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
|
|
|
1059
1104
|
function authStubFactory() {
|
|
1060
1105
|
const made: Array<{
|
|
1061
1106
|
documentName: string;
|
|
1107
|
+
token: string | undefined;
|
|
1062
1108
|
destroyed: boolean;
|
|
1063
1109
|
emitAuthFailure: (reason: string) => void;
|
|
1064
1110
|
document: Y.Doc;
|
|
1065
1111
|
}> = [];
|
|
1066
|
-
const factory = (args: { documentName: string; document?: Y.Doc }) => {
|
|
1112
|
+
const factory = (args: { documentName: string; token?: string; document?: Y.Doc }) => {
|
|
1067
1113
|
const document = args.document ?? new Y.Doc();
|
|
1068
1114
|
const cbs = new Set<(info: { reason: string }) => void>();
|
|
1069
1115
|
const entry = {
|
|
1070
1116
|
documentName: args.documentName,
|
|
1117
|
+
token: args.token,
|
|
1071
1118
|
destroyed: false,
|
|
1072
1119
|
emitAuthFailure: (reason: string) => {
|
|
1073
1120
|
for (const cb of cbs) cb({ reason });
|
|
@@ -1171,6 +1218,392 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
|
|
|
1171
1218
|
}
|
|
1172
1219
|
});
|
|
1173
1220
|
|
|
1221
|
+
test('invalid-token triggers ONE silent renewal and an immediate re-probe with the fresh token', async () => {
|
|
1222
|
+
const url = 'https://hub.example.com';
|
|
1223
|
+
writeHubsConfig(url, 'mau_stale');
|
|
1224
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1225
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1226
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
|
|
1227
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'c.html'), '<c/>');
|
|
1228
|
+
|
|
1229
|
+
const timers = fakeTimerQueue();
|
|
1230
|
+
const { factory, made } = authStubFactory();
|
|
1231
|
+
let renewCalls = 0;
|
|
1232
|
+
const origWarn = console.warn;
|
|
1233
|
+
const origLog = console.log;
|
|
1234
|
+
console.warn = () => {};
|
|
1235
|
+
console.log = () => {};
|
|
1236
|
+
try {
|
|
1237
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1238
|
+
providerFactory: factory,
|
|
1239
|
+
auth: {
|
|
1240
|
+
warnDebounceMs: 2_000,
|
|
1241
|
+
reprobeMs: 300_000,
|
|
1242
|
+
settleTimeoutMs: 15_000,
|
|
1243
|
+
setTimer: timers.setTimer,
|
|
1244
|
+
clearTimer: timers.clearTimer,
|
|
1245
|
+
renewCredential: async () => {
|
|
1246
|
+
renewCalls++;
|
|
1247
|
+
return { token: 'mau_fresh', expiresAt: null };
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
});
|
|
1251
|
+
await runtime?.start();
|
|
1252
|
+
expect(made).toHaveLength(3);
|
|
1253
|
+
expect(made.every((m) => m.token === 'mau_stale')).toBe(true);
|
|
1254
|
+
|
|
1255
|
+
// The whole burst rejects with the expired credential…
|
|
1256
|
+
for (const m of made.slice(0, 3)) m.emitAuthFailure('invalid token');
|
|
1257
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1258
|
+
|
|
1259
|
+
// …ONE renewal (single-flight), and the re-probe happened NOW — no
|
|
1260
|
+
// 5-minute wait — with the fresh token on every reconnected provider.
|
|
1261
|
+
expect(renewCalls).toBe(1);
|
|
1262
|
+
const reprobed = made.slice(3);
|
|
1263
|
+
expect(reprobed.map((m) => m.documentName).sort()).toEqual(['ui-a', 'ui-b', 'ui-c']);
|
|
1264
|
+
expect(reprobed.every((m) => m.token === 'mau_fresh')).toBe(true);
|
|
1265
|
+
|
|
1266
|
+
await runtime?.stop();
|
|
1267
|
+
} finally {
|
|
1268
|
+
console.warn = origWarn;
|
|
1269
|
+
console.log = origLog;
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test('F1: a hub refusing on VOLUME cannot drive a renewal storm — the cap stops it', async () => {
|
|
1274
|
+
// The regression this whole review turned on. A cell refusing its own
|
|
1275
|
+
// clients with "invalid token — rate limited" classifies permanent →
|
|
1276
|
+
// triggers renewal → renewal succeeds (token was fine) → reprobe → every
|
|
1277
|
+
// reconnected doc is refused again → loop. Reproduced at 2342/s before the
|
|
1278
|
+
// cap. Here every reconnected provider auto-re-rejects, so without the cap
|
|
1279
|
+
// this test would spin forever; with it, renewals are bounded.
|
|
1280
|
+
const url = 'https://hub.example.com';
|
|
1281
|
+
writeHubsConfig(url, 'mau_stale');
|
|
1282
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1283
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1284
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
|
|
1285
|
+
|
|
1286
|
+
let renewCalls = 0;
|
|
1287
|
+
let providersMade = 0;
|
|
1288
|
+
const origWarn = console.warn;
|
|
1289
|
+
const origLog = console.log;
|
|
1290
|
+
console.warn = () => {};
|
|
1291
|
+
console.log = () => {};
|
|
1292
|
+
// Every provider auto-rejects with the volume-refusal string the moment a
|
|
1293
|
+
// failure handler is attached — modelling a full hub bucket.
|
|
1294
|
+
const factory = (args: { documentName: string; token?: string; document?: Y.Doc }) => {
|
|
1295
|
+
const document = args.document ?? new Y.Doc();
|
|
1296
|
+
providersMade++;
|
|
1297
|
+
return {
|
|
1298
|
+
document,
|
|
1299
|
+
onAuthFailed(cb: (info: { reason: string }) => void) {
|
|
1300
|
+
queueMicrotask(() => cb({ reason: 'invalid token — rate limited, retry in up to 60s' }));
|
|
1301
|
+
return () => {};
|
|
1302
|
+
},
|
|
1303
|
+
onceSynced: () => new Promise<void>(() => {}),
|
|
1304
|
+
destroy() {},
|
|
1305
|
+
};
|
|
1306
|
+
};
|
|
1307
|
+
try {
|
|
1308
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1309
|
+
providerFactory: factory,
|
|
1310
|
+
auth: {
|
|
1311
|
+
warnDebounceMs: 2_000,
|
|
1312
|
+
reprobeMs: 300_000,
|
|
1313
|
+
settleTimeoutMs: 15_000,
|
|
1314
|
+
setTimer: () => 0 as unknown as ReturnType<typeof setTimeout>,
|
|
1315
|
+
clearTimer: () => {},
|
|
1316
|
+
renewMinIntervalMs: 0, // isolate the CAP from the floor
|
|
1317
|
+
renewCredential: async () => {
|
|
1318
|
+
renewCalls++;
|
|
1319
|
+
return { token: `mau_fresh_${renewCalls}`, expiresAt: null };
|
|
1320
|
+
},
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
await runtime?.start();
|
|
1324
|
+
// Let the storm run itself out — if the cap failed, this never settles
|
|
1325
|
+
// and the test times out (which is itself the failure signal).
|
|
1326
|
+
for (let i = 0; i < 20; i++) await new Promise((res) => setTimeout(res, 5));
|
|
1327
|
+
|
|
1328
|
+
// Bounded by the cap (default 3), NOT thousands.
|
|
1329
|
+
expect(renewCalls).toBeLessThanOrEqual(3);
|
|
1330
|
+
expect(renewCalls).toBeGreaterThan(0);
|
|
1331
|
+
// Providers created is likewise bounded — no runaway reconnect fan-out.
|
|
1332
|
+
expect(providersMade).toBeLessThan(20);
|
|
1333
|
+
await runtime?.stop();
|
|
1334
|
+
} finally {
|
|
1335
|
+
console.warn = origWarn;
|
|
1336
|
+
console.log = origLog;
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
test('F1: the frequency floor collapses a rapid second burst to no extra renewal', async () => {
|
|
1341
|
+
const url = 'https://hub.example.com';
|
|
1342
|
+
writeHubsConfig(url, 'mau_stale');
|
|
1343
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1344
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1345
|
+
|
|
1346
|
+
let renewCalls = 0;
|
|
1347
|
+
let clock = 1_000_000;
|
|
1348
|
+
const origWarn = console.warn;
|
|
1349
|
+
const origLog = console.log;
|
|
1350
|
+
console.warn = () => {};
|
|
1351
|
+
console.log = () => {};
|
|
1352
|
+
const { factory, made } = authStubFactory();
|
|
1353
|
+
try {
|
|
1354
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1355
|
+
providerFactory: factory,
|
|
1356
|
+
auth: {
|
|
1357
|
+
warnDebounceMs: 2_000,
|
|
1358
|
+
reprobeMs: 300_000,
|
|
1359
|
+
settleTimeoutMs: 15_000,
|
|
1360
|
+
setTimer: () => 0 as unknown as ReturnType<typeof setTimeout>,
|
|
1361
|
+
clearTimer: () => {},
|
|
1362
|
+
now: () => clock,
|
|
1363
|
+
renewMinIntervalMs: 60_000,
|
|
1364
|
+
renewCredential: async () => {
|
|
1365
|
+
renewCalls++;
|
|
1366
|
+
return { token: 'mau_fresh', expiresAt: null };
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
await runtime?.start();
|
|
1371
|
+
made[0]?.emitAuthFailure('invalid token');
|
|
1372
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1373
|
+
expect(renewCalls).toBe(1);
|
|
1374
|
+
// A second rejection 10 s later (inside the 60 s floor) → no new renewal.
|
|
1375
|
+
clock += 10_000;
|
|
1376
|
+
made[0]?.emitAuthFailure('invalid token');
|
|
1377
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1378
|
+
expect(renewCalls).toBe(1);
|
|
1379
|
+
// Past the floor → renewal is allowed again.
|
|
1380
|
+
clock += 60_000;
|
|
1381
|
+
made[0]?.emitAuthFailure('invalid token');
|
|
1382
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1383
|
+
expect(renewCalls).toBe(2);
|
|
1384
|
+
await runtime?.stop();
|
|
1385
|
+
} finally {
|
|
1386
|
+
console.warn = origWarn;
|
|
1387
|
+
console.log = origLog;
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
test('a failed renewal changes nothing — rejected docs wait for the slow re-probe as before', async () => {
|
|
1392
|
+
const url = 'https://hub.example.com';
|
|
1393
|
+
writeHubsConfig(url, 'mau_stale');
|
|
1394
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1395
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1396
|
+
|
|
1397
|
+
const timers = fakeTimerQueue();
|
|
1398
|
+
const { factory, made } = authStubFactory();
|
|
1399
|
+
const origWarn = console.warn;
|
|
1400
|
+
const origLog = console.log;
|
|
1401
|
+
console.warn = () => {};
|
|
1402
|
+
console.log = () => {};
|
|
1403
|
+
try {
|
|
1404
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1405
|
+
providerFactory: factory,
|
|
1406
|
+
auth: {
|
|
1407
|
+
warnDebounceMs: 2_000,
|
|
1408
|
+
reprobeMs: 300_000,
|
|
1409
|
+
settleTimeoutMs: 15_000,
|
|
1410
|
+
setTimer: timers.setTimer,
|
|
1411
|
+
clearTimer: timers.clearTimer,
|
|
1412
|
+
renewCredential: async () => null, // signed out / revoked / self-hosted
|
|
1413
|
+
},
|
|
1414
|
+
});
|
|
1415
|
+
await runtime?.start();
|
|
1416
|
+
made[0]?.emitAuthFailure('invalid token');
|
|
1417
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1418
|
+
|
|
1419
|
+
// No immediate re-probe (the renewal failed) — the doc stays rejected…
|
|
1420
|
+
expect(made).toHaveLength(1);
|
|
1421
|
+
expect(runtime?.status()?.docs?.rejected).toBe(1);
|
|
1422
|
+
// …until the slow re-probe fires, with the ORIGINAL token (unchanged).
|
|
1423
|
+
timers.fire(300_000);
|
|
1424
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1425
|
+
expect(made).toHaveLength(2);
|
|
1426
|
+
expect(made[1]?.token).toBe('mau_stale');
|
|
1427
|
+
|
|
1428
|
+
await runtime?.stop();
|
|
1429
|
+
} finally {
|
|
1430
|
+
console.warn = origWarn;
|
|
1431
|
+
console.log = origLog;
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
test('a stored expiry arms a pre-expiry renewal at ~80% of the remaining life', async () => {
|
|
1436
|
+
const url = 'https://hub.example.com';
|
|
1437
|
+
const cfgPath = process.env.HUBS_CONFIG_PATH;
|
|
1438
|
+
if (!cfgPath) throw new Error('HUBS_CONFIG_PATH not set by beforeEach');
|
|
1439
|
+
writeFileSync(
|
|
1440
|
+
cfgPath,
|
|
1441
|
+
JSON.stringify({
|
|
1442
|
+
hubs: { [url]: { token: 'mau_stale', linkedAt: 1, expiresAt: Date.now() + 100_000 } },
|
|
1443
|
+
})
|
|
1444
|
+
);
|
|
1445
|
+
chmodSync(cfgPath, 0o600);
|
|
1446
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1447
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1448
|
+
|
|
1449
|
+
const timers = fakeTimerQueue();
|
|
1450
|
+
const { factory } = authStubFactory();
|
|
1451
|
+
let renewCalls = 0;
|
|
1452
|
+
const origWarn = console.warn;
|
|
1453
|
+
const origLog = console.log;
|
|
1454
|
+
console.warn = () => {};
|
|
1455
|
+
console.log = () => {};
|
|
1456
|
+
try {
|
|
1457
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1458
|
+
providerFactory: factory,
|
|
1459
|
+
auth: {
|
|
1460
|
+
warnDebounceMs: 2_000,
|
|
1461
|
+
reprobeMs: 300_000,
|
|
1462
|
+
settleTimeoutMs: 15_000,
|
|
1463
|
+
setTimer: timers.setTimer,
|
|
1464
|
+
clearTimer: timers.clearTimer,
|
|
1465
|
+
renewCredential: async () => {
|
|
1466
|
+
renewCalls++;
|
|
1467
|
+
return { token: 'mau_fresh', expiresAt: null };
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
});
|
|
1471
|
+
await runtime?.start();
|
|
1472
|
+
// 100 s of life left → the renewal timer sits at 80 s, so a 30 s sweep
|
|
1473
|
+
// must not reach it (only the settle timers live down there)…
|
|
1474
|
+
timers.fire(30_000);
|
|
1475
|
+
await new Promise((res) => setTimeout(res, 5));
|
|
1476
|
+
expect(renewCalls).toBe(0);
|
|
1477
|
+
// …and the 80 s sweep fires it, once, without any rejection happening.
|
|
1478
|
+
timers.fire(80_000);
|
|
1479
|
+
await new Promise((res) => setTimeout(res, 5));
|
|
1480
|
+
expect(renewCalls).toBe(1);
|
|
1481
|
+
|
|
1482
|
+
await runtime?.stop();
|
|
1483
|
+
} finally {
|
|
1484
|
+
console.warn = origWarn;
|
|
1485
|
+
console.log = origLog;
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
test('a successful re-probe clears the refusal, and the reconcile is a separate word', async () => {
|
|
1490
|
+
// A REGRESSION GUARD, not a falsifier — this already held. It is here
|
|
1491
|
+
// because the clear moved: it used to sit at the BOTTOM of the
|
|
1492
|
+
// post-handshake path, below a reconcile that can throw into a rejection
|
|
1493
|
+
// `settleWait` swallows, so a document that reconnected fine but failed to
|
|
1494
|
+
// settle on disk would have kept telling the user to go fix a credential
|
|
1495
|
+
// that was never the problem. The clear now runs on the handshake, and the
|
|
1496
|
+
// reconcile reports separately (`pending` vs `connected`) and loudly.
|
|
1497
|
+
const url = 'https://hub.example.com';
|
|
1498
|
+
writeHubsConfig(url, 'mau_test');
|
|
1499
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1500
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1501
|
+
|
|
1502
|
+
const timers = fakeTimerQueue();
|
|
1503
|
+
const origWarn = console.warn;
|
|
1504
|
+
const origLog = console.log;
|
|
1505
|
+
console.warn = () => {};
|
|
1506
|
+
console.log = () => {};
|
|
1507
|
+
|
|
1508
|
+
let round = 0;
|
|
1509
|
+
const factory = (args: { documentName: string; document?: Y.Doc }) => {
|
|
1510
|
+
const document = args.document ?? new Y.Doc();
|
|
1511
|
+
const first = round++ === 0;
|
|
1512
|
+
return {
|
|
1513
|
+
document,
|
|
1514
|
+
onAuthFailed(cb: (info: { reason: string }) => void) {
|
|
1515
|
+
if (first) queueMicrotask(() => cb({ reason: 'token not authorized' }));
|
|
1516
|
+
return () => {};
|
|
1517
|
+
},
|
|
1518
|
+
onceSynced: () => (first ? new Promise<void>(() => {}) : Promise.resolve()),
|
|
1519
|
+
destroy() {},
|
|
1520
|
+
};
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
try {
|
|
1524
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1525
|
+
providerFactory: factory,
|
|
1526
|
+
auth: {
|
|
1527
|
+
warnDebounceMs: 2_000,
|
|
1528
|
+
reprobeMs: 300_000,
|
|
1529
|
+
settleTimeoutMs: 15_000,
|
|
1530
|
+
setTimer: timers.setTimer,
|
|
1531
|
+
clearTimer: timers.clearTimer,
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
await runtime?.start();
|
|
1535
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1536
|
+
expect(runtime?.status().docs?.rejected).toBe(1);
|
|
1537
|
+
|
|
1538
|
+
timers.fire(300_000);
|
|
1539
|
+
await new Promise((res) => setTimeout(res, 40));
|
|
1540
|
+
|
|
1541
|
+
expect(runtime?.status().docs?.rejected).toBe(0);
|
|
1542
|
+
expect(runtime?.status().rejectedSlugs ?? []).toEqual([]);
|
|
1543
|
+
|
|
1544
|
+
await runtime?.stop();
|
|
1545
|
+
} finally {
|
|
1546
|
+
console.warn = origWarn;
|
|
1547
|
+
console.log = origLog;
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test('a new process never serves the previous one’s verdict, not even mid-boot', async () => {
|
|
1552
|
+
// `_sync.json` is a file and `/_sync-status` returns whatever is in it. The
|
|
1553
|
+
// first honest snapshot of a run is not written until every provider has
|
|
1554
|
+
// been built — after a scan AND a listing fetch that waits up to 6 seconds.
|
|
1555
|
+
// For that whole window the CLI and the browser banner were reading the
|
|
1556
|
+
// LAST run's counters as current. So the window is what this asserts: the
|
|
1557
|
+
// fetch is held open, and the file must ALREADY be clean.
|
|
1558
|
+
const url = 'https://hub.example.com';
|
|
1559
|
+
writeHubsConfig(url, 'mau_test');
|
|
1560
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1561
|
+
const statusFile = join(ctx.paths.designRoot, '_sync.json');
|
|
1562
|
+
writeFileSync(
|
|
1563
|
+
statusFile,
|
|
1564
|
+
JSON.stringify({
|
|
1565
|
+
url,
|
|
1566
|
+
canvases: 76,
|
|
1567
|
+
state: 'online',
|
|
1568
|
+
docs: { synced: 0, pending: 3, rejected: 73 },
|
|
1569
|
+
rejectedSlugs: ['ui-a', 'ui-b'],
|
|
1570
|
+
})
|
|
1571
|
+
);
|
|
1572
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1573
|
+
|
|
1574
|
+
const realFetch = globalThis.fetch;
|
|
1575
|
+
let releaseListing: () => void = () => {};
|
|
1576
|
+
const listingReached = new Promise<void>((res) => {
|
|
1577
|
+
globalThis.fetch = (async () => {
|
|
1578
|
+
res();
|
|
1579
|
+
await new Promise<void>((r) => {
|
|
1580
|
+
releaseListing = r;
|
|
1581
|
+
});
|
|
1582
|
+
return new Response(JSON.stringify({ documents: [] }), { status: 200 });
|
|
1583
|
+
}) as typeof fetch;
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
try {
|
|
1587
|
+
const { factory } = inMemoryProviderFactory();
|
|
1588
|
+
const runtime = createSyncRuntime(ctx, { providerFactory: factory });
|
|
1589
|
+
const booting = runtime?.start();
|
|
1590
|
+
await listingReached;
|
|
1591
|
+
|
|
1592
|
+
// Mid-boot, with the listing still in flight — exactly where the stale
|
|
1593
|
+
// file used to be authoritative.
|
|
1594
|
+
const midBoot = JSON.parse(readFileSync(statusFile, 'utf8'));
|
|
1595
|
+
expect(midBoot.docs.rejected).toBe(0);
|
|
1596
|
+
expect(midBoot.rejectedSlugs ?? []).toEqual([]);
|
|
1597
|
+
expect(midBoot.state).toBe('connecting');
|
|
1598
|
+
|
|
1599
|
+
releaseListing();
|
|
1600
|
+
await booting;
|
|
1601
|
+
await runtime?.stop();
|
|
1602
|
+
} finally {
|
|
1603
|
+
globalThis.fetch = realFetch;
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1174
1607
|
test('boot summary prints AFTER settle with honest counts (not the premature N/N)', async () => {
|
|
1175
1608
|
const url = 'https://hub.example.com';
|
|
1176
1609
|
writeHubsConfig(url, 'mau_test');
|
|
@@ -151,7 +151,7 @@ describe('video-comp seek bridge', () => {
|
|
|
151
151
|
expect(a.seeks).toEqual([42, 42]);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test('a Player
|
|
154
|
+
test('a not-yet-ready Player is resilient in UI mode but still RECORDED', async () => {
|
|
155
155
|
const w = window as unknown as SeekWindow;
|
|
156
156
|
installMaudeSeekBridge();
|
|
157
157
|
if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
|
|
@@ -163,6 +163,28 @@ describe('video-comp seek bridge', () => {
|
|
|
163
163
|
height: 360,
|
|
164
164
|
ref: { current: null },
|
|
165
165
|
});
|
|
166
|
+
// A live scrub of a still-mounting comp must not blow up the UI...
|
|
167
|
+
const before = w.__maude_seek_failures__ ?? 0;
|
|
166
168
|
await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
|
|
169
|
+
// ...but it is no longer silent: the capture shim reads this counter and
|
|
170
|
+
// refuses to encode, because a seek that never landed means a stale frame.
|
|
171
|
+
expect(w.__maude_seek_failures__ ?? 0).toBeGreaterThan(before);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('capture mode (strict) throws instead of pretending the seek landed', async () => {
|
|
175
|
+
const w = window as unknown as SeekWindow;
|
|
176
|
+
installMaudeSeekBridge();
|
|
177
|
+
if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
|
|
178
|
+
w.__maudeVideoComps.set('pending-strict', {
|
|
179
|
+
id: 'pending-strict',
|
|
180
|
+
fps: 30,
|
|
181
|
+
durationInFrames: 90,
|
|
182
|
+
width: 640,
|
|
183
|
+
height: 360,
|
|
184
|
+
ref: { current: null },
|
|
185
|
+
});
|
|
186
|
+
await expect(w.__maude_seek__?.(10, { strict: true })).rejects.toThrow(
|
|
187
|
+
/refusing to report a seek that did not happen/
|
|
188
|
+
);
|
|
167
189
|
});
|
|
168
190
|
});
|
|
@@ -81,7 +81,20 @@ interface MaudeSeekWindow {
|
|
|
81
81
|
__maudeVideoComps?: Map<string, CompEntry>;
|
|
82
82
|
/** Seek every registered comp to `frame` (paused). Ordinary artboards fall
|
|
83
83
|
* back to a time-based WAAPI seek. Resolves once the frame has painted. */
|
|
84
|
-
__maude_seek__?: (
|
|
84
|
+
__maude_seek__?: (
|
|
85
|
+
frame: number,
|
|
86
|
+
opts?: {
|
|
87
|
+
fps?: number;
|
|
88
|
+
/** Capture mode: throw when a seek never lands, instead of only counting it. */
|
|
89
|
+
strict?: boolean;
|
|
90
|
+
}
|
|
91
|
+
) => Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Count of seeks that never landed. The capture shim reads this and refuses
|
|
94
|
+
* to ship the export rather than encoding a stale frame — a silent wrong
|
|
95
|
+
* frame is worse than a loud failure.
|
|
96
|
+
*/
|
|
97
|
+
__maude_seek_failures__?: number;
|
|
85
98
|
/** Serializable comp list for the capture shim / Timeline panel. */
|
|
86
99
|
__maude_comps__?: () => CompSnapshot[];
|
|
87
100
|
/**
|
|
@@ -140,6 +153,36 @@ function afterPaint(): Promise<void> {
|
|
|
140
153
|
});
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
/** How many paint-spaced attempts a cold Player gets before a seek is a failure. */
|
|
157
|
+
const SEEK_ATTEMPTS = 3;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Seek one comp, giving a not-yet-mounted Player a bounded number of chances.
|
|
161
|
+
*
|
|
162
|
+
* Returns whether the seek actually landed. The retry replaces the old silent
|
|
163
|
+
* swallow: a Player that is genuinely mid-mount settles within a paint or two,
|
|
164
|
+
* and one that never settles is a real fault the caller must not paper over.
|
|
165
|
+
*/
|
|
166
|
+
async function seekEntryWithRetry(
|
|
167
|
+
ref: { current: { pause?: () => void; seekTo: (f: number) => void } | null },
|
|
168
|
+
frame: number
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
for (let attempt = 0; attempt < SEEK_ATTEMPTS; attempt += 1) {
|
|
171
|
+
try {
|
|
172
|
+
const player = ref.current;
|
|
173
|
+
if (player) {
|
|
174
|
+
player.pause?.();
|
|
175
|
+
player.seekTo(frame);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
/* fall through to the retry — the next paint may settle it */
|
|
180
|
+
}
|
|
181
|
+
if (attempt < SEEK_ATTEMPTS - 1) await afterPaint();
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
143
186
|
/**
|
|
144
187
|
* Install the singleton seek bridge on `window`. Idempotent — the first
|
|
145
188
|
* VideoComp to mount wins; later mounts reuse the same functions. The bridge
|
|
@@ -156,11 +199,32 @@ export function installMaudeSeekBridge(): void {
|
|
|
156
199
|
if (entries.length > 0) {
|
|
157
200
|
for (const e of entries) {
|
|
158
201
|
const f = clamp(Math.round(frame), 0, Math.max(0, e.durationInFrames - 1));
|
|
159
|
-
try
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
202
|
+
// This used to be a bare try/catch with an empty body and the comment
|
|
203
|
+
// "Player not yet ready — a later seek settles it". That made a seek
|
|
204
|
+
// that COMPLETELY FAILED resolve as success: `afterPaint()` proves a
|
|
205
|
+
// paint happened, not that the right content was in it. Serially it was
|
|
206
|
+
// benign — frame N+1 repaired frame N's miss, and only frame 0 was ever
|
|
207
|
+
// exposed — which is exactly why it survived. It is not benign the
|
|
208
|
+
// moment anything captures ranges in parallel: every range boundary is a
|
|
209
|
+
// first seek on a cold page, so "a later seek settles it" would be
|
|
210
|
+
// invoked once per worker instead of once per export, and a stale frame
|
|
211
|
+
// is a valid-looking file with wrong pixels, not a crash.
|
|
212
|
+
//
|
|
213
|
+
// So the hope becomes an explicit bounded retry, and exhausting it is a
|
|
214
|
+
// real failure the capture shim can see and refuse to ship.
|
|
215
|
+
if (!(await seekEntryWithRetry(e.ref, f))) {
|
|
216
|
+
// ALWAYS recorded, so the capture shim can refuse the export even on
|
|
217
|
+
// the non-strict path. Only THROWN under `strict`, which the shim
|
|
218
|
+
// passes: a live UI scrub of a still-mounting comp should stay
|
|
219
|
+
// resilient, but a capture must never proceed on a seek that did not
|
|
220
|
+
// happen. The old code did neither — it just resolved.
|
|
221
|
+
w.__maude_seek_failures__ = (w.__maude_seek_failures__ ?? 0) + 1;
|
|
222
|
+
if (opts?.strict) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`__maude_seek__: comp did not accept frame ${f} after ${SEEK_ATTEMPTS} attempts ` +
|
|
225
|
+
'(Player never became ready) — refusing to report a seek that did not happen'
|
|
226
|
+
);
|
|
227
|
+
}
|
|
164
228
|
}
|
|
165
229
|
}
|
|
166
230
|
await afterPaint();
|
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "cloud-sync-matches-your-desktop",
|
|
6
|
+
"version": "0.58.3",
|
|
7
|
+
"date": "2026-08-11",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "The cloud copy matches your desktop",
|
|
10
|
+
"summary": "A project you connect to the cloud now looks exactly like it does on your machine: no duplicated files, every canvas opens instead of breaking, and your images show up instead of grey boxes. The cloud picker says \"Connected\" for the project this folder is linked to, with a Disconnect right there — and once you're connected, the cloud saves every change automatically, so there's one place your work is saved, not two.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "cloud-link-self-renews",
|
|
15
|
+
"version": "0.58.3",
|
|
16
|
+
"date": "2026-08-11",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Cloud sync keeps itself signed in",
|
|
19
|
+
"summary": "A workspace you connect to the cloud stays synced — the session refreshes itself in the background instead of quietly expiring after a few hours and stranding your canvases on \"connecting…\". If a link ever can't sync for a few minutes it now says so and points you to reconnect, instead of spinning forever.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "figma-import",
|
|
24
|
+
"version": "0.58.3",
|
|
25
|
+
"date": "2026-08-11",
|
|
26
|
+
"kind": "feature",
|
|
27
|
+
"title": "Bring your Figma work in",
|
|
28
|
+
"summary": "Paste a Figma link and get the real thing, not a picture of it: design frames become editable canvases, a FigJam board becomes your whiteboard with its sticky notes and arrows still attached, and your colour and text styles become design tokens. Add a Figma access token once in Settings to get started.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "sync-carries-the-path",
|
|
33
|
+
"version": "0.58.2",
|
|
34
|
+
"date": "2026-08-08",
|
|
35
|
+
"kind": "fix",
|
|
36
|
+
"title": "Canvases arrive in the folder you made",
|
|
37
|
+
"summary": "A canvas you create in a nested folder now shows up on your other machine — and in the cloud — in that same folder, instead of quietly landing somewhere the file tree never listed. Connecting a project to an empty folder brings the whole thing down with its structure intact.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
4
40
|
{
|
|
5
41
|
"id": "release-version-visible",
|
|
6
42
|
"version": "0.58.0",
|
|
@@ -66,6 +66,10 @@ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: stri
|
|
|
66
66
|
prefix: '/_api/generate',
|
|
67
67
|
why: 'media generation runs tenant-authored prompts against a provider key held here',
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
prefix: '/_api/figma',
|
|
71
|
+
why: 'the Figma lane STORES and SPENDS a user personal access token, and imports write the design root — a cell must hold no provider credential (DDR-216 D2/D3, same class as /_api/generate)',
|
|
72
|
+
},
|
|
69
73
|
{
|
|
70
74
|
prefix: '/_api/shell-shot',
|
|
71
75
|
why: 'the shell screenshot spawns a headless browser against the studio — the same evaluation `/_api/export` is forbidden for',
|