Back to work

Case study

SyncScribe

A real-time collaborative editor with owner, editor and viewer roles, version history and its own sync server.

01

Problem

People editing one document need each other’s changes instantly, and a viewer must not be able to write, even with a modified client.

02

Constraints and approach

  • Identity comes only from the server session, and roles only from the database.
  • The sync server checks every connection itself, and drops a viewer’s writes.
  • Losing access takes effect on open documents, not on the next page load.

Tiptap on Yjs (a CRDT) in a Next.js app, synced through a self-hosted Hocuspocus server. The app mints a two-minute token per user, document and role; the sync server re-checks the role in Postgres, drops a viewer’s writes and closes sockets when access is revoked.

Who can do what

One table drives every permission check, in the app and on the sync server[1]Source 1Authorization model in the SyncScribe READMEOpen source 1 (opens in a new tab)All sources. People without access get “not found”, so document ids cannot be probed.

Actions allowed for each role
ActionViewerEditorOwner
Read, see who is here, export Markdownyesyesyes
Edit content, rename, invitenoyesyes
View, save and restore versionsnoyesyes
Change roles, remove members, transfer, deletenonoyes

Design choices

Design choices and their trade-offs
ChoiceWhy, and what it cost
Yjs, a CRDTEdits merge without a central sequencer, so the sync server only relays and stores updates, and clients keep working through short disconnects. Content cannot be deleted on the server, so restoring a version writes the old text as a new edit.
My own sync serverA self-hosted Hocuspocus server keeps the security boundary in code I can test: its websocket tests connect real clients and check that bad tokens, strangers and viewer writes are refused.
Short-lived tokensThe sync server runs on another origin, so the app mints a two-minute token for one user, one document and one role. The server still checks the database, because a token can outlive a permission change.
Postgres LISTEN/NOTIFYRemoving a member or lowering a role notifies the sync server, which closes that person’s socket. No Redis or message queue needed.
A static demoHosting the full stack on free tiers means cold starts, so the live demo is a static build that shares the editor, role table and Markdown export, and syncs between tabs over a BroadcastChannel.

The README covers each choice and its trade-off in more detail[2]Source 2Design decisions and trade-offs in the SyncScribe READMEOpen source 2 (opens in a new tab)All sources.

03

Results

The tests run against a real Postgres and real websockets. They cover every cell of the permission table, forged roles in request bodies, expired and tampered tokens, viewer writes that must never be stored, and removed members being cut off and refused when they reconnect[3]Source 3What the tests cover, in the SyncScribe READMEOpen source 3 (opens in a new tab)All sources.

Unit and integration tests
241
End-to-end tests
20
Roles enforced on the server
3
Sync token lifetime
120 s

Other limits from the README: each document lives in one sync server process, so it does not scale out yet, and both apps share one signing secret[4]Source 4Limitations and next steps in the SyncScribe READMEOpen source 4 (opens in a new tab)All sources.

04

Sources