---
title: "Server routes and authentication"
description: "How every editor route authenticates through the identity port, the full route table with status codes, and websocket authentication."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/routes-and-auth"
---
# 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](#auth-notes)).

## Routes

<table>
<thead>
  <tr>
    <th>
      Method
    </th>
    
    <th>
      Path
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

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

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.

```ts
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`:

<table>
<thead>
  <tr>
    <th>
      Request
    </th>
    
    <th>
      Status
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        GET
      </code>
      
      /<code>
        PUT
      </code>
      
       without a user
    </td>
    
    <td>
      401
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        GET
      </code>
      
      , no <code>
        settings
      </code>
      
       port
    </td>
    
    <td>
      404 (route not mounted; client stays on <code>
        localStorage
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        GET
      </code>
      
      , nothing stored
    </td>
    
    <td>
      200 with body <code>
        null
      </code>
      
       (client uploads its local settings)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        GET
      </code>
      
      , stored
    </td>
    
    <td>
      200, <code>
        EditorSettings
      </code>
      
       normalized by <code>
        parseEditorSettings
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        PUT
      </code>
      
      , no <code>
        settings
      </code>
      
       port
    </td>
    
    <td>
      404 (route not mounted)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        PUT
      </code>
      
      , body is no complete valid <code>
        EditorSettings
      </code>
    </td>
    
    <td>
      400
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        PUT
      </code>
      
      , ok
    </td>
    
    <td>
      204
    </td>
  </tr>
</tbody>
</table>

## 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.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
