Skip to content

Add a self-hosted Hocuspocus collaboration server (docs-collab-server)#342

Draft
jhodapp wants to merge 18 commits into
mainfrom
feat/docs-collab-server
Draft

Add a self-hosted Hocuspocus collaboration server (docs-collab-server)#342
jhodapp wants to merge 18 commits into
mainfrom
feat/docs-collab-server

Conversation

@jhodapp

@jhodapp jhodapp commented Jun 3, 2026

Copy link
Copy Markdown
Member

Description

Adds docs-collab-server, a self-hosted, Hocuspocus-compatible real-time collaboration server (Rust/Axum) that replaces TipTap Cloud for coaching notes. It speaks the Hocuspocus wire protocol over yrs (Yjs) and persists document state to our own Postgres, so collaborative editing no longer depends on a paid, document-capped external service.

The existing application crates are unchanged: only the tiptap_* config values repoint from TipTap Cloud to the local server. The frontend change is a config-only repoint and ships as a separate, coordinated PR in refactor-platform-fe.

docs-collab-server is a standalone workspace member (excluded from default-members, with no dependency on any app crate) so it can be extracted to its own repository later.

GitHub Issue: link if applicable

Changes

  • New docs-collab-server crate: Axum WebSocket + REST server; in-memory document registry with single-flight load and idle eviction; debounced write-behind persistence to a collab_documents table.
  • Hocuspocus protocol layer: byte-exact frame codec over the lib0/yrs encoding, the sync + awareness handshake, and the POST/DELETE /api/documents/:name management API the app backend already calls.
  • Auth: HS256 JWT verification with per-document scope enforcement (allowedDocumentNames wildcard), using the same signing key the app backend mints with.
  • App backend: no code changes; tiptap_* settings repoint to the local server.
  • Docs: implementation plan, local end-to-end runbook, and architecture diagrams (component, system, crate-dependency, network). Adds functional-style + compact-comment coding standards.

Testing Strategy

  • cargo test -p docs-collab-server runs the unit suite, the frozen protocol conformance tests (decode/encode verified byte-for-byte against real @hocuspocus/provider capture fixtures), and the document-sync integration tests.
  • Env-gated integration tests run on demand against a live DB/server: storage_pg (Postgres), e2e_provider (real WebSocket handshake), and authz_e2e (cross-scope / bad-signature rejection over the wire).
  • Validated end-to-end locally per docs/test-plans/docs-collab-server-local-e2e.md: two-browser real-time text sync, presence, persistence across a server restart, CRDT offline merge, REST document provisioning/deletion, and scope-based auth rejection.

Concerns

  • Local validation only. Production deploy wiring (Docker/compose, CI, TLS for the WS route) is intentionally out of scope here; the server is not yet deployed.
  • Coordinated frontend PR required. The FE provider repoint ships separately; the JWT signing key and the management auth key must match across the app backend and docs-collab-server.
  • Known follow-ups before prod/beta (tracked in the plan's "Known gaps" section): force-evict on DELETE of a live doc, constant-time management-auth compare, idle-connection keepalive, and SyncStatus-after-initial-sync. None block local use.
  • Single instance (in-memory registry; no multi-node coordination yet).

jhodapp added 17 commits May 31, 2026 14:03
New binary+lib crate that will reimplement the Hocuspocus collaboration wire
protocol over generic yrs 0.26 for self-hosted document sync. Phase 1 of the
plan at docs/implementation-plans/docs-collab-server.md.

Wired into [workspace].members but excluded from default-members so it does
not build with the main application (mirrors the testing-tools exclusion).
Module skeletons (protocol, storage, auth, document, registry, config) expose
the public API the frozen Phase 2 test suite will compile against; all bodies
are todo!() and will be filled in per-phase.

Config follows the service crate idioms: clap derive with #[arg(long, env)],
private fields with accessors, a from_args test constructor, and a side-
effect-free Default that uses from_args(["docs-collab-server"]). Required
secrets (jwt_signing_key, management_auth_key) are Option<String> so Default
works for tests; the runtime bootstrap checks Some.

Dependency versions pin to copies already resolved in the workspace lockfile
(axum 0.7.9, dashmap 6.2.1, sqlx 0.8.6, jsonwebtoken 10, async-trait 0.1) so
no third or duplicate crate copy is introduced. sqlx declares the postgres
feature explicitly because this crate does not pull it in via sea-orm.
… harness

Phase 2 of the docs-collab-server experiment. Writes the conformance gate
that subsequent implementation phases must satisfy, then physically freezes
it (chmod a-w) so any edit during implementation is a deliberate, visible
act rather than a quiet bias.

Test layout (all read-only post-commit):
* tests/protocol_conformance.rs: per-fixture decode + byte-stable round-trip,
  proptest round-trip on simple bodies, negative cases (truncated, unknown
  tag, bad utf8, unknown auth/sync sub-tag).
* tests/auth.rs: HS256 wildcard scope, aud-tolerant verify, expired/wrong-key/
  garbage rejection. Mints tokens with the same claim shape the app uses
  (domain/src/jwt/claims.rs) and the same aws_lc_rs crypto backend.
* tests/storage_pg.rs: PostgresStorage round-trip + idempotent upsert +
  missing-row semantics, all #[ignore], gated on DATABASE_URL.
* tests/document_sync.rs: two yrs::Doc peers converge through one Document,
  awareness fan-out without sender echo, evict+reload persistence, registry
  Arc-identity for unevicted names.
* tests/e2e_provider.rs: #[ignore] tungstenite replay of the auth fixture
  against an externally-running server (Phase 7 will expose the in-process
  serve hook this test should be lifted onto).

Wire fixtures: tests/fixtures/*.bin + manifest.json are the source of truth
for the protocol layer. They are captured by tests/fixtures/capture/capture.mjs,
which calls the same lib0 + y-protocols + @hocuspocus/common primitives the
real @hocuspocus/provider 2.15.3 invokes internally (its OutgoingMessage
classes are not in the package "exports" surface, so we go through their
underlying functions: byte-identical output).

White-box unit tests live in sibling files src/{protocol,document,registry}_
tests.rs, wired in via #[cfg(test)] #[path = "..."] mod tests; from each
source module. This pattern was chosen over in-file #[cfg(test)] mod tests {}
blocks specifically so the test files can chmod a-w without locking the
source above them.

API signatures pinned by the tests (Phase 3 forward, change deliberately):
* Document: name(), join() -> (ConnectionId, broadcast::Receiver<Vec<u8>>),
  leave(id), handle(from, body) -> Result<Vec<Body>, StorageError>, flush().
* DocumentRegistry: evict_now(name) -> Result<bool, StorageError>.
* lib re-exports: AuthError, ConnectionId.

The plan doc (docs/implementation-plans/docs-collab-server.md) is updated in
this commit to record the API pins and the separate-file freeze pattern.

cargo test -p docs-collab-server --no-run: builds cleanly.
cargo test -p docs-collab-server: fails on todo!() stubs as expected
(19 todo-panics, 12 ignored, 0 spurious passes).
cargo clippy -p docs-collab-server --all-targets -- -D warnings: clean.
cargo fmt -p docs-collab-server --check: clean.
…unce-coalesce invariants

Promotes three Phase 2 placeholder unit tests to real, executable tests
authored to spec while every Phase 5 stub is still todo!(), so the tests
cannot be biased by an implementation. Adds a counting in-memory Storage
helper, gated #[cfg(test)] only. Adds Document::open_with_debounce as an
additive constructor so the existing frozen integration tests using
Document::open are unaffected; the new constructor remains a stub for
Phase 5 to implement.
Implements Frame::decode and Frame::encode against the @hocuspocus/common
2.15.3 wire format, layered on the lib0 codec exposed by yrs 0.26. State
vectors, sync updates, and awareness payloads are round-tripped via the
yrs Encode/Decode impls so the bytes match y-protocols' JS encoder
exactly. SyncStatus uses lib0's sign-bit varInt (yrs VarInt for i64), not
the zigzag SignedVarInt wrapper.

Sub-tag dispatch happens before reading sub-payloads so an unknown sync
or auth sub-tag surfaces as UnknownSyncTag / UnknownAuthTag rather than
Truncated. Doc-name utf-8 validation is done after reading the raw byte
buffer so Truncated and Utf8 stay distinct error kinds.

All 8 tests in tests/protocol_conformance.rs pass, including the
byte-identical fixture round-trip and the simple-body proptest.
Documents, registry, storage, auth, and SSE remain todo!() stubs for
later phases.
…ends

MemoryStorage backs the test suite and the in-process path; PostgresStorage
persists to <schema>.collab_documents via sqlx runtime queries (no compile-time
macros, so the crate builds with no live DB and no offline metadata).

connect() validates the schema identifier against [A-Za-z_][A-Za-z0-9_]* before
interpolating it into DDL (Postgres cannot bind identifiers), then bootstraps
the schema and table idempotently. The IF NOT EXISTS forms are not atomic at
the catalog level, so a small helper absorbs the lost-race SQLSTATEs (23505,
42P06, 42P07) when concurrent connects collide on pg_namespace / pg_class.

Verified: storage_pg --ignored 4/4 green against local Postgres; the rest of
the default suite still fails on Phase 5/6 todo!() stubs with 0 spurious
passes; protocol_conformance 8/8 still green; fmt + clippy clean.
Implements the concurrency core for the collab server (Phase 5).

Document
- Owns Awareness (which owns the Doc), per-connection broadcast::Senders for
  fan-out, an Arc<PersistState> shared with a write-behind persist task, and
  the yrs::Subscription returned by observe_update_v1 (retained as a named
  field so the observer survives past the first callback).
- join allocates a per-connection ConnectionId + dedicated broadcast channel,
  so echo-skip is structural: peer fan-out iterates the map and skips the
  originating id, no wire tag required.
- handle dispatches SyncStep1/SyncStep2/Update/Awareness/AwarenessQuery;
  malformed Update bytes are logged and treated as a state-change signal
  (debounced persist still fires) rather than propagated as a storage error.
- Debounced write-behind: a single tokio task waits on Notify, sleeps until
  last_change + window, re-arms if a fresher poke arrived during sleep, and
  on quiescence snapshots state under the awareness lock (then drops the
  guard) before awaiting storage.store. A burst inside the window coalesces
  to exactly one store. The task is aborted in Document::Drop.

DocumentRegistry
- DashMap<String, Arc<OnceCell<Weak<Document>>>> with Arc::new_cyclic so each
  Document receives a Weak<DocumentRegistry> back-reference at construction.
- get_or_load uses tokio::sync::OnceCell::get_or_try_init for single-flight
  loading (one Storage::fetch under concurrent first-load); a per-call
  Mutex<Option<Arc<Document>>> side-channel hands the initiating caller the
  strong Arc while the cell stores only a Weak. Concurrent peers upgrade the
  Weak (the winner is mid-return holding the strong ref).
- Document::Drop self-removes its registry cell iff conns is empty, so an
  unjoined idle doc is collected as soon as its last external Arc releases.
  A still-joined doc whose last external Arc happens to drop is left in the
  map so a subsequent evict_now still reports it as present.
- evict_now removes the cell, flushes when the Weak upgrades, and returns
  presence at call time.

Dependencies
- yrs gains the sync feature so Awareness's observer callbacks are
  Send + Sync (required to share Arc<Mutex<Awareness>> with the spawned
  persist task).
- parking_lot::Mutex replaces std::sync::Mutex for all internal locks: no
  Result return on lock(), no .unwrap() on infallible paths.

Plan doc updated to reflect the per-connection broadcast topology and the
Weak-storage + conditional Drop auto-eviction registry design.

Gates green:
- tests/document_sync.rs 4/4
- src/document_tests.rs::debounced_writes_coalesce_a_burst
- src/registry_tests.rs::evict_drops_inner_entry_after_last_arc_release
- src/registry_tests.rs::concurrent_get_or_load_does_not_double_load
Remaining suite still fails on stubs (auth Phase 6); zero spurious passes.
clippy --all-targets -D warnings clean; cargo fmt --check clean.
forget() removed the cell by name unconditionally. Under a multi-threaded
runtime a concurrent reload can replace a dead cell with a fresh live
Document reusing the same name between the dying doc's last-Arc drop and its
Drop reaching forget(); the unconditional remove would then orphan the live
entry, producing two divergent Documents for one name. Guard the removal on
the cell's Weak being unupgradeable so only a genuinely dead cell is collected.
Prefer Rust combinator/functional style over procedural loops where it reads
clearly, and keep comments compact but useful to a human developer (explain
why, one short line, not absent). Adds both as Code Review Checklist items.
…rage.rs

Bring Phase 3/4 code up to the functional-style standard where each expression
reads like a logical sentence. No behavior change (protocol_conformance 8/8,
storage_pg 4/4 still green).

- protocol.rs: replace match-on-Result and let-then-wrap with map/map_err chains
  (read_var_string, Awareness/SyncStep1/SyncStatus decode arms).
- storage.rs: validate_schema_ident as a single boolean predicate + then_some;
  is_concurrent_bootstrap_race via is_some_and; fetch via map(...).transpose().
Drops the three .lock().unwrap() sites in MemoryStorage by switching its
std::sync::Mutex to parking_lot::Mutex, matching the no-unwrap-in-production
rule and the pattern already used in document.rs/registry.rs. Derives still
hold (parking_lot::Mutex is Default + Debug). No behavior change.
Wire the protocol/document/registry/auth/storage layers into a live axum
server. New ws.rs hosts the per-connection actor (single-task sink
ownership, StreamMap fan-in for peer broadcast, per-doc authz gate);
new rest.rs hosts POST/DELETE /api/documents/:name with verbatim
Authorization compare. main.rs becomes a thin tracing-subscriber +
serve() entrypoint.

serve() refuses to start if either shared secret is absent (an empty
fallback would silently accept every token and every management call),
installs Ctrl-C as the graceful-shutdown future, and after the listener
stops calls registry.flush_all() so debounced writes still in-flight at
shutdown land in storage before exit.

Two additive, non-breaking registry methods:
- new_with_debounce(storage, persist_debounce) threads the configured
  debounce into every Document, while new() keeps the frozen default.
- flush_all() snapshots strong refs out of the DashMap and serially
  flushes each live document. Required because Document::Drop aborts
  the persist task without flushing.

No frozen test was modified. Plan doc updated to reflect the
single-sink + StreamMap concurrency shape, the log-and-continue Lagged
handling, and the new registry surface.
… runbook

Add a Known gaps / follow-ups section to the plan (DELETE-evict, constant-time
auth compare, idle keepalive, SyncStatus-after-initial-sync) capturing the gap
between our validated subset and faithful Hocuspocus, and commit the Phase 9
local end-to-end verification runbook.
…-collab-server in system/crate/network diagrams

- New docs_collab_server_components.md: first-order module view across
  docs-collab-server, refactor-platform-rs, and refactor-platform-fe, with the
  three cross-component contracts and the two shared-secret invariants.
- system_architecture: external TipTap node becomes docs-collab-server; add the
  frontend's direct collaboration WebSocket edge (same topology as TipTap Cloud,
  endpoint moved).
- crate_dependency_graph: add docs-collab-server as a standalone, edgeless crate
  (no app-crate deps, excluded from default-members, extractable).
- network_flow: add docs-collab-server as a PLANNED droplet element (validated
  locally; production routing/deploy not yet done).
@jhodapp jhodapp self-assigned this Jun 3, 2026
@jhodapp jhodapp changed the title feat: self-hosted Hocuspocus collaboration server (docs-collab-server) Add a self-hosted Hocuspocus collaboration server (docs-collab-server) Jun 3, 2026
@jhodapp jhodapp added the feature work Specifically implementing a new feature label Jun 3, 2026
@jhodapp jhodapp moved this to 🏗 In progress in Refactor Coaching Platform Jun 3, 2026
@jhodapp jhodapp added this to the 1.0.0-beta3 milestone Jun 3, 2026
@jhodapp

jhodapp commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Frontend companion PR: refactor-group/refactor-platform-fe#409 (config-only provider repoint). Merge/deploy must be coordinated; the JWT signing key and management auth key must match across both.

Replace the hardcoded postgres://refactor:password@localhost connection string
with a pointer to the environment/.env, so no local credentials live in the doc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature work Specifically implementing a new feature

Projects

Status: 🏗 In progress

Development

Successfully merging this pull request may close these issues.

1 participant