Skip to content

Latest commit

 

History

History
1179 lines (819 loc) · 105 KB

File metadata and controls

1179 lines (819 loc) · 105 KB

docs.plus Backend API

Base URL (dev): http://localhost:4000 Package: @docs.plus/hocuspocus

The REST API runs from src/index.ts on a Hono app. This document covers the HTTP surface only. For the three-process architecture and environment variables, see Readme.md and ENV.md. For the WebSocket protocol, see WebSocket API.

Machine-readable spec. The same surface is published as OpenAPI 3.1 at GET /openapi.json, with Swagger UI at GET /docs (src/modules/openapi/). Request schemas are generated from the live zod schemas, so they cannot drift from validation. Traefik routes only /api and /health, so both paths are reachable in local and internal environments but not on the public edge. Publishing them is a routing decision, not a code change.

Contents

  1. Authentication
  2. Response envelope
  3. Health
  4. Documents
  5. Document content
  6. Document versions
  7. Document changes
  8. Document conversion
  9. Media
  10. Link metadata
  11. Email
  12. Admin
  13. Push notifications
  14. Rate limiting
  15. WebSocket API

Authentication

Three schemes apply, by route group:

Scheme Used by Header
None /, /health/*, /openapi.json, /docs, GET /api/plugins/hypermultimedia/:documentId/:mediaId, /api/metadata, GET/POST /api/email/unsubscribe, GET /api/email/health, GET /api/email/status, POST /api/email/validate —
Optional Supabase user JWT GET /api/documents (list without ownerId), GET /api/documents/:slug, PUT /api/documents/:docId token: <jwt>
Required Supabase user JWT GET /api/documents?ownerId=… / ?deleted=true, POST /api/documents, document lifecycle (DELETE /:id, /:id/restore, /:id/duplicate, /:id/favorite, /:id/opened, /:id/permanent, POST /trash/purge, /trash/restore), POST /api/plugins/hypermultimedia/:documentId (media upload; the service-role key is refused there — it is not a user token, so requireUser can resolve nobody from it) token: <jwt>
Either of the two above GET /api/documents/:documentId/export, POST /api/documents/:documentId/import — the key passes every document, a user token is checked against the document's privacy and lock token: <jwt> or the service-role bearer
Supabase service-role key /api/email/send-generic, /send-digest, /bounce, /preview/:type, GET/PATCH /api/documents/:documentId/content, every /api/documents/:documentId/versions route, GET /api/documents/:documentId/changes, and the content / ownerId fields on POST /api/documents Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
Supabase user JWT + admin_users row /api/admin/* Authorization: Bearer <jwt>

GET /api/documents/:slug auth (shipped — owner-scoped private): optionalUser attaches the caller. Public docs return full metadata to anyone; private docs are owner-only — anonymous or ownerless-private → 403 { access: 'sign-in-required' }, signed-in non-owner → 403 { access: 'denied' }. See the slug access matrix below.

Service-role checks use a constant-time compare (verifyServiceRole in src/lib/auth.ts). When SUPABASE_SERVICE_ROLE_KEY is unset they fail closed in every environment, non-production included. Admin routes verify the JWT with Supabase, then require a matching row in admin_users (src/api/middleware/adminAuth.ts); failures return 401 (no/invalid token) or 403 (not an admin).

List owner filter (shipped): When ownerId is present on GET /api/documents, the caller must send a valid token header. ownerId must equal the JWT subject (sub). Missing token → 401; mismatched ownerId → 403.

Unauthenticated fleet list: GET /api/documents without ownerId returns public rows only. isPrivate: true rows are clamped out for any unverified caller or owner-less list (both the page query and its total count). This closes anonymous ?title= enumeration of private titles/descriptions. Owner-scoped calls (ownerId === token.sub) are unaffected and still see the owner's private docs. The webapp Settings → Documents UI always sends ownerId + token and never uses the fleet path.

Response envelope

The canonical error shape is defined by getErrorResponse in src/lib/errors.ts:

{
  "success": false,
  "error": { "message": "Human-readable message", "code": "ERROR_CODE" }
}

error.details is included only when NODE_ENV=development. AppError subclasses map to status codes and codes: VALIDATION_ERROR (400), BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), PAYLOAD_TOO_LARGE (413), UNSUPPORTED_MEDIA_TYPE (415), UNPROCESSABLE_ENTITY (422), RATE_LIMIT_EXCEEDED (429), INTERNAL_SERVER_ERROR (500), SERVICE_UNAVAILABLE (503), DATABASE_ERROR. handlePrismaError maps Prisma codes (P2002 → conflict, P2025 → not found, etc.) into the same set. AUTH_UNAVAILABLE (503) sits outside this set: the auth middleware returns it, not an AppError, when Supabase token verification is unreachable.

The documents controller emits this envelope on error and { "success": true, "data": ... } on success.

Known inconsistency — not yet wired globally. Several handlers return ad-hoc shapes instead of the canonical envelope. Wiring getErrorResponse everywhere is a separate task. Until then expect these variants:

  • { "error": "..." } — email handlers, admin middleware, media-upload guards.
  • { "success": false, "code": "...", "message": "..." } (note code/message at top level, not nested) — /api/metadata; its code is INVALID_URL or BLOCKED_URL.
  • Email send-* and bounce success bodies are { "success": true, ... } with their own fields, not the data wrapper.

Health

Health routes are exempt from rate limiting. Each returns 200 when healthy and 503 otherwise. Source: src/api/routers/health.router.ts, src/api/services/health.service.ts.

GET /health

Aggregate check across database, Redis, and Supabase. Returns degraded (503) only when a critical service (database, or Redis when enabled) is unhealthy; Supabase may be down without failing the overall check.

{
  "status": "ok",
  "timestamp": "2026-06-15T12:00:00.000Z",
  "services": {
    "database": { "status": "healthy", "lastCheck": "...", "metadata": { "pool": {} } },
    "redis": { "status": "healthy", "lastCheck": "...", "metadata": {} },
    "supabase": { "status": "healthy", "lastCheck": "..." }
  }
}

Per-service status is one of healthy, unhealthy, or disabled (Redis/Supabase when not configured).

GET /health/database

Database connectivity (SELECT 1) plus pool metadata.

GET /health/redis

Redis PING. disabled when Redis is not configured.

GET /health/supabase

Supabase reachability. disabled when SUPABASE_URL/SUPABASE_ANON_KEY are unset.

GET /health/push

Push gateway status. 200 when VAPID is configured and the queue is connected.

Documents

Base path /api/documents (src/api/routers/documents.router.ts). Success responses use { "success": true, "data": ... }.

GET /api/documents

List or search documents. With any of title/keywords/description, runs a full-text search; otherwise lists all rows. Owner profiles (snake_case, mirroring public.users) are joined in.

Param Type Default Description
title string — Search term (tokenized)
keywords string — Search term (tokenized)
description string — Search term (tokenized)
ownerId uuid — Filter by owner — requires token header; must match JWT sub
sort string updatedAt_desc Allowlisted: updatedAt_desc, createdAt_desc, lastOpenedAt_desc, title_asc, title_desc
limit string 10 Page size (1–100)
offset string 0 Pagination offset (≥ 0)

Auth: optionalUser on the route. When ownerId is set, missing or invalid token → 401; ownerId !== token.sub → 403. Without ownerId, no auth is required (fleet list — legacy).

List rows include readOnly, isPrivate, createdAt, and updatedAt. Owner live list and Owner Trash list include preview and lastOpenedAt. Owner live list also includes isFavorite. Owner Trash list omits isFavorite. Create, slug GET, update, and the public fleet omit preview and lastOpenedAt.

Sort: Optional sort query param. Allowed values: updatedAt_desc (default), createdAt_desc, lastOpenedAt_desc, title_asc, title_desc. The server maps each key to a fixed, allowlisted Prisma orderBy (an unknown value falls back to updatedAt_desc). Required for the Settings → My Documents sort dropdown — client-side sort breaks paginated Load more. An owner live list pins that user's Favorites first, then applies sort inside each group. Trash and the fleet do not pin.

Private clamp: Without a verified requester (ownerId === token.sub), private rows are excluded from both the results and total — see Unauthenticated fleet list above.

Semantic note: updatedAt on DocumentMetadata reflects metadata changes (title, flags, keywords), not every collaborative body save. The UI label “Last modified” matches Google Docs parity.

{
  "success": true,
  "data": {
    "docs": [/* ... */],
    "total": 100
  }
}

GET /api/documents/:docName

Fetch one document by slug. When no row matches, returns a synthesized draft (createDraftDocument) rather than 404. A soft-deleted row is the one 404 case. Synthesizing a draft there would mint a new documentId under a still-unique slug, and the two would collide on persist.

Param Type Description
userId string (query) Optional; accepted for access context

Auth (shipped — owner-scoped private): optionalUser attaches the caller (owner identity is token.sub, never the userId query param), and the controller applies:

Document Viewer Result
Soft-deleted anyone 404 — never the draft path
Public anyone 200 — full metadata
Private token sent, Supabase auth unreachable 503 — AUTH_UNAVAILABLE, checked before the rows below
Private no token or anonymous JWT 403 — { access: 'sign-in-required' }
Private signed-in non-owner 403 — { access: 'denied' }
Private owner (sub === ownerId) 200 — full metadata
Private, ownerId: null any signed-in user 403 — { access: 'sign-in-required' } (sign-in wall until owner backfill)

The 403 body is { success: false, error: { code: 'FORBIDDEN', message }, access }; the top-level access hint drives the webapp private gate's CTA. Draft slugs (no DB row) are unchanged — synthesized draft, never treated as private.

POST /api/documents

Create a document.

{ "title": "My Document", "slug": "my-document", "description": "", "keywords": [] }

title and slug are required; description defaults to "", keywords to []. The stored title is plain text. The schema strips HTML tags. The slug is normalized via slugify; a 19-char documentId is generated.

Two further fields are accepted only under the service-role key: content (a Tiptap JSON document, seeded as version 1) and ownerId (a bare Supabase uid). A user JWT presenting either gets 403 FORBIDDEN and nothing is written. See Create with content. Without them, behavior is unchanged for user-JWT callers.

PUT /api/documents/:docId

Upsert document metadata by documentId. All fields optional: title, description, keywords (string[]), readOnly (boolean), isPrivate (boolean), slug (string). A stored title is plain text. The schema strips HTML tags. slug sets the row's slug only when this call creates the row. It is ignored on every update and never renames a live document.

Access follows ownership. An owned document accepts writes only from its owner; every other caller gets 403, private or not. An ownerless document is open: anyone, signed in or not, may set title / description / keywords. But its locks cannot move, because a document with no owner has nobody to be private for. Those changes are ignored and logged. Creating the row through this route makes a signed-in caller its owner, so they may set the locks in the same request. A soft-deleted document is 404.

A signed-in rename that changes Pad title posts a workspace-chat notice after the write. The PUT does not wait for that call. A missing RPC, a signed-out rename, a create, an unchanged title, or an empty title posts nothing. The Prisma write still succeeds.

DELETE /api/documents/:documentId

Soft-delete (owner-only, requireUser). Stamps deletedAt; the row survives for restore and is reaped after DOC_DELETE_RETENTION_DAYS (default 30). Idempotent — a missing row returns success. Non-owner → 403.

POST /api/documents/:documentId/restore

Clear deletedAt (owner-only). Idempotent; non-owner → 403.

POST /api/documents/:documentId/duplicate

Copy the source's latest Yjs bytes into a fresh owner-owned doc (owner-only). Slug is <title> (copy), uniquified. Media is cloned, not shared: the source's objects are copied under the copy's own storage prefix, and the snapshot's URLs are repointed there. Each document therefore owns its media, and purging one never strips the other. The copy runs inside the request, so a source naming more than 32 media objects is refused up front → 413. Non-owner → 403; soft-deleted source → 404.

PUT /api/documents/:documentId/favorite

Owner-only (requireUser). Body { favorite: boolean }. Writes a DocumentFavorite join for token.sub (userId + documentId). true upserts; false deletes. Soft-deleted → 404. Non-owner → 403. Soft-delete of the document keeps the join, so restore stays favorited. Purge cascade-drops the row with the metadata.

Response { documentId, isFavorite }.

POST /api/documents/:documentId/opened

Owner-only (requireUser). Sets lastOpenedAt with raw SQL so @updatedAt does not move. Soft-deleted → 404. Repeats inside 30 seconds are a no-op success.

DELETE /api/documents/:documentId/permanent

Purge a soft-deleted doc's full footprint (owner-only) — chat and views via the purge_document_footprint Supabase RPC, editor media via a storage-prefix delete, then the metadata row. DocumentFavorite rows cascade-drop with that metadata delete. Refuses a live doc → 400. Idempotent (already-gone → success). Shares the reaper's purge path.

GET /api/documents?deleted=true

The caller's own soft-deleted docs (Trash), newest-tombstone-first. requireUser; owner-scoped to token.sub (never a client ownerId). Same page shape as the list, but no isFavorite and no favorites-first ordering. Owner Trash list includes preview and lastOpenedAt. A SQL-NULL preview is filled the same way as the Owner live list.

POST /api/documents/trash/purge

Bulk permanent-delete (owner-only). Body { ids?: string[] } — omit ids to empty the whole trash, or pass a selection (capped at 500). Returns { purged: <count> }. Runs the per-doc purge sequentially; synchronous, so a very large trash can exceed the request timeout (small in practice — the reaper bounds growth).

POST /api/documents/trash/restore

Bulk restore (owner-only). Body { ids: string[] } (1–500). Returns { restored: <count> }. Non-owned ids are skipped.

Document content

Read and write a document's body from a server-side integration. Writes reach live collaborators in real time. The REST process forwards them to the collaboration process, which applies them to the in-memory Y.Doc through the same pipeline a browser edit uses. Module: src/modules/document-content/.

All three surfaces are service-role only (Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>). A user JWT gets 401.

Identifying a document

Content routes address documents by documentId — the 19-character id that is also the collaboration room name and the snapshot key. Never by slug. The slug read never 404s: it synthesizes a draft and mints a fresh documentId on every call. So an automation trusting it would address a room no client will ever join.

Resolve a slug once with GET /api/documents/:docName and keep the documentId from the response. A synthesized draft carries no id or createdAt — that absence is how you detect a mistyped slug.

Private documents are not discoverable this way. The slug read enforces the owner-only private gate and does not honor the service-role key, and GET /api/documents?ownerId= needs that owner's JWT. Capture documentId from the create response. For a pre-existing private document it has to come out of band (the owner's own slug read, or an operator reading DocumentMetadata).

Content contract

  • Documents are title-first. The first node must be a level-1 heading. A heading-less replace or create payload is rejected with 422. The editor would otherwise synthesize a title on first open and persist a heading you never wrote.
  • Image nodes are inline. Wrap them in a paragraph or heading. A top-level image is 422.
  • The root content array is flat. A heading holds inline text only and never nests the blocks that follow it.
  • A section is a caller-side rule. A heading's section is the run of following root siblings, up to the next heading whose level is the same or smaller. Nested subsections fall inside it. Compute it from a GET; the server has no section concept.
  • Headings and tables carry a toc-id. The server assigns one where it is missing or duplicated within the payload. That id is the identity a heading's chat thread, fold state, and ?id= deep link hang off. Round-trip the toc-ids from GET through a replace to keep them stable. A paragraph carries no toc-id, so it is not addressable.
  • On append, a duplicate toc-id is permanent. The de-duplication covers one payload: the server never reads the live document's ids. So an id you send that a heading already owns is kept as sent. Both headings then share one chat thread, one fold and one ?id= anchor. The first occurrence in document order owns the address, and the second is unaddressable. Omit toc-id and let the server mint one, or GET first and avoid ids already in use. replace cannot collide this way, because it clears the fragment before it writes.
  • Unregistered attributes are dropped at encode. ProseMirror keeps only attributes the server's extension set declares; anything else is discarded silently. GET returns what was stored, not what was sent.
  • The first editable browser open may normalize the document, so a GET after someone opens it can differ from what you wrote.

GET /api/documents/:documentId/content

Reads the persisted head snapshot. Query: format = json (default) or text.

{
  "success": true,
  "data": {
    "documentId": "kR4pZ2mQ7tY1nB8xW3v",
    "version": 12,
    "format": "json",
    "content": {
      "type": "doc",
      "content": [
        {
          "type": "heading",
          "attrs": { "level": 1, "toc-id": "a1B2c3D4e5F6g7H8" },
          "content": [{ "type": "text", "text": "Quarterly report" }]
        }
      ]
    }
  }
}

format=text returns block text, one line per textblock:

{
  "success": true,
  "data": {
    "documentId": "kR4pZ2mQ7tY1nB8xW3v",
    "version": 12,
    "format": "text",
    "content": "Quarterly report\nRevenue grew 12%."
  }
}

A document with metadata but no snapshot row yet returns version: 0 with { "type": "doc", "content": [] } (or "" for text) — not an error.

Staleness. This is the persisted head, so it can trail a document people are actively editing by the store debounce — 10 s idle, 60 s maximum. There is no live read.

Trust boundary. Content is stored and returned verbatim; the server never sanitizes it. docs.plus renders it safely through its extension render gates plus CSP. But a consumer rendering this JSON with its own renderer must apply its own URL-scheme allowlist.

PATCH /api/documents/:documentId/content

Applies content to the document. Query: mode = replace (default) or append.

{
  "content": {
    "type": "doc",
    "content": [
      { "type": "paragraph", "content": [{ "type": "text", "text": "Appended by automation." }] }
    ]
  }
}
Field Type Description
content Tiptap doc JSON Required. { "type": "doc", "content": [...] } with at least one node
commitMessage string Optional, trimmed, 1–200 characters. Names the version row this write mints

replace swaps the whole body; append adds the payload's top-level nodes after the existing ones. Appending into an empty document makes the payload the document's first node, so the title-first rule applies there too.

Naming a write is permanent history. A commitMessage becomes the version row's name, and autosave retention never thins a named row — see Document versions. Name the writes a person would want to find again, and leave a bulk import's chunks unnamed. A named 500-chunk run leaves 500 rows nothing will ever prune.

Live and cold documents take the same path. If collaborators have the document open, they see the change immediately without reconnecting. If nobody has it open, it loads, applies, persists, and unloads.

Clearing a document is replace with a single empty level-1 heading: [{ "type": "heading", "attrs": { "level": 1 } }]. The applier also clears the editor's needsInitialization flag, which is what stops the starter template from overwriting that particular payload on first open.

Concurrency is CRDT last-writer-wins; there is no checksum or If-Match guard. Across replicas, replace guarantees removal of persisted content — concurrent unsynced edits on another replica may survive as a union or be superseded by CRDT order. Both are safe; neither corrupts.

Verifying a write. A 200 means the payload reached the live document and its save was handed off for persistence. Your own write flushes at once, not on the store debounce. So the 10 s / 60 s figures in the Staleness note above describe browser edits, never your verification wait. Confirm a write by comparing the content GET /api/documents/:documentId/content?format=json returns.

Retries. replace is idempotent. append is at-least-once under a 503 or timeout — GET and verify before retrying one. A retried append that carries toc-ids plants each one a second time — see Content contract. A single persist-failed 500 is retriable with mode=replace. A repeated persist-failed 500 for the same document means server-side persistence is wedged for it until the collaboration process restarts. Stop retrying and alert an operator, because further attempts keep mutating and broadcasting to live clients while never persisting. The operator signal is document_content_apply_total{outcome="error"} on the collaboration process's metrics endpoint.

Batching imports. Every PATCH persists a full document version. Prefer one replace or a few large appends over many small ones. A 500-chunk import stores roughly 500 cumulative snapshots, which survive until autosave retention thins unnamed versions to one per document per day (DOC_AUTOSAVE_RETENTION_DAYS). Many small appends also run into the global rate limiter.

Create with content

POST /api/documents with content (service-role only) encodes first, then writes the metadata row and version 1 in one transaction — an invalid payload leaves zero rows behind. ownerId sets the owner; isPrivate is not accepted. The first visitor renders the injected content rather than the starter template. A taken slug conflicts with 409 instead of being silently renamed.

Status codes

Status Code When
400 VALIDATION_ERROR Malformed documentId, unknown format or mode, or a body that is not a non-empty Tiptap doc
401 UNAUTHORIZED Missing, wrong, or user-JWT bearer; also when SUPABASE_SERVICE_ROLE_KEY is unset (fails closed)
403 FORBIDDEN content or ownerId on POST without the service-role key
404 NOT_FOUND No metadata row, or the document is soft-deleted. PATCH never creates — use POST
409 CONFLICT POST slug already taken
413 PAYLOAD_TOO_LARGE Body over 5 MiB, on PATCH and POST alike
422 UNPROCESSABLE_ENTITY Unknown node or mark, a content-expression violation, a missing level-1 title heading, or over 50 000 nodes / 100 levels of nesting. Nothing is written
429 RATE_LIMIT_EXCEEDED Global rate limiter. The house envelope; the retry seconds ride the Retry-After header — see Rate limiting
500 INTERNAL_SERVER_ERROR Snapshot decode failure on GET; persist failure on PATCH (see the durability contract); or the REST and collaboration processes holding different service-role keys, which the message names explicitly
503 SERVICE_UNAVAILABLE The collaboration process is unreachable or did not answer within 30 s

Access semantics under the service key

Document state GET PATCH
Public 200 200
Private 200 200 — the service key is the admin plane; user gates are untouched
Read-only 200 200 — the read-only lock keeps blocking non-owner users
Soft-deleted 404 404
No metadata row 404 404

Durability contract

A PATCH 200 means the change was applied to the document, broadcast to any live collaborators, and pushed into the normal store pipeline. It does not certify a committed database row:

  • In production the immediate store can lose the cross-replica lock to a peer and abort silently. The release flush and any later client-driven store heal it — the same crash window normal collaborative edits have.
  • For a cold document, 200 means durably enqueued. A worker outage longer than the claim-check TTL is a pre-existing platform gap.

A 500 with the persist-failed message means the change may already be visible to live clients but did not persist.

Security posture

Content surfaces sit behind the constant-time, fail-closed service-role check; the caps and guards below are availability and hygiene, not access control.

  • 5 MiB per content-bearing request body, enforced before parse.
  • 50 000 nodes and 100 levels of nesting, enforced before the payload reaches the Yjs transformer. Node count, not bytes, is what bounds encode cost.
  • documentId is shape-checked against the 19-character alphanumeric id format before any database round-trip or internal hop.
  • No server-side content sanitizer, deliberately: it is dead defense against a service-role caller. Stored-content XSS is handled at render time by the extension gates and CSP — see the trust boundary above.

Internal apply endpoint

POST /internal/documents/:documentId/content on the collaboration process's internal listener (port HOCUSPOCUS_INTERNAL_HTTP_PORT, default 4003, shared with /metrics). Not Traefik-routed and not reachable from the public edge. REST forwards to it over HOCUSPOCUS_INTERNAL_URL with the same service-role bearer and the caller's x-request-id. Deploy hocuspocus-server before rest-api whenever this wire shape changes.

Document versions

Read, compare, name, delete, and restore a document's history. Module: src/modules/document-versions/.

Route Does
GET /api/documents/:documentId/versions List the history, newest first
POST /api/documents/:documentId/versions Name the next stored version — a checkpoint
GET /api/documents/:documentId/versions/:version Read one version's content
GET /api/documents/:documentId/versions/:version/diff Compare a version with an earlier one, block by block
DELETE /api/documents/:documentId/versions/:version Delete one version, as long as it is not the newest
POST /api/documents/:documentId/versions/:version/restore Put a version's content back into the live document

All six are service-role only (Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>); a user JWT gets 401. They address documents by documentId, never by slug — see Identifying a document.

Reads and the delete run straight off Postgres in the REST process. The checkpoint and the restore need the live Y.Doc. So REST forwards them to the collaboration process over the same internal hop the content applier uses.

Every save is already a version

The collaboration server persists a full snapshot per debounced save — 10 s after typing stops, 60 s at the latest. So rows accumulate whether or not anyone asks for them. There is no versioning schedule to configure: that debounce envelope is the granularity, and it is the same window the content read can trail by.

A checkpoint therefore creates no history that would otherwise be missing. What it adds is a name, and the name is what the retention sweep keys off. Unnamed rows older than DOC_AUTOSAVE_RETENTION_DAYS (default 30) are thinned to one per document per day, while a row named by a person is never pruned. Spend names on the moments a person would want to find again.

Names the server mints for itself are the exception — see trigger. They read like any other named row but the sweep thins them, so a restore older than the retention window stops being undoable.

Snapshots are cumulative full state, not deltas, so deleting or pruning an old row never damages a newer one.

GET /api/documents/:documentId/versions

Metadata for the document's versions, newest first. No snapshot bytes — read those one version at a time.

Param Type Default Description
limit int 50 Page size, 1–100
offset int 0 Rows to skip, ≥ 0
{
  "success": true,
  "data": {
    "documentId": "kR4pZ2mQ7tY1nB8xW3v",
    "versions": [
      {
        "version": 14,
        "name": "Before the board review",
        "trigger": "checkpoint",
        "triggeredBy": null,
        "contributors": [],
        "createdAt": "2026-07-25T14:02:11.301Z"
      },
      {
        "version": 13,
        "name": null,
        "trigger": "websocket",
        "triggeredBy": {
          "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
          "avatar_url": "https://example.test/ada.png",
          "avatar_updated_at": "2026-07-01T09:12:00.000Z",
          "full_name": "Ada Lovelace",
          "display_name": "Ada",
          "status": "online"
        },
        "contributors": [],
        "createdAt": "2026-07-25T13:58:40.117Z"
      }
    ],
    "total": 14,
    "limit": 50,
    "offset": 0
  }
}

total counts every row for the document. It is read in the same transaction as the page, so it cannot describe a different history than the rows beside it.

Field Meaning
version Monotonic per document, starting at 1. Gaps are normal — retention and DELETE both leave them
name The row's commit message, or null for an unnamed autosave
trigger What caused the row: websocket, api, checkpoint, revert, revert-backup, schema-migration, or null on rows stored before attribution shipped
triggeredBy Profile of whoever caused the row, or null
contributors Profiles of everyone whose edits landed in this row
createdAt Commit time of the row, not of the edits inside it

Profiles are the public.users columns the history sidebar renders, snake_case as that table spells them. Attribution is decoration: when the profile lookup fails, triggeredBy falls back to null and unresolvable ids drop out of contributors rather than failing the request. An id that resolves to no profile disappears the same way.

triggeredBy on a websocket row is the collaborator whose edit flushed the debounce window. On a checkpoint or revert row it is whoever asked for the operation. Both REST write routes leave it null — the service key is nobody, and only the editor's own in-app revert carries a real account.

contributors is best-effort and per-replica. It is the set of accounts whose updates reached this collaboration replica since its previous save. So a room split across replicas records each replica's own set. Anonymous editors are stored in the column: they carry a Supabase sub like anyone else. But they resolve to no profile, so they never appear in the rendered list. Read the column's length as a floor on the rendered one, never as its equal.

The server names some rows itself, and their trigger is what tells them apart from a name a person typed. Before restore of version N carries revert-backup and Schema migration carries schema-migration; both list like any other named version, but retention thins them once they pass the window. Restored version N carries revert — a person asked for it, so it survives retention like a checkpoint.

POST /api/documents/:documentId/versions

Names the next stored version.

{ "name": "Before the board review" }

name is required, trimmed, and 1–200 characters. The document's body is untouched. The checkpoint opens the document, commits an empty transaction to trigger one immediate save, and lets that save carry the name.

{
  "success": true,
  "data": { "documentId": "kR4pZ2mQ7tY1nB8xW3v", "name": "Before the board review" }
}

A 200 means the save pipeline was triggered, not that a row exists. The row is written by the worker. When its transaction commits, the worker publishes doc:<documentId>:saved on Redis, and the collaboration process re-broadcasts it into the room as a document:saved stateless message. That broadcast is the signal the row landed. A REST caller gets no such push — poll GET …/versions for the name if you need certainty.

One gap to know about: when the enqueue fails, the collaboration process falls back to saving directly to Postgres. The row still lands, but nothing publishes doc:*:saved, so the room never sees a confirmation for it. That is pre-existing behavior of the fallback, not specific to checkpoints.

A checkpoint always lands, even when the bytes have not changed. The store pipeline deduplicates saves of identical content within a ten-second window. A checkpoint taken right after an autosave would otherwise be swallowed by it and lose the name. A checkpoint widens its own job id with a random key to stay off that dedupe. So naming an unchanged document produces a named row whose content duplicates the row before it. That is the intent — the name is the point.

GET /api/documents/:documentId/versions/:version

One version's content, through the same decoder as GET /content. Query: format = json (default) or text.

{
  "success": true,
  "data": {
    "documentId": "kR4pZ2mQ7tY1nB8xW3v",
    "version": 13,
    "format": "json",
    "content": { "type": "doc", "content": [] }
  }
}

The same trust boundary applies: stored content is returned verbatim and the server never sanitizes it.

GET /api/documents/:documentId/versions/:version/diff

Which top-level blocks differ between two versions, and whose text sits in them now. :version is the newer side.

Param Type Default Description
base int the nearest version below The older side of the comparison

The default is the greatest version below :version, which is not always :version - 1 — retention and DELETE leave gaps. A base at or above :version is a 400. That check runs before the document is looked up, so it answers 400 rather than 404 even for a document that does not exist. A base naming a version that does not exist is a 404. An omitted base on a document's oldest version is not, and comes back as fromVersion: 0 with the whole document read as added.

{
  "success": true,
  "data": {
    "documentId": "kR4pZ2mQ7tY1nB8xW3v",
    "fromVersion": 16,
    "toVersion": 17,
    "blocksBefore": 269,
    "blocksAfter": 269,
    "changes": [
      {
        "kind": "changed",
        "index": 65,
        "nodeType": "taskList",
        "from": 9610,
        "to": 11033,
        "before": "Capture the goal\nDraft the first pass",
        "after": "Capture the goal\nDraft the second pass",
        "clientIds": [3728007124]
      }
    ],
    "totalChanges": 4,
    "coarse": false,
    "unattributed": false,
    "authors": []
  }
}
Field Meaning
fromVersion The older side. 0 when the newer version has no predecessor
blocksBefore / blocksAfter Top-level block counts on each side
changes added, removed, and changed blocks, in the newer document's order, capped at 500 entries
totalChanges The true count before the cap. changes.length < totalChanges means the list was clipped
coarse Matching hit its cell budget and fell back to remove-all plus add-all. Positions and previews still hold, the pairing does not
unattributed The stored Yjs items did not line up with the decoded blocks, so every clientIds is empty

Each entry in changes carries index, the top-level index in the newer document — in the older one on removed. from and to are ProseMirror positions in the newer document, absent on removed. before and after are the first 200 characters of the block's text.

Comparison is over whole top-level blocks of decoded ProseMirror content, not over words, so a one-word edit reports the paragraph. Editor-regenerated toc-id attributes are stripped first: re-anchoring a heading is not a change.

Authorship. clientIds lists the Yjs clientIDs holding live content in the block, and authors names them — one entry per clientID that has a binding row.

{ "clientId": 3728007124, "user": { "id": "1b9d…", "display_name": "Ada" }, "anonymous": false }

user carries the same public.users columns the history sidebar renders, or null when the binding exists but no profile resolved. anonymous records that the account was anonymous when the clientID was bound. Identity is joined at read time. So an anonymous editor who later signs up gets their real name here across their whole history, with no backfill.

A clientID that appears in changes[].clientIds but not in authors is unattributed. Bindings exist only for edits made after attribution capture shipped, so on an older document authors is [] while clientIds stays populated. That is how a caller tells "unknown writer" apart from "nobody attributed".

The diff answers who wrote the text currently in this block. It does not answer who made this change, and it does not answer who deleted this. removed blocks carry no authorship, and no server setting changes that. Yjs records no deleter. createDeleteSetFromStructStore keys the delete set by the clientID that created each deleted item, and each entry stores only a clock and a length (yjs 13.6.31). Disabling garbage collection would keep the removed text and the name of whoever wrote it, and would still name nobody as the remover.

Server-side writes leave content with no bindable author. A binding is recorded only when a live socket announces the clientID it writes under (src/lib/client-authors.ts). Restore, import, and PATCH /content apply through a direct connection that has no socket, and applyContentToDoc clones every node. The new items therefore carry the server document's own clientID. After any of those operations the affected blocks return clientIds with no matching authors entry. Read that as an unknown writer, never as a wrong one. The version's trigger field names which operation did it.

clientID bindings are a first-claim record, not an audit trail. Yjs clientIDs are asserted by the client. The server records the account that first claimed one on a live socket and never overwrites it. So a legitimate editor cannot be displaced by a later forger. But the row is not proof of authorship, and nothing built on it should imply that it is.

Identical versions. A checkpoint of an unchanged document mints a named row whose content duplicates the row before it. Diffing that pair returns 200 with changes: [] and both block counts 0. The bytes are compared without decoding either side, so the counts are never taken. Zero here means not counted, not empty.

Neither 503 nor 413 can come back from this route. It reads straight off Postgres and never touches the collaboration process, and a GET carries no body to overrun.

DELETE /api/documents/:documentId/versions/:version

Deletes one version permanently.

{ "success": true, "data": { "documentId": "kR4pZ2mQ7tY1nB8xW3v", "version": 7, "deleted": true } }

The newest version cannot be deleted — 409 CONFLICT. It is the base a cold load reads, so removing it rewinds the document to an older snapshot. Worse, a room still open would then flush its newer live state back over that older base as a revert nobody asked for. Checking "is this the head" and deleting happen under one row lock, so the answer cannot go stale between the two.

To drop the current state, restore an earlier version instead: that moves history forward rather than truncating it.

POST /api/documents/:documentId/versions/:version/restore

Puts a version's content back into the live document.

Not to be confused with POST /api/documents/:documentId/restore, which is the trash restore — it clears deletedAt on a soft-deleted document and touches no content.

The route says restore and the internals say revert; they name the same operation.

{
  "success": true,
  "data": { "documentId": "kR4pZ2mQ7tY1nB8xW3v", "restoredFrom": 7, "backupVersion": 15 }
}

Three things happen, in order:

  1. The stored snapshot is decoded and re-encoded against the current schema. A version the schema can no longer express is rejected with 422 before anything is opened or written.
  2. The live document — including edits still inside the debounce window — is captured and committed as a new version named Before restore of version N. backupVersion is that row.
  3. The body is replaced with the target content, and the save that follows is named Restored version N.

So a restore appends two versions and never rewinds the counter. Undoing one is a restore of the backup it wrote. Repeating the same restore is safe but not free: each call banks another backup and another restore row.

backupVersion is number | null. Null means the collaboration process's reply could not be read, not that no backup was taken — the row exists either way. A history UI should render the absence rather than reporting "no backup".

When the backup cannot be committed, nothing is restored. The 500 says so explicitly and the document is unchanged. That ordering is deliberate, so a worker that dies mid-operation still leaves the pre-restore state recoverable.

When persistence is wedged, the backup is already committed and the replacement may already be visible to live collaborators without ever reaching the database. That is the persist-failed 500, and it carries the same rule as PATCH /content. Verify with GET before retrying, and treat a repeat for the same document as persistence being wedged for it until the collaboration process restarts.

The editor's in-app revert runs the same operation over the collaboration server's stateless channel instead of this route. That path requires an authenticated, writable connection, which is why it can attribute the resulting rows to a person and this one cannot.

Status codes

Status Code When
400 VALIDATION_ERROR Malformed documentId; a version or base that is not a positive integer within int4; a base at or above the version it is compared to; limit outside 1–100; negative offset; unknown format; a name that is empty after trimming or over 200 characters
401 UNAUTHORIZED Missing, wrong, or user-JWT bearer; also when SUPABASE_SERVICE_ROLE_KEY is unset (fails closed)
404 NOT_FOUND No metadata row, the document is soft-deleted, or no such version — including an explicitly named base
409 CONFLICT DELETE targeting the newest version
413 PAYLOAD_TOO_LARGE Write body over 16 KiB. These routes carry a name at most — content never rides them
422 DRAFT_DOCUMENT Checkpoint or restore on a document that has never been saved. A draft has no history to name or roll back to
422 UNPROCESSABLE_ENTITY Restore whose stored snapshot cannot be decoded, or cannot be re-encoded against the current schema. Nothing is written
429 RATE_LIMIT_EXCEEDED Global rate limiter. The house envelope; the retry seconds ride the Retry-After header — see Rate limiting
500 INTERNAL_SERVER_ERROR Snapshot decode failure on read; the document could not be opened; the pre-restore backup failed; the save failed; or the REST and collaboration processes holding different service-role keys, which the message names explicitly
503 SERVICE_UNAVAILABLE The collaboration process is unreachable or did not answer within 30 s. Only the checkpoint and the restore hop, so no read route can return this

DRAFT_DOCUMENT is specific to these routes. It shares the 422 status with invalid content, so a caller that only reads the status still behaves correctly. It also carries its own code, so one can be told from the other.

Internal version endpoints

POST /internal/documents/:documentId/versions and POST /internal/documents/:documentId/versions/:version/restore on the collaboration process's internal listener, beside the content apply endpoint and /metrics. Not Traefik-routed. REST forwards to them over HOCUSPOCUS_INTERNAL_URL with the same service-role bearer and the caller's x-request-id. Deploy hocuspocus-server before rest-api whenever this wire shape changes.

Document changes

Compares the newest stored snapshot at or before since with the newest at or before until, and reports the result per heading section. Read-only: nothing is written and no live document is loaded. Module: src/modules/document-changes/.

The one route is service-role only (Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>); a user JWT gets 401. That check runs ahead of validation, so a bad key with a malformed since answers 401 and never 400. The route addresses the document by documentId, never by slug — see Identifying a document.

It reads Postgres in the REST process and never opens the live document. Neither 413 nor 503 can occur: the route takes no body, and it never hops to the collaboration process.

The window bounds commit time

since and until bound the version row's createdAt. They never bound the version number. Both take an ISO 8601 instant carrying an offset, and Z counts as one. The accepted shape is YYYY-MM-DDTHH:MM:SS, then an optional fraction, then Z or ±HH:MM. Several forms a reader may call valid ISO 8601 are refused with a 400: a bare local timestamp 2026-08-01T00:00:00, a minute-precision 2026-08-01T00:00Z, an hour-only offset 2026-08-01T00:00:00+01, and the basic format 20260801T000000Z. In a query string a literal + decodes to a space, so a numeric offset must be percent-encoded as %2B01:00. until is optional and defaults to the moment the request is served, which is why the published OpenAPI carries no frozen date. Equal ends are allowed and take a fast path that reports no change. A since later than until is a caller error and answers 400, but only when you send until. With until omitted, a since in the future resolves both ends to the newest row and reports no change. That reads exactly like the equal-ends answer above, so a mis-computed future date looks like a quiet document.

createdAt is commit time, not edit time. A version row is inserted by the persistence worker, after a debounce of 10 s idle or 60 s maximum. So until=now can miss the last minute of typing. Day-scale windows are unaffected; minute-precision windows are illusory.

The anchor rule. Each end resolves to the newest row whose createdAt is at or before it — resolveAnchor in infra/changesStore.ts, ordered createdAt desc, version desc. So baseline and head echo the rows the comparison actually used. baseline.createdAt is usually older than the since you asked for. Report what the response's anchors say, never what your own query asked for.

A since older than every surviving row resolves to null, not to the oldest row. baseline comes back null, and the whole document is then reported as added. That is correct, not a fault. Nothing stored describes the document at that instant, so there is no earlier side to compare against. Retention thins autosave rows, so this is the ordinary answer for a window reaching back past DOC_AUTOSAVE_RETENTION_DAYS.

A null baseline also widens versions, triggers and contributors to the whole history. The row count then takes its lower bound at version 0. So it covers every version from 1 up to head.version, not only the versions inside the requested window. With a real baseline the bound is exact: it excludes the baseline row and includes the head row. The section counts are unaffected either way, because they come from the two snapshots alone.

GET /api/documents/:documentId/changes

Param Type Default Description
since ISO instant — (required) Anchors the baseline: the newest row at or before it. See the anchor rule
until ISO instant the request instant Anchors the head, the same way. Same shape as since
scope enum summary summary returns counts only; headings adds the sections tree
{
  "success": true,
  "data": {
    "documentId": "kR4pZ2mQ7tY1nB8xW3v",
    "since": "2026-08-01T00:00:00.000Z",
    "until": "2026-08-31T23:59:59.000Z",
    "baseline": { "version": 11, "createdAt": "2026-07-29T16:40:02.884Z" },
    "head": { "version": 18, "createdAt": "2026-08-30T09:14:51.220Z" },
    "changed": true,
    "summary": {
      "sectionsAdded": 1,
      "sectionsRemoved": 0,
      "sectionsModified": 2,
      "sectionsMoved": 0,
      "wordsAdded": 74,
      "wordsRemoved": 12,
      "versions": 7,
      "triggers": ["websocket", "checkpoint"],
      "contributors": [
        {
          "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
          "avatar_url": "https://example.test/ada.png",
          "avatar_updated_at": "2026-07-01T09:12:00.000Z",
          "full_name": "Ada Lovelace",
          "display_name": "Ada",
          "status": "online"
        }
      ]
    }
  }
}

The baseline above is older than the requested since. That is the anchor rule, not a bug.

Field Meaning
since The window as the server read it, echoed back
until The same, filled in with the request instant when the caller omitted it
baseline { version, createdAt } of the older row used, or null when no row is old enough
head { version, createdAt } of the newer row used, or null when no row exists at or before until
changed Whether any section is added, removed, modified, or moved
summary The rollup, described below
sections The outline tree. Present only for scope=headings
summary field Meaning
sectionsAdded / sectionsRemoved / sectionsModified / sectionsMoved How many sections carry that status, counted over the whole tree and not only its roots
wordsAdded / wordsRemoved The section magnitudes summed. A null magnitude contributes 0
versions How many rows were stored in the window, counted by version number
triggers The distinct non-null trigger values on those rows. A debounced save carries websocket
contributors Profiles for every account those rows name, the triggeredBy of each row included

contributors carries the same public.users columns the history sidebar renders. The field is named for contributors, and it holds every account the window's rows name, so it is a superset of the editors.

changed is derived from the section statuses, never from the bytes. A window that only spans the editor's first-open toc-id stamping pass has different bytes and no real edit, and reports changed: false.

summary is identical under both scopes. The rollup is computed before the scope branch, so scope=headings adds sections and changes nothing else. Both scopes run the same comparison. summary is not a cheaper mode.

Four windows come back with changed: false. Each one is a real answer, not an error. The sections column assumes scope=headings; under scope=summary the key is absent in every row.

Window What comes back
No row at or before until baseline and head both null, versions: 0, sections: []
Both ends land on the same row Both anchors set to that row, versions: 0, sections: []
The two rows hold identical bytes Both anchors set, a real versions count, sections: []. Neither side is decoded
Both rows decode, and every section matches Both anchors set, a real versions count, sections full of unchanged nodes

A named checkpoint of an unchanged document mints a row whose bytes duplicate its predecessor, which is the third case above.

The section tree

sections is absent on scope=summary and present on scope=headings. Present and empty is not the same answer as absent. An empty array has two causes. The comparison ran and found no sections, or one of the fast paths above skipped it.

{
  "tocId": "hK2p9QmTvB4nX7Lc",
  "text": "Rollout plan",
  "level": 2,
  "status": "modified",
  "magnitude": { "wordsAdded": 31, "wordsRemoved": 4, "blocksBefore": 5, "blocksAfter": 6 },
  "excerpt": "We move the beta group first and hold the rest for a week.",
  "children": []
}
Field Meaning
tocId The heading's stored toc-id, or null. Sanitized and cut at 200 characters
text The heading's text, sanitized and cut at 200 characters. "" on the preamble
level The heading level, clamped to 1–6. A non-numeric stored level reads as 1. 0 marks the preamble
status One of added, removed, modified, moved, unchanged — see Section status
magnitude { wordsAdded, wordsRemoved, blocksBefore, blocksAfter }, or null when nothing countable moved
excerpt Up to 140 characters of the text that arrived. The key is omitted when there is nothing to show
removedExcerpt Up to 140 characters of the text that left. A removed section puts its body here. Omitted when empty
runs Ordered same, removed, and added text around the edit. Omitted when empty
children Nested sections. Always an array, often empty

A section is one heading plus the top-level nodes up to the next heading of any level. So a section does not contain its subsections, and editing a child never marks its parent modified. The document title is a section like any other.

Nesting is by level, not by containment. A node becomes the child of the nearest preceding node with a strictly smaller level. A level-3 heading directly under a level-1 heading becomes its direct child; no level-2 node is invented. A leading level-3 heading followed by a level-1 heading gives two roots.

Nodes that sit before the first heading become one synthetic preamble. It carries level: 0, tocId: null and text: "". It is always a root sibling with no children, never the parent of the outline that follows. Only legacy documents that break the title-first rule carry one.

The tree is in head order. Each removed section is emitted just after the last head section that did pair, and removals with nothing before them come first. A removed node carries the baseline's level and text, so it can nest under a head section by that older level.

excerpt is not verbatim. Whitespace collapses to single spaces, C0 and C1 control characters are stripped, and the result is cut at 140 characters. Both caps exist because this text is meant for an email body, where a newline forges a whole entry. The Change digest paints runs when they exist. It paints excerpt or removedExcerpt when they do not.

Section status

status When magnitude
added The section has no baseline side Every word and block in it, counted as added
removed The section has no head side Every word and block in it, counted as removed. The body is removedExcerpt, not excerpt
modified Both sides exist and their canonical forms differ Word deltas plus the block count on each side, or null — see below
moved Both sides exist, the canonical forms are equal, and the shared toc-id sits outside one common subsequence Always null
unchanged Both sides exist and their canonical forms are equal Always null. An unchanged pair is never measured

The canonical form covers the heading node and the body together. So a heading level change from 2 to 3 stays visible, even when both node lists are otherwise identical. It drops the toc-id attribute, because the editor stamps that on first open and it is churn, not an edit.

On added and removed the heading's own words and its own node count are included. A one-word heading over a three-word paragraph reports wordsAdded: 4 and blocksAfter: 2.

The excerpt on a modified section is the single widest inserted run, not every insertion. On an added section it is the section body, without the heading. Both are then cut at 140 characters.

How sections are paired

Identity is resolved in four steps, in this order.

  1. The preamble, by position. When both sides open with a level: 0 preamble, those two pair. A preamble carries no toc-id and no name, so position is its only identity.
  2. By toc-id. Both sides must carry one. Inside each snapshot the first occurrence wins, so a duplicate id falls through to the next step. A stable id beats position. A reorder that keeps the text and the toc-id is moved. A toc-id rewrite that keeps the canonical form stays unchanged.
  3. By longest common subsequence, as unchanged. The key is the canonical hash of the whole section. An unstamped baseline section still matches a freshly stamped head section here, because the canonical form drops toc-id. That is what stops the editor's first-open pass reading as a whole new document.
  4. By longest common subsequence, as modified. A leftover removal is zipped with a leftover addition under two conditions. The two levels must be equal, and at least one of the two ids must be null.

That last condition is deliberate. Two sections that both carry a toc-id and still did not match are two different sections. Merging them would hide a deletion and call a new section an edit. So a rename that also mints a new id reports one removed plus one added, never one modified. A heading whose text and level both changed comes out the same way, because the levels have to match for the zip.

Every section is keyed with the same node type, so an adjacent removal and addition can reach the zip at all. Unbalanced runs keep their leftovers: extra removals stay removed, and extra additions stay added.

There is no coarse field on this response, unlike the versions diff. The matcher's fallback needs about 1000 unpaired headings on each side, so no real document reaches it. Stop looking for the field.

What this route does not tell you

A formatting-only edit reports magnitude: null. The default token encoder reads neither marks nor attributes. Bold on existing words, a changed link address, and a heading level change all measure zero. The status still says modified: the edit happened, and only its size is unknown. A measurement that throws also reports magnitude: null, and the response cannot tell the two apart.

Word counting is whitespace splitting. It is not language-aware, so a script that does not separate words with spaces counts as one word.

A parent's magnitude says nothing about its subtree. Sections own no descendants, so a child edit leaves the parent unchanged.

A pure reorder of stamped sections reports changed: false. The moved sections pair by toc-id and compare equal, so every status is unchanged. scope=headings still shows the new order, which is where a caller can see the move. Reordering sections that carry no toc-id is different: those fall to the matcher and can come back as removed plus added.

A caller that rotates every toc-id defeats pairing. Nothing then matches by id, everything falls to the matcher, and an edited section reads removed plus added rather than modified. No editor produces this, because the webapp keeps its ids. A REST caller can, because PATCH /content stores a caller-supplied toc-id verbatim. The result is coarser, never false: the section is still reported as changed, and no edit is attributed to a section nobody touched.

A toc-id rewrite on its own is unchanged. The canonical form drops the attribute, so re-anchoring a heading is not an edit.

Attribution is decoration. It is per-replica and best-effort, anonymous editors are omitted, and retention thinning under-reports contributors on windows longer than the autosave retention. A profile-lookup outage empties contributors rather than failing the request. The content comparison stays correct in every one of those cases.

A document only reaches followers once a signed-in person has opened it at least once. The audience for a content-change notification is workspace membership, and only a signed-in browser open creates the workspace row. So a document created through this API, which nobody has opened while signed in, notifies nobody — the owner included. This is a recorded gap, not a fault. Closing it would put a write into a read path and would record a first visitor who never visited. See issue #203.

The echoed tocId is sanitized, so it is not guaranteed to equal the stored attribute. Whitespace collapses, control characters go, and the value is cut at 200 characters. A server-stamped id is 16 alphanumeric characters, which the sanitizer never alters, so pairing stays consistent. A stranger-written id of another shape can come back changed, and a deep link built from it then resolves to nothing.

Status codes

Status Code When
400 VALIDATION_ERROR Malformed documentId; a since or until that is not an ISO instant with an offset; a missing since; a since later than until, checked only when until is sent; a scope other than summary or headings
401 UNAUTHORIZED Missing, wrong, or user-JWT bearer; also when SUPABASE_SERVICE_ROLE_KEY is unset (fails closed). Checked before validation
404 NOT_FOUND Told apart by the message: there is no metadata row; the document is soft-deleted; or retention removed a version this window anchors on while the request ran
429 RATE_LIMIT_EXCEEDED Global rate limiter. The house envelope; the retry seconds ride the Retry-After header — see Rate limiting
500 INTERNAL_SERVER_ERROR A stored snapshot on either side could not be decoded. Nothing partial is returned

Only the retention case is worth retrying: the document exists, and only the anchor row went. A missing metadata row and a soft-deleted document are both permanent, so retrying either just repeats the 404.

Document conversion

Turn a document into a file, or a file into document content. Module: src/modules/document-conversion/.

Direction Formats Route
Export docx, md, odt GET /api/documents/:documentId/export?format=…
Import docx, md POST /api/documents/:documentId/import

The asymmetry is deliberate: nothing reads ODT back, and a legacy .doc is refused rather than guessed at.

Both routes take either the service-role key (Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>) or a Supabase user token (token: <jwt>, or the same JWT as a bearer). They address the document by documentId, never by slug — see Identifying a document.

A token is required whatever the document's privacy. Both routes fall back to requireUser, not optionalUser, so a call with no credential is a 401 even on a public document. That is the rule a browser caller gets wrong most often. Send the Supabase access token in the token header on every request, public documents included.

Access matrix. The service-role key passes every row. A user token runs the same private-document predicate as the WebSocket gate and GET /api/documents/:slug (resolvePrivateAccess in src/lib/privateAccess.ts) — one rule, three surfaces.

Document GET /export POST /import
Public, unlocked any signed-in caller any signed-in caller
Private, caller owns it 200 200
Private, caller signed in as someone else 403 FORBIDDEN 403 FORBIDDEN
Private, caller anonymous or ownerId null 401 UNAUTHORIZED 401 UNAUTHORIZED
Public and readOnly, caller does not own it 200 — the lock is write-only 403 FORBIDDEN

The privacy rule runs first, so a document that is both private and locked answers with the privacy outcome. Import gates on write access because a locked document's readers can never apply what it returns, and the conversion costs real CPU either way. The readOnly rule is the one the WebSocket handshake enforces: the owner keeps edit rights, everyone else is refused.

Import writes no content. It returns Tiptap JSON and leaves the document untouched. The caller applies it with PATCH /content, which enforces the read-only lock on the write itself. Import enforces it on admission. It is not side-effect free, though: images embedded in the uploaded file are re-hosted to object storage before the JSON returns. So a caller that discards the response leaves those objects behind. Read the fidelity contract before converting anything you cannot re-create.

GET /api/documents/:documentId/export

Renders the document to a file. Query: format = docx (default), md, or odt.

The 200 body is the raw file, not the house envelope; errors still carry it. Content-Disposition is attachment with the slugified title. It falls back to the slug and then the documentId, so a title that slugifies to nothing — all emoji — cannot produce a bare .docx.

Staleness. Export reads the persisted head, the same snapshot GET /content serves, so it trails an actively edited document by the store debounce — 10 s idle, 60 s maximum.

A document whose metadata exists but has no snapshot yet exports as an empty file, not a 404.

POST /api/documents/:documentId/import

Body is multipart/form-data with field name documentFile (not file, and not the media route's mediaFile). There is no raw-body variant: one field means one size cap and one format check for every upload.

The container decides the format, not the filename or the declared MIME type. Both lie too often to trust:

First bytes Read as
50 4B 03 04 .docx (zip)
D0 CF 11 E0 legacy OLE2 .doc → 415, re-save as .docx
anything else Markdown, if it decodes as strict UTF-8 — otherwise 422

The strict decode is Markdown's real gate. A BOM is stripped, so a Windows-written .md keeps its opening # instead of importing as one long paragraph.

Any non-.docx zip lands on the Word error. ODT is a zip, so re-importing your own ODT export returns 422 The upload could not be read as a Word document. That is the sniff working, not a mis-detected file — ODT import does not exist.

Three different 413s. Any upload over 10 MiB (MAX_IMPORT_BYTES, matching the pad's media cap) is refused on size. A .docx whose ZIP directory declares more than 40 MiB unpacked is refused before the converter opens it. A zip bounds its compressed size, never its inflated one, and 200 KiB of deflated padding reaches 200 MiB. Markdown is additionally capped at 65 536 characters, because marked parses in quadratic time — 64 KiB takes ~0.6 s, 954 KiB blocks the event loop for ~144 s. That cap rejects; it never truncates, since half a document is worse than a refused one. Convert the file to .docx to get past it.

Images embedded in a Word file are re-hosted through the media route so the result carries URLs the editor can parse instead of data: payloads. This needs PUBLIC_RESTAPI_URL — see ENV.md. Without it, every image becomes a warning and the text still imports.

A lone media URL becomes a player node. A paragraph that is only [url](url) or an autolink, and whose href is a video, audio, or embed host, becomes that media node. A provider address written as an image — ![youtube](https://youtu.be/…), and the same for vimeo, video, audio, and the other embed hosts — is lifted out of its paragraph so PATCH /content accepts the JSON. A labeled link, a filter link, a media URL next to other text, and a media URL inside a list item stay links. Picture width and height stay empty on this JSON. Settings replace writes natural picture size in the browser.

{
  "success": true,
  "data": {
    "content": { "type": "doc", "content": [/* ... */] },
    "title": "Quarterly report",
    "warnings": [{ "code": "title-promoted-paragraph", "message": "…" }]
  }
}

The result always opens on a level-1 heading, so it satisfies PATCH's title-first rule. An existing first heading is forced to level 1. A first paragraph is promoted to one (losing its marks). A document with no usable opening text gets a heading synthesized from the slug. Each of the last two reports a warning — a paragraph silently becoming the document title is a change the caller has to see.

Fidelity contract

Conversion is lossy in named ways. Import reports what it changed through warnings. Export has no warning channel — a downloaded file is the only evidence — so the table below is the contract.

What export drops

Content DOCX Markdown ODT
The eight media embeds (youtube, vimeo, loom, x, spotify, soundcloud, video, audio) link to the source link to the source link to the source
Images picture — own origin, PNG/JPEG only ![alt](src) link to the source
Highlight dropped ==text== kept
In-progress upload placeholders removed removed removed

DOCX is the one format whose converter fetches: it downloads every <img src> to embed the bytes. src is ordinary pad content, so only images on this server's own media origin (PUBLIC_RESTAPI_URL) are kept. A third-party or unreachable one is dropped rather than fetched, and with no PUBLIC_RESTAPI_URL set no image is embedded at all. An embed becomes a paragraph holding its caption, or its URL when it has no caption. ODF draws a picture through a measured frame. A remote image has no measured width, so ODT gives the reader a link it can follow rather than an empty box.

The origin filter is not the last gate. A second filter sniffs the fetched bytes and keeps only PNG and JPEG, so a same-origin GIF, WebP or SVG is dropped as well. A redirect is refused rather than followed, because following one leaves the origin already checked. The run is budgeted at 128 images, 8 MiB inlined, 5 s per image and 30 s in total. Anything past the budget is removed. Both filters are security boundaries, not gaps. The origin check stops SSRF. The byte sniff keeps crafted headers away from the bundled image-size parsers, which loop forever on them.

What a DOCX round trip loses. Export to .docx and import it back, and the following do not survive: underline, blockquote, code block, horizontal rules, and table header cells. Blockquote and code block each flatten to a paragraph, and table header cells demote to normal cells. Word's own Title, Subtitle and Quote styles are mapped on the way in. So a file authored in Word fares better than one that made this round trip.

Markdown keeps the media and never its placement. The two shapes are in the table above: an image exports as ![alt](src), an embed as a link. Everything else the node holds is dropped — width, height, float, margin, display, clear, and provider flags such as SoundCloud's visual. An image also loses its title and its caption. An embed's caption survives, as the link text. Import of a lone embed or media [url](url) becomes the player again. It does not restore width, height, or caption. Settings replace writes natural picture size. Export to .docx, or apply Tiptap JSON through PATCH /api/documents/:documentId/content, when placement matters.

No export format carries toc-id. That id is the identity a heading's chat channel, fold state and ?id= deep link hang off. Imported content has none, so applying it with mode=replace re-keys every heading in the document and orphans all three, permanently. Re-editing does not bring them back. Default to mode=append and reserve replace for a document whose heading identities nobody depends on yet. An appended import brings its own title heading with it, and the title-first check only looks at the document's first node. Drop or demote content[0] unless you want a second title heading in the middle of the document.

Warning codes

An empty warnings array means a clean import.

Code Meaning
media-placeholder-dropped One image could not be re-hosted and was dropped; the rest of the file imported
title-promoted-paragraph The first paragraph became the document title
title-synthesized No usable opening text, so the title came from the slug
unsupported-element The converter met something it does not model

Status codes

Status Code When
400 VALIDATION_ERROR Malformed documentId, unknown format, a body that is not multipart/form-data, or no documentFile field
401 UNAUTHORIZED Missing, wrong, or user-JWT bearer; also when SUPABASE_SERVICE_ROLE_KEY is unset (fails closed)
404 NOT_FOUND No metadata row, or the document is soft-deleted
413 PAYLOAD_TOO_LARGE Upload over 10 MiB, a .docx declaring over 40 MiB unpacked, or Markdown over 65 536 characters
415 UNSUPPORTED_MEDIA_TYPE Import of a legacy OLE2 .doc
422 UNPROCESSABLE_ENTITY Import of a file that is neither a zip nor UTF-8 text, or one too damaged for its converter to open. Retrying will not fix it
429 RATE_LIMIT_EXCEEDED Global rate limiter. The house envelope; the retry seconds ride the Retry-After header — see Rate limiting
500 INTERNAL_SERVER_ERROR Export only: the stored snapshot could not be decoded, or the converter threw
503 AUTH_UNAVAILABLE Supabase token verification was unreachable

Media

Base path /api/plugins/hypermultimedia (src/api/routers/hypermultimedia.router.ts). Backs the editor's hypermultimedia extension. Storage targets local disk when PERSIST_TO_LOCAL_STORAGE=true, otherwise S3-compatible (DigitalOcean Spaces).

POST /api/plugins/hypermultimedia/:documentId

Upload one media file. Body is multipart/form-data with field name mediaFile (not file).

Allowed MIME types, 34 in all (source of truth: src/schemas/hypermultimedia.schema.ts ALLOWED_MIME_TYPES): image/jpeg, image/jpg, image/png, image/gif, image/webp, image/bmp, image/heic, image/heif, image/svg+xml, video/mp4, video/webm, video/quicktime, video/ogg, video/x-matroska, audio/mpeg, audio/webm, audio/wav, audio/ogg, audio/mp4, audio/aac, audio/flac, audio/opus, application/pdf, text/plain, text/csv, text/markdown, application/json, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/zip. The list is a superset of the chat media allowlist, so any chat attachment can be re-hosted into a document. Max size is DO_STORAGE_MAX_FILE_SIZE (default 10 MB; see ENV.md).

Returns 201. Shape depends on the backend (type: "s3" or local), including fileType, fileName, and fileAddress. Oversized files return 413 (PAYLOAD_TOO_LARGE); disallowed types return 415 (UNSUPPORTED_MEDIA_TYPE).

A missing or invalid token is 401. A body more than 1 MiB over that cap is refused with 413 before the handler runs. The document row is then checked before the file's bytes are examined: a missing or soft-deleted document is 404. A private document the caller does not own is 403, carrying the same top-level access hint the slug read returns. A readOnly document the caller does not own is 403 too.

GET /api/plugins/hypermultimedia/:documentId/:mediaId

Stream a media file with its Content-Type. This read is charged to the larger media budget, not the global one — see Rate limiting.

Link metadata

Base path /api/metadata (self-contained module at src/modules/link-metadata). Unfurls a URL through a cache → oEmbed → special-handler → HTML-scrape pipeline with SSRF protection.

GET /api/metadata?url=<http(s) url>

url is required, must be http(s), and ≤ 2048 chars. Honors Accept-Language.

Success (200) returns the MetadataResponse contract from src/modules/link-metadata/domain/types.ts — success: true, url, requested_url, title, optional description/image/author/publisher/oembed/etc., plus cached and fetched_at. Responses set Cache-Control (positive cache for hits, short negative cache for fallbacks) and Vary: Accept-Language.

Errors (400) use this module's own shape — top-level code and message, not the nested envelope:

{ "success": false, "code": "INVALID_URL", "message": "..." }

code is INVALID_URL or BLOCKED_URL (SSRF guard).

Email

Base path /api/email (src/api/email.ts). Notification delivery runs through a pgmq consumer, not HTTP: email_queue → pg_cron → pgmq → worker → BullMQ → SMTP. The /api/email/send endpoint was removed. Most endpoints below are internal triggers and webhooks. send-generic, send-digest, bounce and preview/:type require the service-role key. health, status, validate, and both unsubscribe routes need no credential. A rejected JSON or query body on send-generic, send-digest, bounce, validate, and POST /unsubscribe is the house envelope (VALIDATION_ERROR), not a raw Zod body.

POST /api/email/send-generic

Send one email directly. Body: to, subject, html, optional text, replyTo (validated by sendGenericEmailSchema). Returns { "success": true, "message_id": "..." }.

POST /api/email/send-digest

Send a daily/weekly digest. Body: to, frequency, documents[], optional user_name (sendDigestEmailSchema).

POST /api/email/bounce

Record a provider bounce event. Body: email, bounce_type, optional provider, reason. Hard bounces auto-suppress the user; returns { "success": true, "bounce_id": ..., "auto_suppressed": <bool> }.

GET /api/email/health

Email gateway health (no auth).

GET /api/email/status

{ "operational": <bool>, "timestamp": "..." } (no auth).

POST /api/email/validate

Public magic-link check. Body: { "email": "<string>" }. Returns { "isValid": <bool> } on HTTP 200 for both results. Uses the house regex, then an MX lookup. Does not return MX records or DNS errors. Success is not wrapped in ok(). A missing or non-string email is the house envelope (VALIDATION_ERROR on 400).

GET /api/email/preview/:type

Render a template (notification or digest) with sample data, as HTML. Service-role only.

GET /api/email/unsubscribe?token=

One-click unsubscribe from an email link. No auth (the token is the credential). Verifies the token in lib/unsubscribeToken.ts, then applies the change through the apply_unsubscribe Supabase RPC, and returns an HTML confirmation page.

POST /api/email/unsubscribe?token=

RFC 8058 List-Unsubscribe-Post handler for mail clients. Returns JSON ({ "success": true } or an { "error": ... } body).

Admin

Base path /api/admin (src/api/routers/admin.router.ts). Every route requires a valid Supabase JWT for a user present in admin_users. The tables below list the routes the dashboard uses most, grouped as in the router. GET /openapi.json is the complete list.

Two audit routes are easy to misread. /audit/media-storage and /audit/media-storage/summary report the Supabase chat media bucket, not editor media.

Dashboard & users

Method Path Purpose
GET /stats Dashboard overview stats
GET /users/document-counts Document count per user
GET /users/admins All admin user IDs
POST /users/:id/toggle-admin Grant or revoke admin

Documents

Method Path Purpose
GET /documents List documents (paginated)
GET /documents/stats Document statistics
PATCH /documents/:id Update document flags
GET /documents/:id/deletion-impact Preview deletion cascade
DELETE /documents/:id Delete (requires confirmSlug in body)
GET /documents/:slug/views View stats for one document
GET /documents/:slug/preview Content preview

View analytics

Method Path Purpose
GET /stats/views Overall view summary
GET /stats/views/top Top viewed documents
GET /stats/views/trend View trend series
GET /stats/views/batch-trends Per-document sparkline trends

Retention & activity

Method Path Purpose
GET /stats/retention DAU/WAU/MAU
GET /stats/user-lifecycle Lifecycle segments
GET /stats/dau-trend Daily active users trend
GET /stats/activity-heatmap Activity by hour
GET /stats/top-active-documents Most active by message count
GET /stats/communication Communication stats
GET /stats/notification-reach Notification delivery stats

Stale documents audit

Method Path Purpose
GET /documents/stale/summary Stale summary
GET /documents/stale List stale documents
POST /documents/stale/bulk-delete Bulk delete stale

Notification audit

Method Path Purpose
GET /audit/notifications/health Combined health score
GET /audit/notifications/push-failures Push failure breakdown
GET /audit/notifications/email-failures Email failure/bounce breakdown
GET /audit/notifications/failed-subscriptions Failed push subscriptions
GET /audit/notifications/email-bounces Bounce list
POST /audit/notifications/disable-failed Disable dead subscriptions
GET /audit/notifications/dlq BullMQ dead-letter contents

Ghost accounts audit

Method Path Purpose
GET /audit/ghost-accounts List ghost accounts
GET /audit/ghost-accounts/summary Category summary
GET /audit/ghost-accounts/:id/impact FK dependency check
DELETE /audit/ghost-accounts/:id Smart-delete one
POST /audit/ghost-accounts/bulk-delete Bulk delete (max 50)
POST /audit/ghost-accounts/resend-confirmation Resend magic link
POST /audit/ghost-accounts/cleanup-anonymous Clean stale anon sessions

Push notifications

There is no HTTP push endpoint — /api/push was removed. Push delivery runs through pgmq the same way email does. A Supabase trigger enqueues to pgmq, and the worker polls it and delivers via BullMQ and the Web Push API. Devices register through Supabase RPCs:

await supabase.rpc('register_push_subscription', {
  p_device_id: 'unique-device-id',
  p_device_name: 'Chrome on MacBook',
  p_platform: 'web',
  p_push_credentials: { endpoint: '...', keys: { p256dh: '...', auth: '...' } }
})

await supabase.rpc('unregister_push_subscription', { p_device_id: 'unique-device-id' })

GET /health/push reports gateway status.

Rate limiting

Two Redis-backed limiters (src/middleware/index.ts) cover every non-OPTIONS request except /health and /health/*. Both are keyed on client IP alone, and when Redis is unavailable, rate limiting is disabled (requests pass). User-Agent is not part of the key: a client that varied that header earned a fresh budget on every request. Requests with no x-forwarded-for and no x-real-ip (direct/internal traffic) skip both.

The global limit is RATE_LIMIT_MAX requests (default 100) per 15-minute window. Production runs 500. There is no separate per-role tier in the REST middleware, so a service-role caller shares the same per-IP budget as a browser. Issue #166 tracks whether that should change.

Public media reads have their own, larger budget. A GET under /api/plugins/hypermultimedia/ is charged to a second limiter, which holds ten times the global points over its own 15-minute window. So a pad holding 20 images does not spend 20 points of the global budget on a cold load. The upload POST on that path stays on the global budget, as does every other route. The multiplier is a constant in the middleware, not an environment variable.

Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (ISO timestamp). A media read reports its own limiter's numbers in those headers. On 429, the body is the house envelope with code RATE_LIMIT_EXCEEDED, and a Retry-After header carries the seconds until the window resets.

WebSocket API

The collaboration server (src/hocuspocus.server.ts, default port 4001) speaks the Hocuspocus protocol over Y.js. Connections authenticate with a JWT passed in the Hocuspocus token field (JSON with accessToken, slug, deviceType).

import { HocuspocusProvider } from '@hocuspocus/provider'

const provider = new HocuspocusProvider({
  url: 'ws://localhost:4001',
  name: 'document-slug',
  token: JSON.stringify({ accessToken, slug, deviceType: 'desktop' })
})

The document name is the room id (Prisma documentId); the server never trusts a client-supplied id in the token for authorization.

Private documents

Breaking change (shipped): isPrivate now allows the owner only (user.sub === ownerId). Anonymous and signed-in non-owners are rejected — previously any signed-in (non-anonymous) user could connect. Ownerless-private rooms (ownerId: null) reject everyone until owner backfill. When the metadata lookup fails, the server can no longer determine privacy, so it fails closed (rejects) instead of admitting the connection as public. A successful public-doc lookup still connects without auth. The decision is a pure resolver (src/lib/wsAccess.ts) with a matrix unit test.

Read-only: When readOnly is true and the connector is not the owner, the connection is marked read-only on the write path (existing behavior).

See the Hocuspocus documentation for the wire protocol.