(collaboration) add create-ydoc endpoint on yhub

Python cannot call yhub's built-in PATCH ydoc api because its body must
be lib0-any encoded - a lib0-specific binary framing with no
implementation outside javascript. The new endpoint
POST /collaboration/create-ydoc/v1/{org}/{docid} accepts the raw binary
Yjs update (pycrdt get_update() / Y.encodeStateAsUpdate output) as
application/octet-stream, so Django can seed a document's initial state
with a plain requests.post(url, data=raw_bytes) - needed by the
server-side creation flows (file import, create-for-owner, duplication,
template instantiation) whose yhub rooms currently stay empty until the
first browser connects.

Strict create semantics: 409 when the room already has content
(checked via getDoc, covering persisted state and uncompacted stream
messages; yhub has no atomic create, concurrent creates merge via CRDT
and never corrupt). The initial content is attributed to the optional
X-User-Id header, else to the caller's identity. Access uses the
default purpose, i.e. standard document write access like the built-in
ydoc routes: the admin JWT, or a user session with update ability.
Malformed updates map to 400 (the compute worker rejects them and the
pool replaces the thread), empty updates to 400, bodies over 10MiB to
413.

Gotcha worth noting: req.bytes() resolves to a Node Buffer, but yhub's
compute-task schema validates with lib0's exact-constructor Uint8Array
check, so the body is re-viewed as a plain Uint8Array before it is
handed to the compute pool.

Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-09-04 15:35:25 +02:00
committed by Anthony LC
parent 8576654dad
commit 8469a68fd4
3 changed files with 107 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ✨(backend) publish the JWT public key on a JWKS endpoint
- 🔧(dev) generate the JWT signing key when bootstrapping the dev stack
- ✨(collaboration) add an admin reset-connections endpoint on yhub
- ✨(collaboration) add a create-ydoc endpoint on yhub
### Changed
+9
View File
@@ -20,6 +20,15 @@ It is not a fork of yhub — it is a thin wrapper (`server.js`) that:
pending) — authenticated with an RS256 admin JWT issued by Django and
verified against its JWKS (`/api/v1.0/jwks`); the `reset-connections`
purpose is granted only to that admin token, never to regular users,
- exposes `POST /collaboration/create-ydoc/v1/{org}/{docid}` (optional
`X-User-Id` header naming the user the initial content is attributed to),
which seeds a document's initial Yjs state from a raw binary update
(`Y.encodeStateAsUpdate` / pycrdt `get_update()` output posted as
`application/octet-stream` — no lib0 encoding, unlike yhub's built-in
`PATCH .../ydoc/`), so the Django backend can create documents
server-side. Strict create: 409 when the document already has content.
Guarded by standard document write access (the admin JWT, or a user
session with update ability),
- mirrors the environment conventions used elsewhere in this repository
(`*_FILE` secret indirection, `COLLABORATION_SERVER_ORIGIN` allowlist, …).
+97
View File
@@ -23,6 +23,15 @@ const Y_PROVIDER_API_KEY = secret('Y_PROVIDER_API_KEY', 'yprovider-api-key');
const ORG = process.env.YHUB_ORG || 'docs';
const UUID4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// an empty Yjs update (what `Y.encodeStateAsUpdate(new Y.Doc())` encodes to) —
// hardcoded so we don't import @y/y for two bytes
const EMPTY_YDOC = new Uint8Array([0, 0]);
// uws buffers the whole body before the handler sees it, so this cap does not
// bound upload memory — it bounds what a single create hands to a compute
// worker and writes to the valkey stream as one message. Creates carry one
// freshly-converted snapshot (typically KBs); anything bigger belongs on the
// websocket path.
const MAX_CREATE_BYTES = 10 * 1024 * 1024;
// Public keys verifying the RS256 admin tokens Django issues (JWTService).
// Lazily fetched on first use; jose caches the keys and refetches on unknown
@@ -169,6 +178,94 @@ const api = [
},
},
}),
// POST /collaboration/create-ydoc/v1/{org}/{docid} — create a document's
// initial Yjs state from a RAW binary update (`Y.encodeStateAsUpdate` /
// pycrdt `get_update()` output) posted as application/octet-stream. Unlike
// yhub's built-in `PATCH ydoc`, the body is not lib0-any encoded, so Django
// can call it with a plain `requests.post(url, data=raw_bytes)`. Strict
// create: 409 when the room already has content. Default access purpose:
// guarded like the built-in ydoc routes (write access on the doc — the
// admin JWT, or a user session with update ability).
createApiEndpoint('create-ydoc', {
post: {
handler: async (req) => {
if (req.org !== ORG) {
return jsonResponse(400, { error: 'Unknown org' });
}
if (!UUID4.test(req.docid)) {
return jsonResponse(400, { error: 'Room name is invalid' });
}
const body = await req.bytes();
// req.bytes() resolves to a Node Buffer, but the compute-task schema
// requires an exact Uint8Array (lib0 $constructedBy compares the
// constructor) — re-view the same bytes without copying
const update = new Uint8Array(
body.buffer,
body.byteOffset,
body.byteLength,
);
if (update.byteLength > MAX_CREATE_BYTES) {
// 413 is missing from yhub's status-line map, so the reason phrase
// is empty ("HTTP/1.1 413 ") — legal, and callers switch on the code
return jsonResponse(413, { error: 'Update too large' });
}
// <= 3 bytes is yhub's "no effective content" convention (an empty
// update encodes to 2 bytes) — reject before it reaches a worker
if (update.byteLength <= 3) {
return jsonResponse(400, { error: 'Empty update' });
}
// covers persisted state AND uncompacted stream messages. Not atomic
// with addMessage below (yhub has no atomic create): two concurrent
// creates can both pass — acceptable, Yjs merges both updates; worst
// case is a doubly-attributed first revision, never corruption.
const { gcDoc } = await req.yhub.getDoc(
req.room,
{ gc: true, nongc: false },
{ gcOnMerge: false },
);
if (gcDoc != null && gcDoc.byteLength > 3) {
return jsonResponse(409, { error: 'Document already exists' });
}
// attribute the initial content to the acting user when the caller
// names one, else to the caller's identity ('system' for admin tokens)
const userid = req.headers['x-user-id'] || req.authInfo.userid;
let result;
try {
// diffs the posted update against the (empty) current doc and
// stamps the attribution contentmap
result = await req.yhub.computePool.patchYdoc(
{
update,
currentDoc: gcDoc ?? EMPTY_YDOC,
userid,
customAttributions: [],
},
{ room: req.room },
);
} catch {
// a malformed update makes the compute worker throw (yhub logs
// 'worker failed' and replaces the thread). The update is the only
// untrusted input here, so a rejection maps to 400; getDoc /
// addMessage failures stay generic 500s.
return jsonResponse(400, { error: 'Invalid Yjs update' });
}
if (result == null) {
// structurally valid but no effective content (e.g. delete-set
// only). A "successful" create that leaves the room nonexistent
// would lie to the caller — a later create would not 409.
return jsonResponse(400, { error: 'Empty update' });
}
// on a fresh room this creates the stream, schedules compaction, and
// fans out to any live subscribers — nothing else to do
await req.yhub.stream.addMessage(req.room, {
type: 'ydoc:update:v1',
contentmap: result.contentmap,
update: result.update,
});
return jsonResponse(201, { message: 'Document created' });
},
},
}),
];
await createYHub({