@1agh/maude 0.58.2 → 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.
Files changed (105) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/api.ts +6 -1
  3. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  4. package/apps/studio/bin/_import-asset.mjs +72 -0
  5. package/apps/studio/bin/_import-figma.mjs +1121 -0
  6. package/apps/studio/bin/_video-playwright.mjs +86 -3
  7. package/apps/studio/bin/import-figma.sh +38 -0
  8. package/apps/studio/bin/read-annotations.mjs +11 -1
  9. package/apps/studio/bun.lock +16 -22
  10. package/apps/studio/canvas-edit.ts +29 -5
  11. package/apps/studio/client/app.jsx +44 -1
  12. package/apps/studio/client/export-center.jsx +42 -4
  13. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  14. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  15. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  16. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  17. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  18. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  19. package/apps/studio/client/panels/timeline-parse.js +3 -3
  20. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  21. package/apps/studio/client/styles/4-components.css +134 -0
  22. package/apps/studio/clip-ops.ts +93 -17
  23. package/apps/studio/cloud/endpoints.ts +78 -10
  24. package/apps/studio/cloud/renew.ts +183 -0
  25. package/apps/studio/context.ts +2 -1
  26. package/apps/studio/dist/client.bundle.js +1261 -1261
  27. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  28. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  29. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  30. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  31. package/apps/studio/dist/runtime/remotion.js +12 -12
  32. package/apps/studio/dist/styles.css +1 -1
  33. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  34. package/apps/studio/exporters/_runtime.ts +19 -0
  35. package/apps/studio/exporters/degraded.ts +92 -0
  36. package/apps/studio/exporters/index.ts +5 -0
  37. package/apps/studio/exporters/jobs.ts +19 -0
  38. package/apps/studio/exporters/unsupported-media.ts +170 -0
  39. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  40. package/apps/studio/exporters/video-render-lib.ts +6 -0
  41. package/apps/studio/exporters/video.ts +62 -1
  42. package/apps/studio/figma/assets.test.ts +372 -0
  43. package/apps/studio/figma/assets.ts +398 -0
  44. package/apps/studio/figma/client.test.ts +395 -0
  45. package/apps/studio/figma/client.ts +513 -0
  46. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  47. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  48. package/apps/studio/figma/endpoints.ts +200 -0
  49. package/apps/studio/figma/sanitize.test.ts +256 -0
  50. package/apps/studio/figma/sanitize.ts +315 -0
  51. package/apps/studio/figma/style-map.ts +352 -0
  52. package/apps/studio/figma/to-artboard.test.ts +808 -0
  53. package/apps/studio/figma/to-artboard.ts +701 -0
  54. package/apps/studio/figma/to-render.test.ts +180 -0
  55. package/apps/studio/figma/to-render.ts +306 -0
  56. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  57. package/apps/studio/figma/to-strokes.test.ts +705 -0
  58. package/apps/studio/figma/to-strokes.ts +749 -0
  59. package/apps/studio/figma/to-tokens.test.ts +321 -0
  60. package/apps/studio/figma/to-tokens.ts +305 -0
  61. package/apps/studio/figma/types.ts +539 -0
  62. package/apps/studio/figma/url.test.ts +167 -0
  63. package/apps/studio/figma/url.ts +160 -0
  64. package/apps/studio/http.ts +129 -0
  65. package/apps/studio/sync/asset-push.ts +124 -0
  66. package/apps/studio/sync/connection-state.ts +11 -0
  67. package/apps/studio/sync/hub-link.ts +63 -7
  68. package/apps/studio/sync/hubs-config.ts +31 -3
  69. package/apps/studio/sync/index.ts +276 -26
  70. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  71. package/apps/studio/sync/presentation.ts +45 -1
  72. package/apps/studio/sync/supervisor.ts +5 -1
  73. package/apps/studio/sync/workspace-signin.ts +7 -3
  74. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  75. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  76. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  77. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  78. package/apps/studio/test/clip-addressing.test.ts +6 -1
  79. package/apps/studio/test/clip-ops.test.ts +5 -1
  80. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  81. package/apps/studio/test/cloud-renew.test.ts +205 -0
  82. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  83. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  84. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  85. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  86. package/apps/studio/test/figma-provenance.test.ts +108 -0
  87. package/apps/studio/test/figma-routes.test.ts +294 -0
  88. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  89. package/apps/studio/test/hub-link.test.ts +11 -0
  90. package/apps/studio/test/import-figma.test.ts +479 -0
  91. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  92. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  93. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  94. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  95. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  96. package/apps/studio/test/sync-presentation.test.ts +77 -0
  97. package/apps/studio/test/sync-runtime.test.ts +316 -1
  98. package/apps/studio/test/video-comp.test.ts +23 -1
  99. package/apps/studio/test/workspace-containment.test.ts +1 -0
  100. package/apps/studio/video-comp.tsx +70 -6
  101. package/apps/studio/whats-new.json +27 -0
  102. package/apps/studio/workspace-mode.ts +4 -0
  103. package/cli/commands/design.mjs +8 -0
  104. package/package.json +8 -8
  105. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -22,7 +22,7 @@
22
22
  // phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
23
23
  // is the loopback caller gate + no reflection + no token on the probe.
24
24
 
25
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
26
26
  import { dirname } from 'node:path';
27
27
 
28
28
  import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
@@ -35,7 +35,7 @@ export interface HubLinkResult {
35
35
  const HUB_PROBE_TIMEOUT_MS = 4000;
36
36
 
37
37
  interface HubsFile {
38
- hubs: Record<string, { token: string; linkedAt: number }>;
38
+ hubs: Record<string, { token: string; linkedAt: number; role?: string; expiresAt?: number }>;
39
39
  trusted?: string[];
40
40
  }
41
41
 
@@ -108,8 +108,20 @@ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string
108
108
  }
109
109
  }
110
110
 
111
- /** Upsert the token (+ the vouched role) under `normUrl` + record per-machine trust; mode 0600. */
112
- export function saveHubCredential(normUrl: string, token: string, role?: string): void {
111
+ /**
112
+ * Upsert the token (+ the vouched role + expiry) under `normUrl` + record
113
+ * per-machine trust; mode 0600.
114
+ *
115
+ * The upsert REPLACES the record, so a caller that knows the role/expiry must
116
+ * pass them again or they are dropped — the silent-renewal path reads the old
117
+ * record first and carries the role forward for exactly this reason.
118
+ */
119
+ export function saveHubCredential(
120
+ normUrl: string,
121
+ token: string,
122
+ role?: string,
123
+ expiresAt?: number
124
+ ): void {
113
125
  const path = hubsConfigPath();
114
126
  const dir = dirname(path);
115
127
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -123,15 +135,59 @@ export function saveHubCredential(normUrl: string, token: string, role?: string)
123
135
  /* malformed → start fresh rather than throw */
124
136
  }
125
137
  }
126
- cfg.hubs[normUrl] = { token, linkedAt: Date.now(), ...(role ? { role } : {}) };
138
+ cfg.hubs[normUrl] = {
139
+ token,
140
+ linkedAt: Date.now(),
141
+ ...(role ? { role } : {}),
142
+ ...(typeof expiresAt === 'number' && Number.isFinite(expiresAt) ? { expiresAt } : {}),
143
+ };
127
144
  if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
128
145
  if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
129
- writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
146
+ // F3 (2026-08-10 review) — atomic temp-then-rename. This write used to run
147
+ // only when a human pressed Connect; silent renewal now fires it on a timer,
148
+ // several times a minute under a rejection burst. A truncating in-place
149
+ // writeFileSync that is interrupted (crash / kill / full disk) leaves a
150
+ // half-written file — and since it holds EVERY hub credential on the machine,
151
+ // that drops them all. rename(2) is atomic on POSIX: a reader sees the whole
152
+ // old file or the whole new one, never a torn one.
153
+ const tmp = `${path}.${process.pid}.tmp`;
154
+ writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
155
+ try {
156
+ chmodSync(tmp, 0o600);
157
+ } catch {
158
+ /* windows / read-only fs — best effort */
159
+ }
160
+ renameSync(tmp, path);
161
+ }
162
+
163
+ /**
164
+ * Forget the credential + per-machine trust for `normUrl` — the unlink half of
165
+ * `saveHubCredential`. Idempotent (a missing entry is simply done), and atomic
166
+ * for the same reason the save is: the file holds EVERY hub credential on the
167
+ * machine, so a torn write drops them all.
168
+ */
169
+ export function deleteHubCredential(normUrl: string): void {
170
+ const path = hubsConfigPath();
171
+ if (!existsSync(path)) return;
172
+ let cfg: HubsFile;
173
+ try {
174
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
175
+ if (!parsed || typeof parsed.hubs !== 'object' || parsed.hubs === null) return;
176
+ cfg = parsed as HubsFile;
177
+ } catch {
178
+ return; // malformed → nothing recoverable to forget
179
+ }
180
+ if (!(normUrl in cfg.hubs) && !cfg.trusted?.includes(normUrl)) return;
181
+ delete cfg.hubs[normUrl];
182
+ if (Array.isArray(cfg.trusted)) cfg.trusted = cfg.trusted.filter((u) => u !== normUrl);
183
+ const tmp = `${path}.${process.pid}.tmp`;
184
+ writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
130
185
  try {
131
- chmodSync(path, 0o600);
186
+ chmodSync(tmp, 0o600);
132
187
  } catch {
133
188
  /* windows / read-only fs — best effort */
134
189
  }
190
+ renameSync(tmp, path);
135
191
  }
136
192
 
137
193
  export const __testing = { probeHealth };
@@ -23,6 +23,20 @@ export interface HubRecord {
23
23
  * write-capable, and nobody is demoted by an upgrade.
24
24
  */
25
25
  role?: string;
26
+ /**
27
+ * When this credential dies (ms epoch), as the workspace reported at mint.
28
+ *
29
+ * A cell session expires WITH the 12 h project token that minted it (Phase
30
+ * 23 B2 — that cap is the revocation window and must stay). What was
31
+ * missing is anyone LOOKING at the deadline: the token was saved, the
32
+ * expiry discarded, and ≤ 12 h later all sync died into a permanent
33
+ * `connecting…`. Stored so the runtime can renew BEFORE it, silently.
34
+ *
35
+ * Absent on self-hosted hubs (their tokens may not expire) and on every
36
+ * credential written before this shipped — both mean "no scheduled
37
+ * renewal", which is exactly the old behaviour.
38
+ */
39
+ expiresAt?: number;
26
40
  }
27
41
 
28
42
  export interface HubsConfig {
@@ -40,6 +54,16 @@ export function hubsConfigPath(): string {
40
54
  /** Normalize a hub URL — trim trailing slash, lower-case scheme + host. */
41
55
  export function normalizeUrl(url: string): string {
42
56
  const u = new URL(url);
57
+ // Reject embedded credentials outright (2026-08-10 review, claim-a residual).
58
+ // `URL.toString()` PRESERVES `user:pass@` — the renewal lane is safe only
59
+ // because it fetches the normalized string and requires it to equal a URL the
60
+ // cloud itself listed. A `https://evil@proj.cloud.maude.sh` config would
61
+ // survive normalization intact; refusing it here means such a value can never
62
+ // be stored or matched, so a later "fetch hubUrl directly" edit can't become
63
+ // credential exfiltration. A real hub URL never carries userinfo.
64
+ if (u.username || u.password) {
65
+ throw new Error('hub URL must not contain embedded credentials');
66
+ }
43
67
  u.protocol = u.protocol.toLowerCase();
44
68
  u.hostname = u.hostname.toLowerCase();
45
69
  let str = u.toString();
@@ -108,11 +132,15 @@ export function isHubReadOnly(url: string): boolean {
108
132
 
109
133
  /** Look up a token for `url`. Returns null when no entry exists. */
110
134
  export function getHubToken(url: string): string | null {
135
+ return getHubRecord(url)?.token ?? null;
136
+ }
137
+
138
+ /** The whole stored record for `url` (token + role + expiresAt), or null. */
139
+ export function getHubRecord(url: string): HubRecord | null {
111
140
  try {
112
141
  const norm = normalizeUrl(url);
113
- const cfg = loadHubsConfig();
114
- const record = cfg.hubs[norm];
115
- return record ? record.token : null;
142
+ const record = loadHubsConfig().hubs[norm];
143
+ return record && typeof record.token === 'string' ? record : null;
116
144
  } catch {
117
145
  return null;
118
146
  }
@@ -23,12 +23,13 @@ import path from 'node:path';
23
23
 
24
24
  import type { Awareness } from 'y-protocols/awareness';
25
25
  import * as Y from 'yjs';
26
-
26
+ import { renewHubCredential } from '../cloud/renew.ts';
27
27
  import { Y_TYPES } from '../collab/persistence.ts';
28
28
  import type { Context, LinkedHub } from '../context.ts';
29
29
  import { createHistory } from '../history.ts';
30
30
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
31
31
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
32
+ import { pushAssets } from './asset-push.ts';
32
33
  import { atomicWrite } from './atomic-write.ts';
33
34
  import { createAutoCommit } from './autocommit.ts';
34
35
  import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
@@ -41,9 +42,10 @@ import {
41
42
  import { createDocNameResolver } from './doc-name.ts';
42
43
  import { createEchoGuard } from './echo-guard.ts';
43
44
  import { createFsReader, type FsReader } from './fs-mirror.ts';
44
- import { getHubToken } from './hubs-config.ts';
45
+ import { getHubRecord } from './hubs-config.ts';
45
46
  import { loadJournal, type SyncJournal } from './journal.ts';
46
47
  import { isLoopbackHost } from './loopback.ts';
48
+ import { migrateFlatFallback } from './migrate-flat-fallback.ts';
47
49
  import { migrateSeed } from './migrate-seed.ts';
48
50
  import { ORIGINS } from './origins.ts';
49
51
  import { createDocProjection, type DocProjection } from './projection.ts';
@@ -96,15 +98,50 @@ export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token'
96
98
  * → `generic` (interop-safe degradation). */
97
99
  export function classifyAuthFailure(raw: string): AuthFailureClass {
98
100
  const s = raw.toLowerCase();
99
- if (s.includes('rate limit')) return 'rate-limit';
100
- if (s.includes('not authorized')) return 'not-authorized';
101
+ // Permanent classes FIRST. The hub's invalid-token bucket refuses with
102
+ // "invalid token — rate limited, retry in up to 60s": both substrings are
103
+ // present, and testing 'rate limit' first filed an expired credential under
104
+ // the transient class — so the runtime retried into the very bucket
105
+ // refusing it, and the one cause that needed a person never surfaced.
101
106
  if (s.includes('invalid token')) return 'invalid-token';
107
+ if (s.includes('not authorized')) return 'not-authorized';
108
+ if (s.includes('rate limit')) return 'rate-limit';
102
109
  return 'generic';
103
110
  }
104
111
 
105
112
  export const AUTH_WARN_DEBOUNCE_MS = 2_000;
106
113
  export const AUTH_REPROBE_MS = 5 * 60 * 1000;
107
114
  export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
115
+ /** F1 — minimum wall-clock between renewal attempts. A burst of rejections
116
+ * collapses to one renewal (single-flight); the NEXT burst waits this out. */
117
+ export const RENEW_MIN_INTERVAL_MS = 60_000;
118
+ /** F1 — consecutive successful renewals with no completed handshake in between
119
+ * before the runtime stops renewing and lets the link surface as
120
+ * refused/stalled. Small: a renewal that fixed the credential clears at least
121
+ * one doc, so >0 useless renewals means the credential was never the cause. */
122
+ export const RENEW_MAX_WITHOUT_PROGRESS = 3;
123
+ /** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
124
+ * far-future expiry into a tight renewal loop. Clamp before we hand it over. */
125
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
126
+ /** F2 — a hub-reported `expiresAt` further out than this is not believed for
127
+ * scheduling (cloud cells issue ≤ 12 h; a month is already implausible). */
128
+ export const MAX_CREDENTIAL_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
129
+
130
+ /**
131
+ * A hub-reported `expiresAt` sane enough to schedule against, or null.
132
+ *
133
+ * F2: the value crosses the DDR-054 trust boundary (a hub picks it) and is fed
134
+ * to `setTimeout`. A non-integer, a past stamp, or one absurdly far out (skew,
135
+ * or a hostile hub planting a far-future value to arm a tight loop) must not
136
+ * schedule a renewal — null means "no timer", the safe pre-existing behaviour.
137
+ * A credential already expired simply falls to the invalid-token path instead.
138
+ */
139
+ export function validExpiry(raw: unknown, nowMs: number = Date.now()): number | null {
140
+ if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
141
+ if (raw <= nowMs) return null;
142
+ if (raw - nowMs > MAX_CREDENTIAL_LIFETIME_MS) return null;
143
+ return raw;
144
+ }
108
145
 
109
146
  const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
110
147
  'rate-limit':
@@ -202,6 +239,22 @@ export interface CreateSyncRuntimeOptions {
202
239
  settleTimeoutMs?: number;
203
240
  setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
204
241
  clearTimer?: (h: ReturnType<typeof setTimeout>) => void;
242
+ /**
243
+ * Silent credential renewal (cloud workspaces). Called single-flight at
244
+ * ~80 % of the stored credential's remaining life and on any
245
+ * `invalid-token` rejection; a non-null return swaps the runtime's token
246
+ * in place and re-probes rejected docs immediately. Default: the Maude
247
+ * Cloud renewal lane (`cloud/renew.ts`); it answers null for a machine
248
+ * that is not signed in, so self-hosted hubs keep today's behaviour.
249
+ * Never called under cell pairing (the cell's token comes from its env).
250
+ */
251
+ renewCredential?: () => Promise<{ token: string; expiresAt: number | null } | null>;
252
+ /** F1 — clock for the renewal frequency floor (test injection). Default Date.now. */
253
+ now?: () => number;
254
+ /** F1 — minimum ms between renewal attempts. Default RENEW_MIN_INTERVAL_MS. */
255
+ renewMinIntervalMs?: number;
256
+ /** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
257
+ renewMaxWithoutProgress?: number;
205
258
  };
206
259
  }
207
260
 
@@ -355,14 +408,21 @@ export function createSyncRuntime(
355
408
  // this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
356
409
  // credential store and does not exist in a cell (HOME=/tmp) — which is exactly
357
410
  // why the old guard was unreachable by accident rather than by design.
358
- const resolvedToken = cellPairing ? cellPairing.token : getHubToken(linkedHub.url);
411
+ const storedRecord = cellPairing ? null : getHubRecord(linkedHub.url);
412
+ const resolvedToken = cellPairing ? cellPairing.token : (storedRecord?.token ?? null);
359
413
  if (!resolvedToken) {
360
414
  console.warn(
361
415
  `[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
362
416
  );
363
417
  return null;
364
418
  }
365
- const token: string = resolvedToken;
419
+ // MUTABLE on purpose: silent renewal swaps it in place, and connectCanvas
420
+ // reads it at call time — so every provider (re)created after a renewal
421
+ // carries the fresh credential without a runtime restart.
422
+ let token: string = resolvedToken;
423
+ // Through validExpiry (F2) — the stored value came off disk written from a
424
+ // hub body, so a bogus/far-future stamp must not schedule a renewal at boot.
425
+ let tokenExpiresAt: number | null = validExpiry(storedRecord?.expiresAt);
366
426
 
367
427
  // DDR-102 — the default factory multiplexes every provider over ONE shared
368
428
  // WebSocket per hub URL; the runtime owns its disposal (stop(), after the
@@ -418,6 +478,36 @@ export function createSyncRuntime(
418
478
  >();
419
479
  let authWarnTimer: TimerHandle | null = null;
420
480
  let reprobeTimer: TimerHandle | null = null;
481
+ let renewTimer: TimerHandle | null = null;
482
+ /** Single-flight guard: 73 rejected docs must trigger ONE renewal, not 73. */
483
+ let renewInFlight: Promise<boolean> | null = null;
484
+ // F1 (2026-08-10 security review) — RATE DISCIPLINE on the renew↔reprobe
485
+ // cycle. Single-flight bounds concurrency; these bound FREQUENCY. Without
486
+ // them a legitimate cell refusing on VOLUME (its invalid-token bucket, which
487
+ // now answers "invalid token — rate limited") classifies permanent →
488
+ // triggers renewal → renewal succeeds (the token was never the problem) →
489
+ // reprobeNow reconnects all N docs → the bucket refuses them again → loop.
490
+ // Reproduced at 2342 renewals/s. This is the exact retry-storm class the
491
+ // whole change exists to end, one level up — so it gets a floor AND a cap.
492
+ const renewNow = opts.auth?.now ?? (() => Date.now());
493
+ const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
494
+ const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
495
+ /** Wall-clock of the last renewal attempt (0 = never). The floor. */
496
+ let lastRenewAt = 0;
497
+ /** Successful renewals since the last completed handshake. The cap: a
498
+ * renewal that keeps succeeding while nothing connects is not fixing
499
+ * anything — stop, and let the link surface as refused/stalled. Reset to 0
500
+ * at every `connected` doc (the handshake-success point). */
501
+ let renewalsSinceProgress = 0;
502
+ // Silent credential renewal (cloud). Absent under cell pairing — the cell's
503
+ // token arrives via its environment and is the hub's own to rotate.
504
+ const renewCredential = cellPairing
505
+ ? null
506
+ : (opts.auth?.renewCredential ??
507
+ (async () => {
508
+ const r = await renewHubCredential(linkedHub.url);
509
+ return r.ok ? { token: r.token, expiresAt: r.expiresAt } : null;
510
+ }));
421
511
  const settleTimers = new Set<TimerHandle>();
422
512
  /** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
423
513
  * design-root-relative path so a second write to the same file inside the
@@ -446,6 +536,18 @@ export function createSyncRuntime(
446
536
  // honest seed (`connecting`, nothing known) the moment the runtime starts.
447
537
  resetPersistedStatus(ctx, linkedHub.url);
448
538
 
539
+ // Fix 4 (sync RCA 2026-08-10) — one-shot quarantine of the pre-fix-5 flat
540
+ // fallback twins, BEFORE the scan: a flat twin that survived into the scan
541
+ // would sync as its own document and re-seed the duplicate on every peer.
542
+ // Skipped under cell pairing — a cell's checkout is the hub's to manage,
543
+ // and quarantining there would dirty a tree the hub commits.
544
+ if (!cellPairing) {
545
+ migrateFlatFallback({
546
+ designRoot: ctx.paths.designRoot,
547
+ designRel: ctx.paths.designRel,
548
+ });
549
+ }
550
+
449
551
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
450
552
  // DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
451
553
  // the pinned set must be bounded. See `admitCanvases`.
@@ -753,22 +855,112 @@ export function createSyncRuntime(
753
855
  console.warn(`[sync] hub auth rejections (${linkedHub.url}):\n${lines.join('\n')}`);
754
856
  };
755
857
 
858
+ /** Reconnect every permanently-rejected doc now. Idempotent — the map is
859
+ * drained, so a second call during the same burst is a no-op. */
860
+ const reprobeNow = (): void => {
861
+ if (stopped) return;
862
+ if (reprobeTimer !== null) {
863
+ authClearTimer(reprobeTimer);
864
+ reprobeTimer = null;
865
+ }
866
+ const entries = [...rejectedPermanent.values()];
867
+ rejectedPermanent.clear();
868
+ for (const entry of entries) {
869
+ mon.noteDocState(entry.canvas.slug, 'pending');
870
+ void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
871
+ console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
872
+ });
873
+ }
874
+ };
875
+
756
876
  const scheduleReprobe = (): void => {
757
877
  if (reprobeTimer !== null || stopped) return;
758
878
  reprobeTimer = authSetTimer(() => {
759
879
  reprobeTimer = null;
760
- if (stopped) return;
761
- const entries = [...rejectedPermanent.values()];
762
- rejectedPermanent.clear();
763
- for (const entry of entries) {
764
- mon.noteDocState(entry.canvas.slug, 'pending');
765
- void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
766
- console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
767
- });
768
- }
880
+ reprobeNow();
769
881
  }, reprobeMs);
770
882
  };
771
883
 
884
+ /**
885
+ * Renew the hub credential in place — single-flight, silent, and never
886
+ * worse than failure: an unrenewable credential leaves the stored one
887
+ * untouched and every existing behaviour (slow re-probe, refused status)
888
+ * exactly as it was.
889
+ */
890
+ const renewCredentialNow = (): Promise<boolean> => {
891
+ if (!renewCredential || stopped) return Promise.resolve(false);
892
+ if (renewInFlight) return renewInFlight;
893
+ // F1 — the cap: renewals that keep succeeding without a handshake landing
894
+ // are not fixing anything (a real fix clears at least one doc, which
895
+ // resets this to 0). Stop, and let the docs' own rejected state surface
896
+ // as refused/stalled — both name "reconnect the workspace".
897
+ if (renewalsSinceProgress >= renewMaxWithoutProgress) return Promise.resolve(false);
898
+ // F1 — the floor: one renewal per interval, whatever the outcome. Stamped
899
+ // here (commit point), not on success, so a failing renewal also waits.
900
+ const sinceLast = renewNow() - lastRenewAt;
901
+ if (lastRenewAt !== 0 && sinceLast < renewMinIntervalMs) return Promise.resolve(false);
902
+ lastRenewAt = renewNow();
903
+ renewInFlight = (async () => {
904
+ try {
905
+ const fresh = await renewCredential();
906
+ if (stopped || !fresh || typeof fresh.token !== 'string' || !fresh.token) return false;
907
+ token = fresh.token;
908
+ tokenExpiresAt = validExpiry(fresh.expiresAt);
909
+ renewalsSinceProgress++;
910
+ console.log(`[sync] hub credential renewed for ${linkedHub.url}`);
911
+ scheduleRenewal();
912
+ return true;
913
+ } catch (err) {
914
+ console.warn(`[sync] hub credential renewal failed: ${(err as Error).message}`);
915
+ return false;
916
+ } finally {
917
+ renewInFlight = null;
918
+ }
919
+ })();
920
+ return renewInFlight;
921
+ };
922
+
923
+ /**
924
+ * Arm the pre-expiry renewal at ~80 % of the credential's REMAINING life
925
+ * (never sooner than a minute out). No stored expiry — a self-hosted hub,
926
+ * or a credential written before expiry was persisted — means no timer:
927
+ * exactly the old behaviour. A failed renewal retries on the re-probe
928
+ * cadence; once the credential actually dies, the invalid-token path
929
+ * below triggers renewal anyway, so the timer is an optimization, not the
930
+ * safety net.
931
+ */
932
+ const scheduleRenewal = (): void => {
933
+ if (renewTimer !== null) {
934
+ authClearTimer(renewTimer);
935
+ renewTimer = null;
936
+ }
937
+ if (!renewCredential || stopped || tokenExpiresAt === null) return;
938
+ // F2 — clamp both ends. The lower floor keeps a near-expiry credential
939
+ // from arming an immediate timer; the upper clamp stops a far-future
940
+ // `expiresAt` (validExpiry already rejects > 30 d, but a peer's clock
941
+ // skew can still land the *delay* past setTimeout's int32 ceiling, where
942
+ // it silently fires at 1 ms) from becoming a tight loop.
943
+ const raw = (tokenExpiresAt - renewNow()) * 0.8;
944
+ const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(60_000, raw));
945
+ renewTimer = authSetTimer(() => {
946
+ renewTimer = null;
947
+ void renewCredentialNow().then((ok) => {
948
+ // F4 — a failed pre-expiry renewal RE-ARMS on the slow cadence
949
+ // (through scheduleRenewal, so the frequency floor + cap still
950
+ // apply) instead of leaving the process with no scheduled renewal
951
+ // for the rest of its life.
952
+ if (!ok && !stopped && tokenExpiresAt !== null) {
953
+ renewTimer = authSetTimer(() => {
954
+ renewTimer = null;
955
+ void renewCredentialNow().then((ok2) => {
956
+ if (!ok2) scheduleRenewal();
957
+ });
958
+ }, reprobeMs);
959
+ }
960
+ });
961
+ }, delay);
962
+ };
963
+
772
964
  const handleAuthFailure = (
773
965
  canvas: CanvasDescriptor,
774
966
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
@@ -797,6 +989,17 @@ export function createSyncRuntime(
797
989
  }
798
990
  scheduleReprobe();
799
991
  }
992
+ // An invalid token is the one refusal the runtime can FIX: the stored
993
+ // credential expired (≤ 12 h cell sessions, Phase 23 B2) while the
994
+ // account next to it is still signed in. Renew silently — single-flight
995
+ // across the whole burst — and on success reconnect the rejected docs
996
+ // NOW instead of making the user wait out the slow re-probe (or press
997
+ // Connect again, which is all this ever needed).
998
+ if (reasonClass === 'invalid-token') {
999
+ void renewCredentialNow().then((renewed) => {
1000
+ if (renewed) reprobeNow();
1001
+ });
1002
+ }
800
1003
  }
801
1004
  };
802
1005
 
@@ -820,6 +1023,21 @@ export function createSyncRuntime(
820
1023
  console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
821
1024
  };
822
1025
 
1026
+ /**
1027
+ * Stamp `syncMeta.path` from where this canvas actually is on THIS disk —
1028
+ * never echoed from the wire, so a value some receiver refused is never
1029
+ * laundered onward by being re-sent. Best-effort: a failure never costs
1030
+ * the canvas its sync.
1031
+ */
1032
+ const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
1033
+ try {
1034
+ const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
1035
+ if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
1036
+ } catch {
1037
+ /* best-effort bookkeeping — never costs the canvas its sync */
1038
+ }
1039
+ };
1040
+
823
1041
  /** Post-handshake reconcile — shared by first connect and re-probe. */
824
1042
  const handleSynced = async (
825
1043
  canvas: CanvasDescriptor,
@@ -881,21 +1099,18 @@ export function createSyncRuntime(
881
1099
  }
882
1100
  }
883
1101
  }
884
- // The path travels back OUT. `syncMeta.path` is stamped from where this
885
- // canvas actually is on THIS disk — never echoed from the wire — so the
886
- // next peer to receive this document can place it, and a value some
887
- // receiver refused is never laundered onward by being re-sent.
888
- try {
889
- const rel = path.relative(ctx.paths.designRoot, canvasPaths.html).split(path.sep).join('/');
890
- if (rel && !rel.startsWith('..'))
891
- stampCanvasPath(provider.document, rel, ORIGINS.DISK_PROJECTION);
892
- } catch {
893
- /* best-effort bookkeeping — never costs the canvas its sync */
894
- }
1102
+ // The path travels back OUT — re-stamped post-reconcile as the belt to
1103
+ // connectCanvas's pre-handshake braces (fix 5): this also covers a PULLED
1104
+ // canvas, whose real local path exists only after relocatePulled ran.
1105
+ stampFromLocalFile(provider.document, canvasPaths.html);
895
1106
 
896
1107
  // DDR-102 — honest status: the handshake + reconcile completed.
897
1108
  mon.noteDocState(canvas.slug, 'connected');
898
1109
  mon.noteSyncActivity(canvas.slug);
1110
+ // F1 — a completed handshake IS progress: a renewal actually helped, so
1111
+ // the no-progress cap resets. Without this a healthy link that renews
1112
+ // legitimately every 12 h would burn one cap slot per renewal forever.
1113
+ renewalsSinceProgress = 0;
899
1114
  };
900
1115
 
901
1116
  /**
@@ -1070,6 +1285,17 @@ export function createSyncRuntime(
1070
1285
  document,
1071
1286
  });
1072
1287
  providers.set(canvas.slug, provider);
1288
+ // Fix 5 (sync RCA 2026-08-10): stamp the canvas path BEFORE the
1289
+ // handshake, not only after reconcile. The path derives from this peer's
1290
+ // real local file, so it is known NOW — and the hub's FIRST
1291
+ // onDocumentStored must see it, or it memoises a flat fallback in its
1292
+ // pathIndex and a stub is born. Pulled canvases are the one exception:
1293
+ // their local path is a guess until the document arrives, and a guessed
1294
+ // stamp would be laundered into every other peer (handleSynced stamps
1295
+ // them after relocatePulled instead).
1296
+ if (!pulledSlugs.has(canvas.slug)) {
1297
+ stampFromLocalFile(provider.document, canvasPaths.html);
1298
+ }
1073
1299
  // First-connect setup (agent/projection creation + doc-scoped wiring)
1074
1300
  // MUST run before handleSynced — that function resolves the
1075
1301
  // agent/projection from the maps, and a test stub's onceSynced can
@@ -1311,7 +1537,27 @@ export function createSyncRuntime(
1311
1537
  // place after each handshake, and the markers are the one consumer that
1312
1538
  // read them before that and would otherwise never read them again.
1313
1539
  markUntrusted();
1540
+
1541
+ // DDR-217 (fix 6) — mirror local assets up AFTER the handshakes settle
1542
+ // (they carry the canvases; assets ride behind, never in front). Not
1543
+ // under pairing: a cell's assets are already on the cell. Fire-and-forget
1544
+ // — a miss is retried on the next boot for free.
1545
+ if (!cellPairing) {
1546
+ void pushAssets({
1547
+ designRoot: ctx.paths.designRoot,
1548
+ hubUrl: linkedHub.url,
1549
+ token: () => token,
1550
+ }).catch((err) => {
1551
+ console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
1552
+ });
1553
+ }
1314
1554
  });
1555
+
1556
+ // Arm the pre-expiry renewal from the credential that just booted. Placed
1557
+ // last — the timer needs nothing from boot, and boot needs nothing from it
1558
+ // (a credential that dies mid-boot lands in the invalid-token path, which
1559
+ // triggers renewal on its own).
1560
+ scheduleRenewal();
1315
1561
  }
1316
1562
 
1317
1563
  async function stop(): Promise<void> {
@@ -1367,6 +1613,10 @@ export function createSyncRuntime(
1367
1613
  authClearTimer(reprobeTimer);
1368
1614
  reprobeTimer = null;
1369
1615
  }
1616
+ if (renewTimer !== null) {
1617
+ authClearTimer(renewTimer);
1618
+ renewTimer = null;
1619
+ }
1370
1620
  for (const h of settleTimers) authClearTimer(h);
1371
1621
  settleTimers.clear();
1372
1622
  for (const h of announceTimers.values()) clearTimeout(h);