Skip to content

Latest commit

 

History

History
521 lines (431 loc) · 27.6 KB

File metadata and controls

521 lines (431 loc) · 27.6 KB

Bento collaboration — CRDT design

Design document, July 2026. Status: implemented (M0–M3 shipped in v0.8.0) — slides/src/sync/ (engine, session, online transport), server/sync-worker/ (relay), scripts/test-sync.ts (convergence rig). Notable deviations from the proposal, all deliberate: share links became the file itself — and since v0.8.1 credentials are minted at CREATION (random room ids, never docId), dormant behind collab.on, so "send the file first, share later" works and "Rotate keys" is revocation; a hosted /s/ joiner page can wrap this later. Offline forks merge TWO-WAY: saves stamp the CRDT state into doc.collab.sync, a rejoining copy restores it, replay dedups by version vector, and a state snapshot frame carries the fork's edits to peers. "Duplicate as new deck" is the explicit identity fork (new docId + fresh credentials). Actor ids are fresh per session instance (a reloaded tab is a new replica; the engine skips "own" ops on apply), the relay replay bookmark is memory-only (valid only alongside the CRDT state it was earned with), and M3 (text RGA) shipped with the engine rather than after it. Companion to architecture.md §7 (updates) and the local-first principles in README.md.

Goals and non-goals

Goals

  1. Multiple people edit the same deck live; everyone converges to the same document without a server ever being the authority.
  2. Offline edits merge when connectivity returns — no locks, no "someone else is editing" dialogs.
  3. The file stays the source of truth at rest: any saved copy opens standalone forever, with zero knowledge of collaboration. Sync is a layer beside the file, never a replacement for it.
  4. Privacy by architecture: the relay can be blind (E2EE), and nothing about a document leaks to infrastructure a user didn't opt into.
  5. Fits the Bento budget: small enough to live inside every file (single-file invariant — nothing is fetched at runtime).

Non-goals (for now)

  • Google-Docs-grade concurrent editing inside one text box (character-level merge). Deck collaboration is overwhelmingly element-level; we start with last-writer-wins per text block and leave a clean upgrade path.
  • Server-side rendering, web viewers, or hosted documents. The room is a conduit, not a home.

The core decision: which CRDT

Two credible paths:

A. Yjs — the mature ecosystem answer. Real text CRDT, awareness protocol, providers for websocket/IndexedDB, a decade of hardening. Costs: ~30–40KB in the compressed shell; the whole store must be mirrored into Y types (every mutation site rewritten or bridged); undo moves to Y.UndoManager; the file gains a second, binary representation of the document (Yjs update log) that must never diverge from the JSON — and that binary blob is opaque to the AI tooling contract we just shipped.

B. bento-sync (in-house, op-based, per-property LWW) — a CRDT shaped exactly like our document. The model is unusually CRDT-friendly: every slide and element already has a globally unique id; properties are scalars/small JSON; order is the only sequence problem, and fractional indexing solves it without a sequence CRDT. Ops are plain JSON (readable by the same AI tooling that reads the document). Estimated ~6–8KB. Costs: we own correctness — a real responsibility — and text merges are whole-value LWW until/unless we upgrade.

Recommendation: B, with A as the named fallback if property-based testing shatters confidence. Reasons, in order: (1) the integration point below makes B nearly free to adopt while A demands a store rewrite; (2) JSON ops preserve the "everything legible" contract that now defines the format; (3) it matches the project's pattern — we replaced GSAP and ECharts with engines shaped exactly to our needs, at ~2% of the size, and the same economics apply; (4) LWW-per-property is the correct semantic for slides — two people dragging the same box should resolve by recency, not merge into a diagonal.

The convergence math for B is standard and small: a state where every register merge is by max((lamport, actorId)) is commutative, associative and idempotent — a textbook state-based CRDT. The risk isn't the algorithm; it's implementation discipline, which is what the M0 test rig is for.

The document model as a CRDT

Identity. Each browser session gets a persistent random actorId. Each op carries a Lamport timestamp (lamport), bumped on every local op and raised to max(local, seen)+1 on every received op. (lamport, actorId) is the total order used for all conflict resolution. The document's docId (already shipped) is the room key.

Node identity (v2, July 2026). Slides key by their id, but element nodes key by the composite slideId U+001F elementId (elKey in crdt.ts). The bare element id is deliberately NOT unique across slides — same-id-on-many-slides is the format's core morph idiom (data-flip-id pairing; the starter deck's sd-tile-* cast rides through every slide) — so v1's bare-id keying collapsed those copies into one node and replicas diverged (one dropped a copy, the other kept it). Under composite keys each per-slide copy is its own CRDT node; the document format is untouched (payloads and materialized JSON carry bare ids; morph pairing happens at render time and never sees CRDT keys). Consequences: an element node is pinned to one slide for life, so a cross-slide move diffs as del(oldKey)+ins(newKey) — concurrent moves of the same element to two different slides duplicate it (both users keep their copy), and concurrent moves onto the SAME slide collapse to one LWW winner (bare ids stay unique within a slide — the editor invariant). The wire and saved sync state are versioned (SYNC_V = 2: pv on frames, v in SyncStateJSON); pre-v2 frames, room snapshots, and doc.collab.sync blobs are discarded on sight — an old-scheme file simply rejoins as a never-synced adopt.

Ops (all JSON, ~one line each):

{ "a": "actor", "l": 41, "op": "set",
  "sl": "slide-id", "el": "el-id", "k": "x", "v": 640 }

{ "a": "actor", "l": 42, "op": "ins",
  "kind": "element", "sl": "slide-id", "id": "el-new",
  "ord": "a0V", "node": { /* full element JSON */ } }

{ "a": "actor", "l": 43, "op": "del", "kind": "element", "id": "el-old" }

{ "a": "actor", "l": 44, "op": "ord", "id": "slide-2", "ord": "a1" }
  • set — per-(nodeId, key) LWW register. Covers every property edit: geometry, style, fx, text html (whole-value), notes, chart options, slide props, doc-level props (title, theme).
  • ins — create with the full node payload and a fractional-index ord.
  • del — tombstone. Delete beats concurrent edits (standard, predictable). Tombstones live in the op log, never in the document JSON.
  • ord — LWW on a node's fractional index (slide order, element z-order). Since node identity v2, an element's parent slide is part of its key, so ord never re-parents — cross-slide moves are del+ins.

Order. Slides and elements carry fractional-index keys (short base-62 strings, jittered to avoid collisions — the Figma/Linear technique). The JSON arrays in the saved document remain plain arrays sorted by these keys; the keys themselves live in the sync layer's metadata, not the format. A fresh (non-collaborative) document has no keys until sync first activates.

Text. element.html is a set register in v1. The upgrade path when character-merge matters: per-text-element sequence CRDT behind the same op envelope (op: "txt"), shippable later without changing anything else.

Integration: the differ is the bridge

We do not instrument a hundred mutation sites. Every store mutation already flows through store.commit / touch, and checkpoint() already snapshots the document for undo. The sync layer hooks that same seam:

commit(mutate) → snapshot before/after → structural diff by id
              → emit ops (set/ins/del/ord) → broadcast
remote op     → apply directly to doc (surgical, by id)
              → store.emit(...) → UI refreshes (existing listeners)

The differ is mechanical: compare slides by id (skip identical by reference or JSON-equality per slide), then elements by id, then properties shallowly. Deck-scale documents diff in well under a millisecond for typical edits. This means collaboration lands with zero rewrites of editor code — panels, canvas, morphs, comments all keep mutating the plain JSON they already know.

Undo under collaboration becomes per-actor inverse ops (undoing your edits, not your collaborator's) — the differ produces inverses for free (swap before/after). Single-user undo behavior is unchanged.

Persistence: the file is a snapshot, the room is a log

The saved .bento.html format changes by one additive optional field:

"collab": {
  "room": "wss://sync.bento.page/d/<docId>",
  "vv": { "actorA": 812, "actorB": 344 }   // version vector at save time
}

Shipped: the version-vector vv sketched here was superseded by stamping the whole CRDT state under collab.sync (SyncStateJSON) — that, not a bare vv, is what a rejoining offline fork restores to merge two-way. The live collab shape also carries key/on/writerPub/writerPriv/ role (see BentoDoc['collab'] in model.ts and the Signed-writes section below). The single-additive-field spirit holds: it's still one optional collab object the format never requires to open.

  • The document JSON in the file is always the compacted merged state — a file never needs the op log to open. Every invariant we shipped (plaintext block, splice contract, AI round-trip, old updaters) is untouched.
  • The op log lives in the room (server, encrypted) and in IndexedDB (local cache/offline queue). On open, the app catches up from vv.
  • A file copied to a colleague is a fork — opening it offline just works; opening it online rejoins the room and converges. This is the local-first promise kept literally.

Topology and transport

editor ⇄ BroadcastChannel (same machine, free, no server)   [M1]
editor ⇄ wss://sync.bento.page  — Cloudflare Worker + one   [M2]
          Durable Object per docId room:
          · authenticates the room token
          · fans out opaque frames to members
          · persists encrypted ops + periodic encrypted snapshot
          · assigns nothing semantic (CRDT needs no server order)

E2EE. Share links carry the room key in the URL fragment: https://bento.page/s/<docId>#k=<base64url-key> — fragments never reach any server. Frames are AES-GCM under a key derived from k (WebCrypto, zero bytes of crypto code shipped). The relay stores ciphertext; a subpoena gets noise. Possession of the link is the capability — appropriate for launch; accounts/ACLs can wrap this later without changing the protocol.

Presence (ephemeral, never persisted): name (localStorage bento-author), a color derived from actorId, current slide, selection ids, cursor. Rendered as colored outlines + avatars chips; disappears on disconnect. Same encrypted channel, p: frames.

Offline. Ops queue in IndexedDB keyed by docId; reconnect exchanges version vectors both ways. Long-offline forks converge like any other pair of replicas — that's the whole point of the CRDT.

Server sketch (M2)

One Worker + Durable Object class, ~300 lines:

  • GET /d/<docId> upgrade → WebSocket; token = hash(roomKey) proves link possession without revealing the key.
  • DO state: member sockets; append-only encrypted op log (DO storage); encrypted snapshot every N ops (clients produce it — server can't); idle TTL ~30 days (the file is the durable artifact; expiry loses nothing but convenience).
  • Rate limits per IP + per room; ops are size-capped (images travel as assets set-ops — chunked, or v1 simply discourages giant paste-ins mid-session).

Failure and abuse honesty

  • LWW surprises: two people restyle the same element simultaneously → one wins silently. Mitigated by presence (you see someone on your slide) and by the property granularity (their color change + your move both survive; only same-property races resolve by recency).
  • Tombstone permanence: delete wins over concurrent edits; un-delete is a fresh insert (undo produces exactly that).
  • Cross-version rooms: clients stamp ops with the app version; unknown op types are buffered, and a room can advertise a minimum version — the update channel (already automatic at launch) is the remedy.
  • Clock abuse: Lamport clocks are logical; wall-clock never decides anything.

Phasing

Phase Deliverable Depends on
M0 src/sync/ — differ, op codec, merge engine, fractional index; property-based convergence tests (random op interleavings across N simulated actors must converge byte-identical) nothing
M1 Same-machine live collab over BroadcastChannel (two windows/tabs); presence; per-actor undo. Ships user value with zero infrastructure and validates the whole pipeline M0
M2 sync.bento.page Worker + DO relay; E2EE; share links (/s/<docId>#k=…); Share UI in the topbar; offline queue M1 + DNS (exists)
M3 Text sequence CRDT for element.html; in-text cursors proven demand

M0+M1 are pure client work, shippable through the existing release channel. M2 is the first Bento server code ever — small, blind, and replaceable.

Identity (v0.8.2 posture and the enterprise path)

Identity is deliberately self-managed and local: a display name in the Collaborate popover (localStorage bento-author, shared with comments), shown to peers via presence — zero friction, zero accounts, consistent with E2EE (the relay couldn't verify identities anyway; it never sees them). Presence names are therefore claims, not proofs — fine for teams that share room keys deliberately. The future enterprise path, when demand appears: an optional identity layer where ops/presence are SIGNED by per-user keypairs and an org roster (SSO-provisioned) maps public keys to verified names — layered ON TOP of the existing protocol (a signature field in frames), relay still blind, local-first files unchanged. Nothing shipped today constrains that design.

Signed writes — enforced read-only (v0.9.18)

The relay's only gate today is ?tok = SHA-256(key) — possession of the room key proves the right to read and write. That means "read-only" could only ever be a client-side courtesy: a viewer must hold the key to decrypt, and holding the key means being able to encrypt and send too. Signed writes split that single capability into two, so read-only becomes something the relay ENFORCES — while the relay stays blind to content.

The two capabilities

  • Content key (collab.key, symmetric AES-GCM, unchanged): the READ capability. Every copy — writers and viewers — carries it. ?tok still proves possession so the relay won't even fan ciphertext to a stranger.
  • Writer keypair (ECDSA P-256, new): the WRITE capability. Minted once per room at creation. collab.writerPub (raw SPKI, base64url) travels in EVERY copy; collab.writerPriv (PKCS#8/JWK) travels ONLY in writer copies. A read-only copy is a writer copy with writerPriv stripped.

Binding the pubkey to the room (trustless, no TOFU)

The relay must know a room's writer pubkey without trusting the client that presents it — a viewer legitimately holds the room id, so first-writer TOFU would let a viewer pin their own key and become the writer. So the room id commits to the pubkey:

room name = "w" + base64url(SHA-256(writerPubRaw))      (new, signed)
room name = "r" + base64url(random)                     (legacy, v0.8–0.9.17)

The leading char is the scheme selector. On connect a client passes ?w=<writerPubRaw>; the relay checks "w"+b64url(sha256(w)) === roomName before pinning it. A viewer passes the same (correct) pubkey — it's public and in the file — but cannot forge signatures without the private half.

What is signed, and how

Only the frames that mutate what a joiner sees are signed: op batches ({p:1}) and snapshots ({snap:1}). Ephemeral frames (hello / need / presence / bye) pass unsigned. The signature is ECDSA-P256/SHA-256 over the UTF-8 of `${i}.${d}` (iv + ciphertext, both base64url) and rides in a new envelope field g. Encrypt-then-sign: the signature covers ciphertext, so the relay verifies authorship without decrypting — blindness preserved. AES-GCM already gives key-holders content integrity; g adds authorization.

Relay enforcement (w-rooms only)

on connect (w-room):  require ?w, verify "w"+b64url(sha256(w)) == name,
                      store `w` in DO storage (like `tok`; commitment-safe)
on {p:1} / {snap:1}:  require valid `g` over `${i}.${d}` vs stored `w`,
                      else DROP the frame (no persist, no fan-out)
on ephemeral frames:  unchanged
on r-rooms:           unchanged (permissive) — legacy files keep working

Rollout & backward-compat (the reason to ship now)

  • Client first, relay second, no breakage window. New clients mint w rooms and sign ops. The current relay ignores the extra w/g fields and the w room name — so signed-scheme files work on the old relay (permissively) until the new relay deploys and enforcement lights up.
  • Legacy r rooms stay permissive forever — existing shared files never break. rotateKeys() mints a fresh (now w) room, i.e. rotation upgrades a deck to enforced.
  • Shipping the protocol now, before the read-only UX, is the point: every room created from here on is signed, so when "read-only viewer" and the "presentation package" rename land, they need NO protocol change and break NO existing session — read-only is just "save without writerPriv."

Threat model / limits

  • Enforcement is at the relay. Same-machine tab sync (BroadcastChannel) can't be gated there — but same machine = same trust domain, so read-only is cooperative locally (editing UI disabled), enforced over the network.
  • A malicious relay could still withhold/reorder frames or serve a stale snapshot; it cannot forge writer ops (no private key) nor read content.
  • Client-side verification is REQUIRED, not optional hardening. An earlier draft of this document called it "not required for the read-only guarantee"; that sentence was the assumption behind a real hole. Relay enforcement only covers what the relay persists. Every reader holds the room key, so a read-only copy can encrypt a well-formed op batch and send it — and a blind relay, which cannot tell the ciphertext of an op batch from the ciphertext of a presence beat, fans it out. Live peers would apply it. So a client in a signed room must accept only frames the relay vouched for (see "Phase 1 wire format"), and drop the rest. Legacy r rooms have no signatures at all and stay on the pre-signing trust model — gating them would drop every frame.
  • writerPriv in a writer file is only as protected as the file. Anyone with a writer copy can write — expected: the writer file is the write cap.

Offline mode

A viewer-side hard switch (localStorage bento-offline, toggle in About) that blocks every network touch: update checks and the relay transport. Same-machine tab sync (BroadcastChannel) is not networking and stays on. Documents keep their (dormant) credentials — offline mode is a property of the VIEWER, not the file — so nothing breaks when the switch flips either way. This is the "no cloud, provably" story for local-first purists.

What we are explicitly protecting

The five invariants that already define the format survive untouched: plaintext #bento-doc, the splice contract, ids-as-identity, pure-data documents (ops are data too — no code ever travels the sync channel), and "any saved file opens alone, forever."


Fine-grained access control (Phase 1 SHIPPED in v1.0.3) and the SSO path

Phase 1 below is IMPLEMENTED and live (client + relay). Phases 2–3 remain roadmap. Goal: per-person capability without giving up the three core promises — the relay stays blind, there are no accounts, and the file remains the read capability.

Phase 1 wire format (as shipped)

  • Room id: w + b64url(SHA-256(ownerPubRaw)) — commits to the OWNER key.
  • Writer connect params: ?w=<signingPub> plus, for members, the chain &o=<ownerPub>&ivp=<invitePub>&ivr=writer&ive=<expiryMs|0>&ivs=<ownerSigOverInvite>&dg=<inviteSigOverMemberPub>. Signature texts: invite = inv.${ivp}.${ivr}.${ive}, delegation = dlg.${memberPub}, revocation = rev.${pub}.
  • The relay pins the verified key PER SOCKET and checks every persisted frame's g against it. Direct (hash-matching) keys cover the owner and legacy 1.0.2 shared-writer rooms unchanged.
  • The relay STAMPS what it verified, so a client can tell a checked frame from one that merely passed through: a persisted frame comes back carrying its sequence number, and a signed frame it fanned out comes back with its signature echoed. In a signed room the client accepts only stamped frames — that is the client half of the read-only guarantee described under Threat model / limits, and it is why the stamp exists at all. (The relay is being changed concurrently; treat the stamp fields in sync/online.ts as the authority for their exact names.)
  • Blob write tickets: a writer that understands them announces bt=1 on connect, and the relay — which only mints a ticket once it has seen such a writer — hands one back on the room's ready message (wt), reissuing it when a member is revoked. The client uses it to authorize blob uploads, so blob writes are gated by the same per-socket verification as ops instead of by mere possession of the room key.
  • Revocation: owner sends {ctl:'revoke', p, o, g}; the relay stores p in a rev set, closes matching sockets, fans out {ctl:'revoked', p} (clients seeing their own key stand down permanently), and refuses future connects/ writes for revoked member OR invite keys.
  • Member device keys live in localStorage (bento-member-<docId>), never in the file. Presence carries pub + derived role → key-bound names in the People panel; the owner gets per-member Remove.
  • The public guestbook runs this scheme: an owner-signed PUBLIC invite in the served deck (anyone writes, individually keyed), owner deck held privately for moderation; daemon auto-roll disabled (a re-mint would orphan the held owner file).
  • Known Phase-1 gaps (protocol-ready, no UI): expiring invites and invite-LINEAGE revocation (today: remove per-device, or Reset access).

Today's model (v1.0.2) in one line

The FILE is the capability: one symmetric read key (everyone in the room) and ONE SHARED writer keypair (every writer copy carries the same writerPriv). Two tiers, doc-wide; revocation only by rotating the whole room.

Phase 1 — per-person identity (the foundation)

Replace the shared writer key with one keypair per participant; the room owner becomes the root of trust.

  • Owner: the deck creator's device mints a personal ECDSA P-256 keypair at creation; the room id commits to the OWNER's pubkey (same w+hash scheme).
  • Certificates: the owner signs small statements — {memberPub, role, expiry} — "this key may write / comment". Certs are data, not identity: no names, no PII in the clear.
  • Invites — delegation chain, not secrets: sharing a copy mints an INVITE KEYPAIR; the owner signs {invPub, role, expiry} and the copy carries the invite's PRIVATE half + that signature. First open: the joiner's device mints its own keypair and signs it with the invite key — the chain owner → invite → member is pure signatures, verifiable by the relay while the owner is OFFLINE. (A secret-redemption scheme would hand the secret to the relay, letting it certify its own key — the delegation chain never exposes private material, so the blind relay still cannot forge writes.) The file stays the invitation; revoking the invite key cuts off every copy descended from it, revoking a member key cuts off one device.
  • Relay change: verify each mutating frame's signature against ANY currently-certified key (cert chain: owner key → member cert → frame sig) instead of the single pinned writer key. Still blind: it sees pubkeys, role bits, expiries, ciphertext — never names.
  • Revocation: the owner publishes a signed revocation for ONE key; that person loses write in seconds. Nobody else re-keys, no files re-sent. "Rotate keys" remains the nuclear option.
  • Attribution: ops are author-signed → presence names and history become key-bound proofs, closing the "names are claims" limitation.

UX (People panel)

Live popover → People panel: members listed with key-bound name + role; the owner gets a role dropdown and a Remove button per member. Joining feels identical to today (open file → you're in).

Phase 2 — roles and scope (client-enforced)

The relay cannot read ops (ciphertext), so fine enforcement is split:

  • Relay keeps enforcing the coarse write/no-write boundary at the edge.
  • Cert claims carry role (commenter) and optional scope (slides: [...]). Every replica verifies on APPLY: an op from a commenter key touching anything but Slide.comments, or outside its slide scope, is deterministically dropped by all peers. Convergence holds because the rule is a pure function of (signed author, op target). Weaker than edge enforcement, still cryptographic — the honest price of a blind relay; say so in security.md.
  • Soft locks first: advisory per-slide/element locks via presence ("Ana is editing slide 4") — no security claim, most of the practical value.
  • Suggest-mode (track changes) is a model feature (pending-change objects), not crypto.

Phase 3 — SSO / enterprise (org-anchored trust)

Same protocol; only WHO SIGNS CERTS changes.

  • Org gateway: a small separate Worker (self-hostable by the enterprise — the only identity-touching component). User signs in via IdP (OIDC/SAML); gateway verifies the token and issues a SPLIT certificate:
    • public envelope (relay-visible): {pubkey, role, expiry, orgSig} — no PII;
    • encrypted payload (room-key, peers only): {name, email, groups} → verified identities in the People panel.
  • Org rooms: the doc's collab block declares a trust anchor — "certs signed by org key O are writers" — so any employee joins without per-person invites.
  • Offboarding follows the directory: certs are short-lived and renew against the IdP; leave the company → renewal fails → write access dies.
  • Audit: author-signed, identity-bound ops → a decryptable, verifiable who-changed-what log the relay never holds.
  • Read remains possession-based by default (file = read capability, same trust as an emailed attachment). Full SSO-gated READ = per-member key wrapping + rotation on offboard — explicit org-policy opt-in, LAST, because it trades away "the file opens forever" for those docs.

Compatibility hooks to ship EARLY (cheap, non-breaking)

  1. Version-tag the collab block (collab.v) so v-next rooms coexist with legacy w/r rooms (the w/r precedent proves the pattern).
  2. Keep the relay's signature check table-driven (pinned-key = a one-entry cert table) so the cert chain is a lookup swap, not a rewrite.
  3. Reserve field names now: collab.owner, collab.certs, collab.invite, cert.{pub,role,scope,exp,sig}, collab.trust (org anchor).

Sequencing

per-person keys + roster + revocation → soft locks → commenter role → per-cell table merges + per-actor undo (engine, orthogonal) → hard scope → org gateway/SSO → key-wrapped read. Sub-document sharing (readers who can only SEE some slides) needs per-section key hierarchies — parked for the Docs/Sheets era.