@1agh/maude 0.54.0 → 0.56.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 (53) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/bin/_smart-frames.mjs +187 -20
  3. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  4. package/apps/studio/bin/_transcribe.mjs +40 -3
  5. package/apps/studio/bin/smoke.sh +7 -1
  6. package/apps/studio/canvas-build-sandbox.ts +101 -9
  7. package/apps/studio/canvas-build-worker.ts +7 -1
  8. package/apps/studio/canvas-build.ts +9 -1
  9. package/apps/studio/client/app.jsx +49 -3
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +505 -45
  12. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  13. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  14. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  15. package/apps/studio/client/styles/4-components.css +72 -0
  16. package/apps/studio/cloud/endpoints.ts +104 -9
  17. package/apps/studio/collab/persistence.ts +29 -2
  18. package/apps/studio/config.schema.json +3 -3
  19. package/apps/studio/context.ts +41 -0
  20. package/apps/studio/dist/client.bundle.js +1545 -1545
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/generation/gemma-models.ts +312 -14
  24. package/apps/studio/generation/prefs.ts +7 -2
  25. package/apps/studio/generation/runtime-probe.ts +50 -0
  26. package/apps/studio/generation/whisper-models.ts +124 -0
  27. package/apps/studio/hmr-broadcast.ts +67 -0
  28. package/apps/studio/http.ts +210 -110
  29. package/apps/studio/input-router.tsx +55 -2
  30. package/apps/studio/server.ts +11 -9
  31. package/apps/studio/sync/autocommit.ts +61 -2
  32. package/apps/studio/sync/cell-pairing.ts +174 -0
  33. package/apps/studio/sync/codec.ts +11 -5
  34. package/apps/studio/sync/index.ts +239 -26
  35. package/apps/studio/sync/limits.ts +49 -0
  36. package/apps/studio/sync/loopback.ts +21 -0
  37. package/apps/studio/sync/projection.ts +47 -12
  38. package/apps/studio/sync/supervisor.ts +178 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +326 -4
  40. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  41. package/apps/studio/test/gemma-models.test.ts +245 -0
  42. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  43. package/apps/studio/test/input-router.test.ts +95 -0
  44. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  45. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  46. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  47. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  48. package/apps/studio/test/whisper-setup.test.ts +97 -0
  49. package/apps/studio/whats-new.json +45 -0
  50. package/apps/studio/ws.ts +9 -1
  51. package/cli/commands/kg.mjs +9 -2
  52. package/package.json +8 -8
  53. package/plugins/design/dependencies.json +21 -3
@@ -25,11 +25,13 @@ import type { Awareness } from 'y-protocols/awareness';
25
25
  import * as Y from 'yjs';
26
26
 
27
27
  import { Y_TYPES } from '../collab/persistence.ts';
28
- import type { Context } from '../context.ts';
28
+ import type { Context, LinkedHub } from '../context.ts';
29
29
  import { createHistory } from '../history.ts';
30
+ import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
30
31
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
31
32
  import { atomicWrite } from './atomic-write.ts';
32
33
  import { createAutoCommit } from './autocommit.ts';
34
+ import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
33
35
  import {
34
36
  type ConnectionMonitor,
35
37
  createConnectionMonitor,
@@ -40,6 +42,7 @@ import { createEchoGuard } from './echo-guard.ts';
40
42
  import { createFsReader, type FsReader } from './fs-mirror.ts';
41
43
  import { getHubToken } from './hubs-config.ts';
42
44
  import { loadJournal, type SyncJournal } from './journal.ts';
45
+ import { isLoopbackHost } from './loopback.ts';
43
46
  import { migrateSeed } from './migrate-seed.ts';
44
47
  import { createDocProjection, type DocProjection } from './projection.ts';
45
48
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
@@ -218,30 +221,60 @@ export function createSyncRuntime(
218
221
  ctx: Context,
219
222
  opts: CreateSyncRuntimeOptions = {}
220
223
  ): SyncRuntime | null {
221
- const linked = ctx.cfg.linkedHub;
222
- if (!linked) return null;
223
- const linkedHub = linked;
224
-
225
- // A CELL'S HISTORY BELONGS TO THE CELL — Cloud Phase 27 D2.
224
+ // A CELL'S HISTORY BELONGS TO THE CELL — Cloud Phase 27 D2 — WITH EXACTLY ONE
225
+ // EXCEPTION, which is desktop↔cloud live pairing (variant C2).
226
226
  //
227
227
  // `.design/config.json` is the TENANT's file, versioned in their repo, and
228
228
  // `linkedHub` is whatever hub their desktop was linked to when they committed
229
229
  // it. Honouring that inside a cell would do two things nobody asked for: dial
230
230
  // OUT from the cell to a third-party hub carrying the project's canvases, and
231
231
  // start a SECOND autocommit over the working tree the hub is already
232
- // committing — the exact duplication this phase exists to delete.
232
+ // committing — the exact duplication that phase exists to delete.
233
+ //
234
+ // Both of those remain refused. What is now permitted is the cell talking to
235
+ // ITSELF: a loopback, commit-disabled, shared-doc provider to its own hub, so
236
+ // the browser's collab doc and the desktop's Hocuspocus doc become ONE doc and
237
+ // presence + edits cross. The conditions live in `cell-pairing.ts` and every
238
+ // one of them is a hard gate — see that file for why each exists.
233
239
  //
234
- // Today this is unreachable by accident: the token lookup below reads
235
- // `~/.config/maude/hubs.json`, which does not exist in a cell (HOME=/tmp), so
236
- // it returns null a few lines further down. An accident is not an invariant,
237
- // and the fix for "it happens to be fine" is to say so out loud.
238
- if (process.env.MAUDE_WORKSPACE_MODE === '1') {
240
+ // Note the ORDER: pairing is resolved from the ENVIRONMENT (which the hub owns
241
+ // and the tenant cannot write) BEFORE `ctx.cfg.linkedHub` is consulted, and it
242
+ // takes only the workspace id from the tenant's config. A cell with pairing on
243
+ // and no `linkedHub` in the tenant's file still pairs; a cell with pairing off
244
+ // and a `linkedHub` pointing anywhere still refuses.
245
+ const workspaceMode = process.env.MAUDE_WORKSPACE_MODE === '1';
246
+ const pairingVerdict = resolveCellPairing();
247
+ const cellPairing: CellPairing | null = pairingVerdict.pairing;
248
+ if (workspaceMode && !cellPairing) {
249
+ if (pairingVerdict.detail) {
250
+ // The operator asked for pairing and we refused — say why, loudly.
251
+ console.warn(`[sync] cell pairing refused — ${pairingVerdict.detail}`);
252
+ }
239
253
  console.warn(
240
- `[sync] ignoring linkedHub ${linkedHub.url} — in a workspace cell the hub owns history and sync. (DDR-209 / Phase 27 D2)`
254
+ `[sync] ignoring linkedHub ${ctx.cfg.linkedHub?.url ?? '(none)'} — in a workspace cell the hub owns history and sync. (DDR-209 / Phase 27 D2)`
241
255
  );
242
256
  return null;
243
257
  }
244
258
 
259
+ // The hub URL the providers dial. Under pairing it is the hub's own loopback
260
+ // address, NEVER the tenant's `linkedHub.url`. `workspaceId` is the one field
261
+ // taken from the tenant's config, because it decides the wire document name
262
+ // and the desktop resolves it the same way — see cell-pairing.ts.
263
+ const linked: LinkedHub | undefined = cellPairing
264
+ ? {
265
+ url: cellPairing.url,
266
+ linkedAt: 0,
267
+ ...(ctx.cfg.linkedHub?.workspaceId ? { workspaceId: ctx.cfg.linkedHub.workspaceId } : {}),
268
+ }
269
+ : ctx.cfg.linkedHub;
270
+ if (!linked) return null;
271
+ const linkedHub = linked;
272
+ if (cellPairing) {
273
+ console.log(
274
+ `[sync] cell pairing ON — loopback shared-doc provider to ${sanitizeForLog(linkedHub.url)}; autocommit disabled (the hub is the sole committer). DDR-209 threaded, not reversed.`
275
+ );
276
+ }
277
+
245
278
  // DDR-054 §2a — CI environment gate. Closes the supply-chain side-door
246
279
  // where a future CI workflow runs `maude design serve` and a PR-controlled
247
280
  // linkedHub.url silently grants a remote actor write access in an
@@ -285,8 +318,17 @@ export function createSyncRuntime(
285
318
  // save: nobody is at a keyboard to commit, so the cell does it. Off entirely
286
319
  // outside workspace mode, where the developer's own git IS the history and
287
320
  // committing under them would be an intrusion (DDR-119).
321
+ //
322
+ // AND OFF UNDER CELL PAIRING, which is the guard DDR-209's core fear asks for.
323
+ // The hub's `afterStoreDocument` already commits every stored document; a
324
+ // second committer inside the studio child would race it over one working
325
+ // tree and one `.git/index`. This is structural rather than conditional on
326
+ // purpose — under pairing the object is never CONSTRUCTED, so there is no
327
+ // later branch that could accidentally reach a commit. `cell-pairing.ts`
328
+ // refuses to pair at all unless MAUDE_SYNC_NO_AUTOCOMMIT says so out loud, so
329
+ // the two halves of this invariant can never disagree.
288
330
  const autoCommit =
289
- process.env.MAUDE_WORKSPACE_MODE === '1'
331
+ workspaceMode && !cellPairing
290
332
  ? createAutoCommit({
291
333
  repoRoot: ctx.paths.repoRoot,
292
334
  run: async (args, { cwd }) => {
@@ -300,7 +342,11 @@ export function createSyncRuntime(
300
342
  })
301
343
  : null;
302
344
 
303
- const resolvedToken = getHubToken(linkedHub.url);
345
+ // Under pairing the credential is the hub's own derived cell token, handed to
346
+ // this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
347
+ // credential store and does not exist in a cell (HOME=/tmp) — which is exactly
348
+ // why the old guard was unreachable by accident rather than by design.
349
+ const resolvedToken = cellPairing ? cellPairing.token : getHubToken(linkedHub.url);
304
350
  if (!resolvedToken) {
305
351
  console.warn(
306
352
  `[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.`
@@ -364,18 +410,33 @@ export function createSyncRuntime(
364
410
  let authWarnTimer: TimerHandle | null = null;
365
411
  let reprobeTimer: TimerHandle | null = null;
366
412
  const settleTimers = new Set<TimerHandle>();
413
+ /** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
414
+ * design-root-relative path so a second write to the same file inside the
415
+ * delay window replaces the pending timer instead of scheduling a second
416
+ * one. Cleared on stop() so a teardown can't fire a reload for a runtime
417
+ * that no longer exists. */
418
+ const announceTimers = new Map<string, ReturnType<typeof setTimeout>>();
367
419
 
368
420
  async function start(): Promise<void> {
369
421
  if (started || stopped) return;
370
422
  started = true;
371
423
 
372
424
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
373
- const canvases = scan.canvases;
425
+ // DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
426
+ // the pinned set must be bounded. See `admitCanvases`.
427
+ const canvases = admitCanvases(scan.canvases, useSharedDoc);
374
428
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
375
429
  // content, so the whole set is untrusted Claude-context. Mark it (writes
376
430
  // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
377
431
  // when the set is empty). Best-effort — never throws into boot.
378
- writeUntrustedMarkers(ctx, canvases, linkedHub.url);
432
+ //
433
+ // NOT under cell pairing. The markers exist for Claude Code reading the
434
+ // checkout, and nothing runs Claude against a cell's tree. `.claudeignore`
435
+ // is a REPO-ROOT file, so writing it here would put a machine-authored file
436
+ // into the tenant's repository, which the hub would then commit and mirror
437
+ // to their GitHub — a change to somebody's repo that nobody asked for. The
438
+ // canvases are no less untrusted; the audience for the marker is absent.
439
+ if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
379
440
  if (canvases.length === 0) {
380
441
  // DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
381
442
  // while syncing nothing (TSX-only projects: discovery admits .html only,
@@ -400,6 +461,9 @@ export function createSyncRuntime(
400
461
  );
401
462
  }
402
463
 
464
+ // DDR-064 pre-cutover A7 — one-time notice, before any doc is attached.
465
+ if (useSharedDoc) noticeSharedDocOnce(linkedHub.url, !!cellPairing);
466
+
403
467
  statusStore =
404
468
  opts.statusStore ??
405
469
  createSyncStatusStore({
@@ -448,6 +512,50 @@ export function createSyncRuntime(
448
512
  reader.notify(rel);
449
513
  });
450
514
 
515
+ /**
516
+ * Tell the rest of the server that a doc→file projection write landed.
517
+ *
518
+ * Only wired under cell pairing. In a container the recursive `fs.watch`
519
+ * misses our atomic tmp+rename writes, so a peer's edit reaches the doc and
520
+ * the disk and then stops: no `fs:any`, no `canvas-hmr`, and the other
521
+ * person's canvas iframe stays on the old render until they reload by hand.
522
+ * That is the same gap `createContainerWriteBridge` closes for API writes —
523
+ * it cannot close this one, because it triggers off `activity:suppress` and
524
+ * the projector never arms it.
525
+ *
526
+ * Delayed by the bridge's margin so a watcher that DOES fire gets there
527
+ * first; the HMR broadcaster coalesces per file within its own debounce, so
528
+ * both arriving is one reload, not two.
529
+ *
530
+ * Keyed by path, mirroring `createContainerWriteBridge`'s own
531
+ * clear-and-replace pattern rather than a flat timer bag — html/css/meta
532
+ * can each flush and re-flush across cold-start `reconcile()` plus the
533
+ * first real edit landing moments later, and two announcements for the
534
+ * SAME file inside the delay window would have been two `fs:any` events,
535
+ * i.e. two reloads for one edit. A per-path replace collapses that back
536
+ * to one, same as the sibling mechanism this is modeled on (not reused
537
+ * directly — that one lives in `ws.ts`/`hmr-broadcast.ts` and arms off
538
+ * `activity:suppress`, a server-boot-scoped bus the sync runtime doesn't
539
+ * otherwise depend on; duplicating the small delay-then-emit shape here
540
+ * keeps this runtime testable standalone, the way every test in
541
+ * `shared-doc-cell-pairing.test.ts` relies on).
542
+ */
543
+ const announceWrite = (abs: string): void => {
544
+ const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
545
+ // Outside the design root there is nothing for the canvas layer to reload.
546
+ if (!rel || rel.startsWith('..')) return;
547
+ const prev = announceTimers.get(rel);
548
+ if (prev) clearTimeout(prev);
549
+ announceTimers.set(
550
+ rel,
551
+ setTimeout(() => {
552
+ announceTimers.delete(rel);
553
+ if (stopped) return;
554
+ ctx.bus.emit('fs:any', rel);
555
+ }, SYNTHETIC_FS_DELAY_MS)
556
+ );
557
+ };
558
+
451
559
  const adoptOnce = opts.adopt ?? !!linkedHub.adopt;
452
560
  let adoptReconciled = 0;
453
561
  const adoptTarget = canvases.length;
@@ -729,6 +837,13 @@ export function createSyncRuntime(
729
837
  paths: canvasPaths,
730
838
  echoGuard,
731
839
  journal: journal ?? undefined,
840
+ // Cell pairing only — see the DocProjectionOptions.onWrote doc.
841
+ // The synthetic event is delayed by the same margin the container
842
+ // write bridge uses, so a watcher that DOES fire wins the race and
843
+ // the HMR broadcaster's per-file coalescing collapses the pair
844
+ // into one `canvas-hmr`. The projector's own echo guard drops the
845
+ // resulting file→doc read, so this cannot loop.
846
+ ...(cellPairing ? { onWrote: announceWrite } : {}),
732
847
  });
733
848
  projection.start();
734
849
  projections.set(canvas.slug, projection);
@@ -924,6 +1039,8 @@ export function createSyncRuntime(
924
1039
  }
925
1040
  for (const h of settleTimers) authClearTimer(h);
926
1041
  settleTimers.clear();
1042
+ for (const h of announceTimers.values()) clearTimeout(h);
1043
+ announceTimers.clear();
927
1044
  rejectedPermanent.clear();
928
1045
  busUnsub?.();
929
1046
  busUnsub = null;
@@ -974,6 +1091,106 @@ export function createSyncRuntime(
974
1091
  };
975
1092
  }
976
1093
 
1094
+ /* ------------------------------------------------ DDR-064 pre-cutover gates */
1095
+
1096
+ /**
1097
+ * Upper bound on shared-doc canvases held live in one process — DDR-064
1098
+ * pre-cutover A6.
1099
+ *
1100
+ * Every shared-doc canvas is a PINNED room: a `Y.Doc` plus its history, kept in
1101
+ * memory for as long as a provider is attached, deliberately immune to the
1102
+ * last-browser-leaves drop. That immunity is what makes the ceiling necessary —
1103
+ * nothing else will ever reclaim them.
1104
+ *
1105
+ * Set well above any real project (the largest in-house one is 83 canvases) so
1106
+ * that in practice this is a runaway guard, not a product limit. Raise it with
1107
+ * `MAUDE_MAX_PINNED_ROOMS` if a real project ever meets it — and if one does,
1108
+ * that is a signal worth reading rather than a number worth bumping.
1109
+ */
1110
+ export const DEFAULT_MAX_PINNED_ROOMS = 500;
1111
+
1112
+ function maxPinnedRooms(): number {
1113
+ const raw = Number.parseInt(process.env.MAUDE_MAX_PINNED_ROOMS ?? '', 10);
1114
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_PINNED_ROOMS;
1115
+ }
1116
+
1117
+ /**
1118
+ * Filter the discovered set down to what may safely be synced.
1119
+ *
1120
+ * **A4 — slug collisions.** `slugFor` flattens `/` to `-`, so `ui/a/b.tsx` and
1121
+ * `ui/a-b.tsx` produce the same slug. Two files on one document is not a
1122
+ * degraded experience, it is silent cross-contamination: each would receive the
1123
+ * other's body as a remote change and write it over itself, forever. Neither
1124
+ * file is more correct than the other, so BOTH are excluded rather than one
1125
+ * being picked — refusing to sync two canvases is recoverable by renaming a
1126
+ * file; overwriting one with the other is not.
1127
+ *
1128
+ * **A6 — pinned-room ceiling.** Under shared-doc, admit at most
1129
+ * `maxPinnedRooms()`. Named loudly, because the ones past the ceiling stop
1130
+ * syncing and silence there would read as "sync is broken" with no cause.
1131
+ *
1132
+ * Exported for the pre-cutover tests.
1133
+ */
1134
+ export function admitCanvases(
1135
+ canvases: readonly CanvasDescriptor[],
1136
+ sharedDoc: boolean
1137
+ ): CanvasDescriptor[] {
1138
+ const bySlug = new Map<string, CanvasDescriptor[]>();
1139
+ for (const c of canvases) {
1140
+ const group = bySlug.get(c.slug);
1141
+ if (group) group.push(c);
1142
+ else bySlug.set(c.slug, [c]);
1143
+ }
1144
+
1145
+ const admitted: CanvasDescriptor[] = [];
1146
+ const collisions: string[] = [];
1147
+ for (const [slug, group] of bySlug) {
1148
+ if (group.length > 1) {
1149
+ collisions.push(`${slug} ← ${group.map((c) => c.html).join(' , ')}`);
1150
+ continue;
1151
+ }
1152
+ admitted.push(group[0] as CanvasDescriptor);
1153
+ }
1154
+ if (collisions.length > 0) {
1155
+ console.error(
1156
+ `[sync] ${collisions.length} slug collision(s) — these canvases are NOT syncing, because two files sharing one document would overwrite each other (DDR-064 A4). Rename one of each pair:\n${collisions
1157
+ .map((c) => ` ${c}`)
1158
+ .join('\n')}`
1159
+ );
1160
+ }
1161
+
1162
+ if (!sharedDoc) return admitted;
1163
+ const cap = maxPinnedRooms();
1164
+ if (admitted.length <= cap) return admitted;
1165
+ const dropped = admitted.length - cap;
1166
+ console.error(
1167
+ `[sync] ${admitted.length} syncable canvases exceeds the shared-doc pinned-room ceiling of ${cap} (DDR-064 A6) — ${dropped} will NOT sync. Raise MAUDE_MAX_PINNED_ROOMS if this project is genuinely this large.`
1168
+ );
1169
+ return admitted.slice(0, cap);
1170
+ }
1171
+
1172
+ /**
1173
+ * DDR-064 pre-cutover A7 — say, once, that a shared document is now crossing
1174
+ * the network.
1175
+ *
1176
+ * Under shared-doc the browser's live editing buffer IS the object that syncs to
1177
+ * the hub. That is a real change in what leaves this machine and when, and the
1178
+ * checklist asks for it to be stated rather than inferred from a release note.
1179
+ *
1180
+ * A cell is the exception, and deliberately so: the operator turned pairing on
1181
+ * per project, the hub is the cell's own loopback, and nothing leaves the
1182
+ * container. Consent was given by configuration, and repeating it at every
1183
+ * canvas boot would train an operator to skip the line that matters.
1184
+ */
1185
+ let sharedDocNoticeShown = false;
1186
+ function noticeSharedDocOnce(url: string, cellPairing: boolean): void {
1187
+ if (cellPairing || sharedDocNoticeShown) return;
1188
+ sharedDocNoticeShown = true;
1189
+ console.warn(
1190
+ `[sync] shared-doc is ON for ${url} — your live editing buffer for each canvas is now the same object that syncs to the hub, not a copy reconciled through disk (DDR-064). Link only hubs you operate or trust.`
1191
+ );
1192
+ }
1193
+
977
1194
  /* ---------------------------------------------------------------- discovery */
978
1195
 
979
1196
  /**
@@ -1409,11 +1626,8 @@ export function checkUrlScheme(url: string): string | null {
1409
1626
  }
1410
1627
  const isPlaintext = proto === 'http:' || proto === 'ws:';
1411
1628
  if (!isPlaintext) return null;
1412
- const host = u.hostname.toLowerCase();
1413
- const isLoopback =
1414
- host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
1415
- if (!isLoopback) {
1416
- return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${host} or change the host to localhost.`;
1629
+ if (!isLoopbackHost(u.hostname)) {
1630
+ return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${u.hostname.toLowerCase()} or change the host to localhost.`;
1417
1631
  }
1418
1632
  return null;
1419
1633
  }
@@ -1422,14 +1636,13 @@ export function checkUrlScheme(url: string): string | null {
1422
1636
  * DDR-072 — true when the hub URL points at a loopback host (localhost,
1423
1637
  * 127.0.0.1, ::1). Used to suppress the `syncTsx` boot banner for local dev
1424
1638
  * hubs (no remote exfil concern). Unparseable URL → treated as non-loopback
1425
- * (fail loud / show the banner). Mirrors checkUrlScheme's loopback host set.
1639
+ * (fail loud / show the banner). Mirrors checkUrlScheme's loopback host set —
1640
+ * literally, both call `isLoopbackHost` (`sync/loopback.ts`).
1426
1641
  */
1427
1642
  export function isLoopbackHubUrl(url: string): boolean {
1428
- let host: string;
1429
1643
  try {
1430
- host = new URL(url).hostname.toLowerCase();
1644
+ return isLoopbackHost(new URL(url).hostname);
1431
1645
  } catch {
1432
1646
  return false;
1433
1647
  }
1434
- return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
1435
1648
  }
@@ -0,0 +1,49 @@
1
+ // Per-type byte ceilings for everything that crosses the sync boundary —
2
+ // DDR-054 §2d.
3
+ //
4
+ // A LEAF MODULE ON PURPOSE. These used to live in `sync/codec.ts`, which imports
5
+ // `Y_TYPES` from `collab/persistence.ts`; the moment persistence needed a
6
+ // ceiling of its own (the doc→disk comments/annotations lane, DDR-064's
7
+ // pre-cutover checklist) that became an import cycle whose only symptom would
8
+ // have been a `const` read in its temporal dead zone — a crash whose stack
9
+ // points at neither file. Numbers depend on nothing, so they belong where
10
+ // nothing has to depend back.
11
+ //
12
+ // `codec.ts` re-exports every name here, so existing importers are unaffected.
13
+
14
+ /** Canvas body (`.html` / opted-in `.tsx`). */
15
+ export const MAX_HTML_BYTES = 4 * 1024 * 1024;
16
+ /** `_comments/<slug>.json`, serialized. */
17
+ export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
18
+ /** `<slug>.annotations.svg`. */
19
+ export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
20
+ /** The shared subset of a canvas `.meta.json`. */
21
+ export const MAX_META_BYTES = 1 * 1024 * 1024;
22
+ /** The canvas's sibling stylesheet. */
23
+ export const MAX_CSS_BYTES = 4 * 1024 * 1024;
24
+
25
+ /**
26
+ * Refuse a doc→file write once it exceeds a byte ceiling — the shared
27
+ * enforcement point for every `MAX_*_BYTES` above, in both directions:
28
+ * `sync/projection.ts` (html/css/meta) and `collab/persistence.ts`
29
+ * (comments/annotations) each guard their own doc→file lane with this same
30
+ * check-and-warn shape; consolidated here rather than kept as two copies
31
+ * that would need editing in lockstep for any future change to the guard
32
+ * (DDR-054 §2d).
33
+ *
34
+ * `label` is a log-line prefix (`projection/<slug>` / `collab/<slug>`) —
35
+ * left to the caller rather than hardcoded, so each subsystem's log lines
36
+ * stay identifiable as to which one refused.
37
+ */
38
+ export function withinByteCap(
39
+ label: string,
40
+ what: string,
41
+ byteLength: number,
42
+ max: number
43
+ ): boolean {
44
+ if (byteLength <= max) return true;
45
+ console.warn(
46
+ `[${label}] refusing doc→file write of ${what} — ${byteLength} bytes > ${max} (hub-pushed oversize, DDR-054 §2d).`
47
+ );
48
+ return false;
49
+ }
@@ -0,0 +1,21 @@
1
+ // The loopback host set — a leaf module for the same reason `limits.ts` is
2
+ // one: this predicate used to be re-typed in three places (`checkUrlScheme`
3
+ // and `isLoopbackHubUrl` in `sync/index.ts`, plus `cell-pairing.ts`'s own
4
+ // copy), and `cell-pairing.ts` even said out loud that "three places asking
5
+ // is this loopback and disagreeing is how a guard becomes decorative"
6
+ // without fixing it. Depends on nothing, so it belongs where nothing has to
7
+ // depend back — importable from both `index.ts` and `cell-pairing.ts` with
8
+ // neither reaching into the other.
9
+ //
10
+ // NOT shared with `apps/hub/src/studio-child.mjs`'s own copy of this exact
11
+ // set — that one is a different app (Node, not Bun; its own package), and the
12
+ // duplication there is the deliberate kind: the studio refuses a non-loopback
13
+ // URL on the receiving end, the hub refuses to emit one, and the point of
14
+ // having two independent checks is that editing one without the other still
15
+ // leaves the other standing (see that file's own comment).
16
+
17
+ /** True when `host` (already lower-cased or not) is a loopback address. */
18
+ export function isLoopbackHost(host: string): boolean {
19
+ const h = host.toLowerCase();
20
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
21
+ }
@@ -37,15 +37,13 @@ import {
37
37
  applyMetaToDoc,
38
38
  cssFromDoc,
39
39
  htmlFromDoc,
40
- MAX_CSS_BYTES,
41
- MAX_HTML_BYTES,
42
- MAX_META_BYTES,
43
40
  mergeSharedMetaIntoLocal,
44
41
  metaFromDoc,
45
42
  stampBodyEdit,
46
43
  } from './codec.ts';
47
44
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
48
45
  import type { SyncJournal } from './journal.ts';
46
+ import { MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES, withinByteCap } from './limits.ts';
49
47
  import { ORIGINS } from './origins.ts';
50
48
 
51
49
  export const PROJECT_FLUSH_MS = 800;
@@ -76,6 +74,20 @@ export interface DocProjectionOptions {
76
74
  echoGuard?: EchoGuard;
77
75
  /** Injected for tests — defaults to atomicWrite. */
78
76
  writer?: (path: string, bytes: string | Uint8Array) => void;
77
+ /**
78
+ * Called with the absolute path after a doc→file write LANDS.
79
+ *
80
+ * Exists for one environment: a cell, where the container's recursive
81
+ * `fs.watch` does not see our atomic tmp+rename writes. Locally the watcher
82
+ * fires and the runtime leaves this unset — the same "the watcher owes us this
83
+ * event" reasoning (and the same workspace-mode gate) as
84
+ * `createContainerWriteBridge`, which covers the API write path and cannot
85
+ * cover this one because the projector never arms `activity:suppress`.
86
+ *
87
+ * Without it a peer's edit reaches the doc, reaches disk, and the browser's
88
+ * canvas iframe still shows the old render until somebody reloads by hand.
89
+ */
90
+ onWrote?: (absPath: string) => void;
79
91
  /** Override the 800 ms debounce. Tests use 0 to flush on the next microtask. */
80
92
  flushMs?: number;
81
93
  /** Circuit-breaker threshold (consecutive parse failures per path). */
@@ -153,18 +165,41 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
153
165
  opts.echoGuard?.record(path, hashBytes(value));
154
166
  }
155
167
 
168
+ /**
169
+ * Write + announce. Every doc→file write goes through here rather than calling
170
+ * `writer` directly, so a future fourth projected type cannot silently skip
171
+ * the announcement — the bug class this whole hook exists to close.
172
+ *
173
+ * NO-OPS WHEN THE FILE ALREADY HOLDS THESE BYTES. The `last*` guards above
174
+ * compare against what THIS projector wrote, which is null on the first pass —
175
+ * so the cold-start `reconcile()` re-wrote every canvas with content identical
176
+ * to what was already there. Harmless while nobody was listening; once the
177
+ * write announces itself (below) it became a spurious reload of every open
178
+ * canvas at boot. A write that changes nothing should cost nothing, including
179
+ * downstream.
180
+ *
181
+ * `onWrote` is best-effort: it feeds a reload, and a reload that failed to
182
+ * fire must never cost the write that already succeeded.
183
+ */
184
+ function writeAndAnnounce(path: string, value: string): void {
185
+ if (readLocal(path) === value) return;
186
+ writer(path, value);
187
+ try {
188
+ opts.onWrote?.(path);
189
+ } catch (err) {
190
+ console.error(`[projection/${slug}] onWrote(${path}) failed:`, err);
191
+ }
192
+ }
193
+
156
194
  // Security re-audit (Phase D, finding A2 / DDR-054 §2d): the codec's
157
195
  // MAX_*_BYTES caps guard the file→doc *import* lane, but hub-pushed content
158
196
  // arrives as raw Yjs updates through the provider (NOT via applyXToDoc), so it
159
197
  // bypasses those caps. This doc→file lane is the consumer's guard on that
160
198
  // direction — refuse to materialize an oversized hub-pushed body to disk
161
- // (disk-fill DoS). Returns true when the write is allowed.
199
+ // (disk-fill DoS). Returns true when the write is allowed. Shared with
200
+ // `collab/persistence.ts`'s equivalent guard via `limits.ts`.
162
201
  function withinCap(path: string, value: string, max: number): boolean {
163
- if (Buffer.byteLength(value, 'utf8') <= max) return true;
164
- console.warn(
165
- `[projection/${slug}] refusing doc→file write of ${path} > ${max} bytes (hub-pushed oversize). DDR-054 §2d.`
166
- );
167
- return false;
202
+ return withinByteCap(`projection/${slug}`, path, Buffer.byteLength(value, 'utf8'), max);
168
203
  }
169
204
 
170
205
  // ----- doc → file (html / css / meta only; room owns comments/annotations)
@@ -180,7 +215,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
180
215
  }
181
216
  if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
182
217
  recordEcho(paths.html, next);
183
- writer(paths.html, next);
218
+ writeAndAnnounce(paths.html, next);
184
219
  lastHtml = next;
185
220
  opts.journal?.record(slug, { bodyHash: hashBytes(next) }); // DDR-102 checkpoint
186
221
  }
@@ -193,7 +228,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
193
228
  if (next === null) return; // doc carries no css yet — nothing to write
194
229
  if (!withinCap(paths.css, next, MAX_CSS_BYTES)) return;
195
230
  recordEcho(paths.css, next);
196
- writer(paths.css, next);
231
+ writeAndAnnounce(paths.css, next);
197
232
  opts.journal?.record(slug, { cssHash: hashBytes(next) }); // DDR-102 checkpoint
198
233
  }
199
234
 
@@ -208,7 +243,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
208
243
  if (merged === null || merged === local) return; // unparseable / disk matches
209
244
  if (!withinCap(paths.meta, merged, MAX_META_BYTES)) return;
210
245
  recordEcho(paths.meta, merged);
211
- writer(paths.meta, merged);
246
+ writeAndAnnounce(paths.meta, merged);
212
247
  }
213
248
 
214
249
  async function flush(): Promise<void> {