Guides

Server routes and authentication

How every editor route authenticates through the identity port, the full route table with status codes, and websocket authentication.

Every route authenticates through ports.identity.authenticate; the package guards its own routes and expects no host middleware (the demo's session middleware only makes sure a session exists; it is not what protects the routes). A null user answers 401 „Nicht angemeldet“ on API routes. The websocket upgrade of /collab/:id succeeds first by default, and a socket whose user is null is closed with code 4401 right after; with the module option collab.authenticateUpgrade the upgrade itself is refused with HTTP 401 (see Auth notes).

Routes

MethodPathPurpose
WS/collab/:idHocuspocus sync; the document name comes from the provider's name. Yjs state is the source of truth; JSON is mirrored via storeContent.
GET/api/ai/modelsModel presets without keys + default Model roles (not rate limited)
POST/api/ai/completeGhost text: { before, model } → { text }
POST/api/ai/generateTask run → SSE delta / done / error
POST/api/ai/chatDocument chat/agent → SSE delta, tool (a read-only tool call the server ran), ops, done, error
POST/api/ai/transcript-cleanup{ segments, model } → { cleaned, failed }
POST/api/ai/extract{ documentId, name, method, model, bibliography?, doi?, maxPages? } → { method, markdown, segments?, bib?, model?, inputHash, outputHash, output, warnings } (model: the speech-to-text model of a transcription). Turns a stored asset into readable Markdown: method auto picks by MIME type (audio/video → transcription with segment timestamps, via ai.transcription, 501 without it, 413 over 25 MB; image → description; PDF → pdf-text, or ocr when the text layer averages under 50 characters per page; anything else 415), bibliography reads a CSL item from Crossref by DOI (body, or found in the first two pages) and falls back to the model (warnings: ['crossref-unavailable']). ocr and description use the request's model and need a vision-capable one. OCR makes one model call per page, so it reads at most 50 pages (fewer when maxPages is lower); skipped pages add warnings: ['ocr-truncated']. PDF reading uses pdfjs-dist; page rasterisation for OCR uses the optional dependency @napi-rs/canvas (without it a PDF with a text layer answers warnings: ['ocr-unavailable'], a scan answers 501). Aborting the request stops the page loops between pages. In markdown every PDF page after the first starts with a <!-- page N --> line; the client turns it into a page label paragraph and the stored raw output keeps it. The raw Markdown is stored as a text/markdown asset (output is its name, null for bibliography); inputHash is the SHA-256 hex of the asset bytes, outputHash the SHA-256 hex of that stored raw output (the client hashes its own re-serialised body for edit detection and ignores it).
GET/api/editor/users?q=@ popup: up to 20 users
GET/api/editor/capabilitiesThe capabilities this server serves, as BackendCapability[] (serverCapabilities(ports), with ai and collaboration as really mounted: ai iff the AI routes are, collaboration iff the websocket is). Always mounted; the client's probe reads it
GET/api/editor/tags?q=# popup: up to 20 tags in use
GET / PUT/api/editor/settingsPer-user settings (table below)
POST/api/editor/assets/:idMultipart file → { url, name }; 413 above MAX_MEDIA_BYTES
GET/api/editor/assets/:id/:nameServes an asset with its stored Content-Type, single byte-range support; 404 when unknown
GET/api/editor/images/prompt-blocksThe prompt blocks of the host as ImageStudioPromptBlock[]; only mounted with an images port
POST/api/editor/images/generate{ description, instruction, promptBlockIds, referenceUrls } → the generated ImageStudioImage; 400 for a malformed body; only mounted with an images port
POST/api/editor/images/uploadMultipart documentId (string) and file (an image) → the stored ImageStudioImage; 400 for a malformed id or a missing file, 413 above MAX_MEDIA_BYTES.image, 415 for a file that is no image; only mounted with an images port
GET/api/editor/themesThe theme library as ThemeRecord[]; only mounted with a themes port
GET/api/editor/themes/:idThe theme's stylesheet as text/css; 400 for a malformed id, 404 when unknown; only mounted with a themes port
POST/api/editor/themesMultipart css (file, required), manifest (file, optional Obsidian manifest.json), name (string, optional) → 201 with the new ThemeRecord (builtin: false). The name is manifest.name, else name, else the CSS file's base name; the id is its slug (a-z0-9 and -, at most 64 characters), made unique with -2, -3 … against the library. Limits: CSS up to MAX_THEME_BYTES (2 MB, 413 „Theme ist größer als 2 MB“), manifest up to 64 KB and parseable by themeManifestSchema (400 „Ungültiges manifest.json“); 400 for a missing or empty css, for content that is not UTF-8 text (invalid UTF-8, NUL bytes) and for markup (<script or <?xml in the first 512 bytes); only mounted with a themes port
DELETE/api/editor/themes/:idDeletes an uploaded theme (204); 400 for a malformed id, 404 when unknown, 403 „Mitgelieferte Themes können nicht gelöscht werden“ for a builtin; only mounted with a themes port

The AI routes are rate limited per user (sliding window, in memory, per process): exceeding it answers 429 „Zu viele KI-Anfragen, bitte kurz warten“ with Retry-After.

Filtering the mounted routes

installEditorServer mounts every route of serverRoutesOf(ports, { ai }) whose port is present. A host that serves some of these paths itself (for example /api/editor/assets/* from its own asset studio) passes routes, a function from the route table to the routes that are mounted; it can drop or reorder routes. The RouteDef type (method, path, port, handler) is exported from @skaile/forge-editor/server/nitro. The websocket is not part of the table; the default keeps every route.

installEditorServer(nitroApp, {
  ports,
  collab: false,
  routes: routes => routes.filter(route => !route.path.startsWith('/api/editor/assets')),
})

A host that drops the asset routes answers GET /api/editor/assets/:id/:name itself (media elements request that URL) and gives the client a backend whose assets.upload returns the URL of its own route.

Settings route status codes

/api/editor/settings:

RequestStatus
GET/PUT without a user401
GET, no settings port404 (route not mounted; client stays on localStorage)
GET, nothing stored200 with body null (client uploads its local settings)
GET, stored200, EditorSettings normalized by parseEditorSettings
PUT, no settings port404 (route not mounted)
PUT, body is no complete valid EditorSettings400
PUT, ok204

Auth notes

  • Websocket authentication (default). The upgrade to /collab/:id always succeeds (HTTP 101). Hocuspocus then authenticates the connection from the upgrade request's headers, so the session cookie authenticates the socket like any same-origin request, and from the client's token: clients that cannot send cookies pass token to the collaboration prop (a string or an async function; it becomes the HocuspocusProvider token) and authenticate receives it as request.token next to the headers. A client without a user gets a permission-denied answer and its socket is closed with code 4401 (not an HTTP 401).
  • Rejecting at the upgrade (collab.authenticateUpgrade: true; Nuxt: the module option, installEditorServer: collab: { authenticateUpgrade: true }). The route calls authenticate({ headers }) (no token) before the upgrade; a null user answers HTTP 401 and no websocket is opened, otherwise the user is passed to the connection and not authenticated a second time. Use it when every client authenticates by cookie or other request headers; a client that identifies itself only by collaboration.token cannot connect then, because the token is only sent after the socket is open. authorize still runs per document in both modes.
  • Document-level authorization (who may open which document) is the host's decision: authenticate returns the user, and authorize(user, id) decides per document on connect (null rejects, { readOnly: true } makes the connection read-only). Without authorize the websocket route passes any authenticated user to Hocuspocus for any document name.
  • Asset URLs are guarded too; media elements send the session cookie.
Copyright © 2026