Server routes and 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
| Method | Path | Purpose |
|---|---|---|
| WS | /collab/:id | Hocuspocus 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/models | Model presets without keys + default Model roles (not rate limited) |
| POST | /api/ai/complete | Ghost text: { before, model } → { text } |
| POST | /api/ai/generate | Task run → SSE delta / done / error |
| POST | /api/ai/chat | Document 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/capabilities | The 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/settings | Per-user settings (table below) |
| POST | /api/editor/assets/:id | Multipart file → { url, name }; 413 above MAX_MEDIA_BYTES |
| GET | /api/editor/assets/:id/:name | Serves an asset with its stored Content-Type, single byte-range support; 404 when unknown |
| GET | /api/editor/images/prompt-blocks | The 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/upload | Multipart 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/themes | The theme library as ThemeRecord[]; only mounted with a themes port |
| GET | /api/editor/themes/:id | The theme's stylesheet as text/css; 400 for a malformed id, 404 when unknown; only mounted with a themes port |
| POST | /api/editor/themes | Multipart 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/:id | Deletes 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:
| Request | Status |
|---|---|
GET/PUT without a user | 401 |
GET, no settings port | 404 (route not mounted; client stays on localStorage) |
GET, nothing stored | 200 with body null (client uploads its local settings) |
GET, stored | 200, EditorSettings normalized by parseEditorSettings |
PUT, no settings port | 404 (route not mounted) |
PUT, body is no complete valid EditorSettings | 400 |
PUT, ok | 204 |
Auth notes
- Websocket authentication (default). The upgrade to
/collab/:idalways 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'stoken: clients that cannot send cookies passtokento thecollaborationprop (a string or an async function; it becomes theHocuspocusProvidertoken) andauthenticatereceives it asrequest.tokennext 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 callsauthenticate({ headers })(notoken) before the upgrade; anulluser 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 bycollaboration.tokencannot connect then, because the token is only sent after the socket is open.authorizestill runs per document in both modes. - Document-level authorization (who may open which document) is the host's decision:
authenticatereturns the user, andauthorize(user, id)decides per document on connect (nullrejects,{ readOnly: true }makes the connection read-only). Withoutauthorizethe websocket route passes any authenticated user to Hocuspocus for any document name. - Asset URLs are guarded too; media elements send the session cookie.
Edges: backlinks and authored connections
Which Blocks point at a Block: the Edges the Block index projects from references, Derivations, embeds and tags, the ones a person adds in the inspector, the edges route and the drizzle table.
Speech: text to audio, and subtitle files
The SpeechProvider port with its engines and voices, the SSE route that streams progress and stores the audio as an asset, the daily character budget, the client call, and the block action that exports a transcript as WebVTT or SubRip.