@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.
- package/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +49 -3
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +505 -45
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/cloud/endpoints.ts +104 -9
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1545 -1545
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +210 -110
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +11 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/index.ts +239 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/cloud-endpoints.test.ts +326 -4
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- 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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// and
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
writeAndAnnounce(paths.meta, merged);
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
async function flush(): Promise<void> {
|