Case study
SyncScribe
A real-time collaborative editor with owner, editor and viewer roles, version history and its own sync server.
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.
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.
| Action | Viewer | Editor | Owner |
|---|---|---|---|
| Read, see who is here, export Markdown | yes | yes | yes |
| Edit content, rename, invite | no | yes | yes |
| View, save and restore versions | no | yes | yes |
| Change roles, remove members, transfer, delete | no | no | yes |
Design choices
| Choice | Why, and what it cost |
|---|---|
| Yjs, a CRDT | Edits 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 server | A 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 tokens | The 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/NOTIFY | Removing a member or lowering a role notifies the sync server, which closes that person’s socket. No Redis or message queue needed. |
| A static demo | Hosting 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.
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.
Sources
- [1]Authorization model in the SyncScribe README (opens in a new tab)Back to where it is cited
- [2]Design decisions and trade-offs in the SyncScribe README (opens in a new tab)Back to where it is cited
- [3]What the tests cover, in the SyncScribe README (opens in a new tab)Back to where it is cited
- [4]Limitations and next steps in the SyncScribe README (opens in a new tab)Back to where it is cited
Links and stack
- TypeScript
- Next.js
- Yjs
- Hocuspocus
- Postgres
- Auth.js