Guides

EditorPorts: auth and storage

The single seam between the package and the host: ports for identity, documents, Yjs state, assets, settings, themes and AI models.

Who owns what

The package owns the routes, the Hocuspocus server, rate limiting and the wire formats; the host owns identity (sessions, sign-in, which user may open which document) and storage (where documents, Yjs state, assets, settings and themes live). Both enter through the one adapter below. The package never reads cookies or touches files itself; authenticate returns the user (or null), the storage methods persist what they are given. In this repository server/plugins/editor-adapter.ts wires the adapter to nuxt-auth-utils sessions and the file system under .data/. The app has no login: server/middleware/demo-session.ts signs every /api/ request whose session is not one of the demo users anna, ben or bernd (shared/utils/demo-users.ts) in as anna, and POST /api/auth/switch (server/api/auth/switch.post.ts) switches the session to another demo user. Both are host code; the package only sees what authenticate returns.

Registering the ports

The single seam between the package and the host: one object of ports, named by the capabilities the client's EditorBackend knows (documents, collaboration, assets, users, tags, settings, themes, ai, comments, images). identity (type AccessControl) and documents (type DocumentContentStore) are required; every other port is optional, and the route table serves only the routes whose port is present (the client treats a refused settings load as "no account settings" and stays on localStorage). PORT_CAPABILITY maps port to capability, serverCapabilities(ports) lists what a set of ports serves (tags follows from documents). Register the ports in a Nitro plugin:

server/plugins/editor-adapter.ts
import type { User } from '#auth-utils'
import { createJsonAdapter } from '@skaile/forge-editor/adapters/json'
import { provideEditorServer } from '@skaile/forge-editor/server/nitro'
import { createWorkspaceTools } from '@skaile/forge-editor/server'

// Connects the editor package's Nuxt integration to this app's sessions, JSON document store and per-user settings.
// Synchronous on purpose: Nitro does not await a plugin, so registering after an `await` would leave the first requests without an editor server.
export default defineNitroPlugin(() => {
  // The add-ons' ports (render targets, speech, knowledge graph) and Block types; each add-on is on behind its flag, `runtimeConfig.public.addons.<name>` (`server/utils/addons.ts`).
  const addons = createAddonServer({ storeAsset, loadAsset }, addonFlagsOf(useRuntimeConfig().public.addons))
  provideEditorServer({
    ports: {
      identity: {
        // Without a session cookie h3 throws ("Cannot initialize a new session") outside a main handler.
        authenticate: request => getUserSession({ headers: request.headers, context: {} }).then(s => s.user ?? null, () => null),
      },
      documents: {
        loadContent: async id => (await getDocument(id))?.content ?? null,
        async storeContent(id, content) {
          const existing = await getDocument(id)
          if (!existing) return // deleted while a store was pending
          await putDocument({ ...existing, content, updatedAt: new Date().toISOString() })
        },
        listContents: async () => (await listAllDocuments()).map(({ id, content }) => ({ id, content })),
        // Document management over the package store: folders, create, move, delete (`/api/editor/{documents,tree}`).
        listDocumentIds: async () => indexedIds(),
        listTree,
        async createDocument(input) {
          const { id, title, updatedAt } = await createDocument(input)
          return { id, title, updatedAt, folder: input.folder ?? '' }
        },
        moveDocument,
        deleteDocument: removeDocument,
      },
      directory: { listUsers: async () => (await listUsers()).map(toSessionUser) },
      collabState: { loadState, storeState, removeState },
      assets: { storeAsset, loadAsset },
      // `user` is what `authenticate` returned: the session user.
      settings: {
        loadSettings: user => getUserSettings((user as User).id),
        storeSettings: (user, settings) => putUserSettings((user as User).id, settings),
      },
      themes: { listThemes, loadTheme, storeTheme, removeTheme },
      // Block index (`index` capability): a projection of the documents above, rebuilt on start and kept current by the editor server; in memory (memory adapter, PGlite with `NUXT_BLOCK_INDEX=pglite`, or PGlite with trigram search and vectors with `NUXT_BLOCK_INDEX=pg-vector`), so nothing to migrate.
      index: createHostBlockIndex(addons.ports.embeddings, addons.embeddingsError),
      // Prüfung records (`pruefung` capability): the editor's JSON store on `<documents dir>/.pruefung.json`, a dot-named file the document store never lists.
      pruefung: createJsonAdapter({ dir: storeRoot() }).pruefung,
      // Change sets of the document chat (`changeSets` capability): what the model proposed for other Documents, decided per Document; `<documents dir>/.change-sets.json`, likewise dot-named.
      changeSets: createJsonAdapter({ dir: storeRoot() }).changeSets,
      // Demo of the model port (`DEMO_STUB_MODEL=1`): the stub answers `{ preset: 'stub' }`; `null` for anything else falls through to the environment presets.
      models: {
        languageModel: async ref => stubModelEnabled() && 'preset' in ref && ref.preset === STUB_MODEL_ID ? stubLanguageModel : null,
        listModels: async () => stubModelEnabled() ? [...publicEnvPresets(), stubModelProfile] : null,
      },
      ...addons.ports,
    },
    ...(addons.blockTypes && { blockTypes: addons.blockTypes }),
    // The document chat's read-only workspace tools (`find_blocks`, `read_block`): the model finds and reads Blocks of the user's other Documents through the block index above.
    tools: createWorkspaceTools(addons.blockTypes && { blockTypes: addons.blockTypes }),
  })
})

Capability probe

By default the HTTP backend assumes every capability and a feature whose route the server does not mount fails on use (404/501). createHttpBackend({ probe: true }) instead asks GET /api/editor/capabilities once, drops the backend fields the server does not list (comments without a comments port, themes without a themes port, ai without configured models, …) and exposes the request as backend.ready, which createEditorContext awaits before it reports ready; so composeEditor(…, { backend }) prunes exactly what the server lacks, reported in omitted. Compose after await backend.ready (the Nuxt plugin does: it installs the context with probe: true and finishes only then). A failed probe (offline, 401, an answer that is no list) keeps every capability and warns once on the console (@skaile/forge-editor: the capability probe …). The Nuxt module passes probe: true; a Vite or bare Nitro host opts in.

Capability locality

Every capability of EditorBackend falls in one of three classes, by what a server is needed for. The class is derived from the adapters, not a runtime setting; it tells you which capabilities a client-only host keeps and which it gives up:

ClassMeaningCapabilities
localworks in the page; a server only makes it durable or shareddocuments, assets, users, tags, settings (all served by createMemoryBackend; documents, uploaded files as blob: URLs and settings last as long as the page); themes (in the page only through a host-written themes member: the memory backend ships none)
relaya server only holds a secret and enforces a limit; the work itself can run in the pageai: the server keeps the model key and applies the per-user rate limit (ai.rateLimit of the configuration), then calls the model. createDirectAi makes generate, complete, models and transcriptCleanup local, at the price of the key in the browser and no rate limit; chat and extract stay server-only. images: the host's provider holds the image-model key, stores the generated and uploaded files and keeps the prompt blocks; there is no browser-direct variant
sessiona server owns live state that several clients sharecollaboration (the Hocuspocus server holds the Yjs document), comments (the comments endpoint holds the threads)

A client-only composition is { ...createMemoryBackend(), ai: createDirectAi({ presets }) } (see Browser-direct AI): the local capabilities the memory backend serves plus a locally running ai, no session capability. composeEditor(…, { backend }) then prunes collab, comments and themes, reported in omitted.

Port members

PortMemberSignatureCalledMust return
identityauthenticate(request: { headers: Headers, token?: string }) => Promise<object | null>Collab websocket (Hocuspocus onAuthenticate; token is the client's collaboration.token, absent for HTTP routes and token-less clients); first in every API route that needs a userThe user, or null (→ 401). The value becomes the Hocuspocus connection context's user, the user argument of the settings methods and the rate-limit key (id, else username, else its JSON).
documentsloadContent(id) => Promise<JSONContent | null>onLoadDocument, when no usable Yjs state exists (new, legacy or restored document)TipTap JSON to seed the Y.Doc from, as stored (the editor migrates it to the current shape itself, see below); null = the document does not exist (connection closed)
documentsstoreContent(id, content) => Promise<void>onStoreDocument (debounced after edits, and on shutdown via the Nitro close hook)Mirrors the JSON; tolerate ids deleted meanwhile
documentslistContents() => Promise<{ id, content }[]>GET /api/editor/tagsStored JSON of every document, as stored
directorylistUsers() => Promise<MentionUser[]> ({ id, username, color })GET /api/editor/users, POST /api/ai/generate for tasks that need usersEvery user that can be @-mentioned, no credentials
collabStateloadState(id) => Promise<Uint8Array | null>onLoadDocumentThe bytes storeState received, unchanged (the Yjs update behind a content-version prefix; state from earlier releases is a raw update and loads as version null); null = none yet
collabStatestoreState(id, state) => Promise<void>onStoreDocument, and right after a Y.Doc was seeded or block IDs were backfilledPersist; tolerate deleted ids
collabStateremoveState(id) => Promise<void>EditorServer.forgetDocument, and onLoadDocument when the stored state is stale (contentVersion)Drop the state of a deleted or rewritten document
identityauthorize?(user, id) => Promise<{ readOnly: boolean } | null>Hocuspocus onConnect, with the object authenticate returnednull rejects the connection (permission denied); { readOnly: true } lets it sync but applies none of its edits. Omit: every authenticated user may edit every document
documentscontentVersion?(id) => Promise<string | null>onLoadDocument; right after every storeContent; before every store of an open documentOpaque version of the stored content (mtime, hash, revision; no NUL character), null = unknown. A stored state with another version is discarded and the document is re-seeded from loadContent; an open document whose version moved is merged with loadContent on its next store instead of being written back. Omit: state is never discarded
assetsstoreAsset(id, asset: AssetRecord & { data: Buffer }) => Promise<void>POST /api/editor/assets/:idPersist the file under asset.name with its record. id is a document id or scratch; it need not name an existing document.
assetsloadAsset(id, name) => Promise<{ record, data } | null>GET /api/editor/assets/:id/:nameThe stored file; null → 404
settingsloadSettings(user) => Promise<unknown | null>GET /api/editor/settingsStored settings (normalized by the route), null = none yet. Port absent: the settings routes are not mounted, settings stay in localStorage
settingsstoreSettings(user, settings) => Promise<void>PUT /api/editor/settingsPersist the validated settings
themeslistThemes() => Promise<ThemeRecord[]>GET /api/editor/themesThe shared theme library ({ id, name, version?, author?, authorUrl?, builtin }), host-seeded themes marked builtin: true. Port absent: the themes routes are not mounted
themesloadTheme(id) => Promise<{ record, css } | null>GET /api/editor/themes/:idThe theme's theme.css text with its record; null → 404
themesstoreTheme(record: Omit<ThemeRecord, 'builtin'>, css) => Promise<void>POST /api/editor/themesPersist an uploaded theme. The route has already validated the content and chosen a unique record.id.
themesremoveTheme(id) => Promise<boolean>DELETE /api/editor/themes/:idDelete an upload; false = unknown id or builtin (the route has checked listThemes() first and answers 403 / 404 itself)
modelslanguageModel?(ref: ModelRef, user) => Promise<LanguageModel | null>Every AI endpoint (/api/ai/{generate,chat,complete,transcript-cleanup}), before the runtime-config Model presetsThe AI SDK language model for ref ({ preset } or a user Model profile's endpoint/model/key), so the host's own providers serve the editor. user is what authenticate returned. null falls through to the Model presets (400 for an unknown preset)
modelslistModels?(user) => Promise<ModelProfile[] | null>GET /api/ai/modelsThe models offered to user, replacing the Model preset list (list them with source: 'preset' and the id that languageModel receives as { preset: id }). null lists the runtime-config Model presets
commentsthe CommentStoresee Storage adaptersthe comments routes (Nitro host only)The comment store
imagespromptBlocks(user) => Promise<ImageStudioPromptBlock[]>GET /api/editor/images/prompt-blocksThe prompt blocks ({ id, label, text }: style, brand, …) the host manages, in its order. Port absent: the image routes are not mounted
imagesgenerate(body: ImageStudioGenerateBody, user) => Promise<ImageStudioImage>POST /api/editor/images/generateGenerates and stores one image from { description, instruction, promptBlockIds, referenceUrls }; the host builds the prompt. Answers { id, url, title, active }
imagesupload(documentId, file: File, user) => Promise<ImageStudioImage>POST /api/editor/images/uploadStores an uploaded reference image of document documentId. The route has checked the id, that the file is an image and its size (413 above MAX_MEDIA_BYTES.image, 415 for other types)

The collaboration schema is not part of storage: createCollabServer({ ports, schema }) and installEditorServer take schema?: () => AnyExtension[] (default advancedSchemaExtensions(); host nodes: () => [...advancedSchemaExtensions(), Callout]); provideEditorServer({ ports, schema }) (Nuxt) takes the same option, and server.collab?.stats() of the EditorServer handle returns { connections, documents } of the running server. The collab server needs the collabState port and throws „@skaile/forge-editor: collaboration needs the collabState port“ without it.

The host's Block types are not storage either; installEditorServer({ blockTypes }) and provideEditorServer({ ports, blockTypes }) take the list the editors get through blockTypes (for instance loaded from a JSON file, see Block types in code and JSON), so the AI chat describes them to the model instead of the kinds the client sends.

Omit the settings port to keep settings in the browser only (the settings routes are not mounted; the client falls back to localStorage).

The theme methods storeTheme and removeTheme are only needed for uploads; without them the upload and delete routes answer 501 while builtins stay selectable. Without the themes port the routes are not mounted and the themes feature falls back to the Default appearance (a console warning, the stored setting stays). ThemeRecord and the THEME_ID pattern come from the Vue-free @skaile/forge-editor/themes/schema. The demo host keeps builtins in server/themes/<id>/{manifest.json,theme.css} (git-tracked, read-only) and uploads in the Nitro storage mount themes (.data/themes/<id>/{manifest.json,theme.css}, configured in nuxt.config.ts), so uploads survive restarts; listThemes returns builtins and then uploads, loadTheme checks builtins first, removeTheme refuses builtins.

Deleting a document

DELETE /api/editor/documents/:id does it all: it calls forgetDocument of the collaboration server (stateless message deleted to connected clients, connections closed, a pending store blocked from writing the state back) and then DocumentAdmin.deleteDocument, which also drops the document's Yjs state and its Blocks from the index. A host route that removes a document itself calls forgetDocument(id) of the EditorServer handle (installEditorServer returns it; a Nuxt host's routes get it from useEditorServer()) before removing the JSON. liveContent(id) of the handle returns the current content of a document that is open in Hocuspocus (unsaved edits included), null when nobody has it open.

Listing, creating and moving documents

A documents port whose documents live in folders implements three optional DocumentAdmin members: listTree(): TreeFolder, createDocument({ title?, folder? }): DocumentSummary and moveDocument(id, folder). They serve GET /api/editor/documents (the Documents the user may read, newest first), GET /api/editor/tree, POST /api/editor/documents and PUT /api/editor/documents/:id/folder; without them these routes answer 501. A DocumentStore for the file tree (see the document tree guide) is a client of exactly these routes plus DELETE /api/editor/documents/:id; the reference app's server/plugins/editor-adapter.ts serves them over its package store.

Changing a document from the server

POST /api/editor/documents/:id/ops with { ops } applies block operations (update, add, delete, convert: the shape of the document chat's edit_document tool, ModelAgentOp) to a document nobody has to have open, all or none. The DocumentMutator behind it (createDocumentMutator({ ports, collab, schema, blockTypes, reindex }) of @skaile/forge-editor/server; EditorServer.mutator of the installed server) checks every operation against the block IDs of the document (checkAgentOps), parses the Markdown without an editor (createMarkdownBlocks), changes an open document inside its Y.Doc (connected clients see an ordinary edit) and a stored one through storeContent, and re-projects the Block index either way. Refusals: 403 for a read-only user, 404 for an unknown document, 409 when a named block no longer exists, 422 for an invalid operation (the error data lists the problems). Host code that acts for a user calls server.mutator.apply(documentId, ops, { user }).

Content rewritten behind the editor's back

When something other than the editor writes a document's content (an agent rewrites the file), call server.collab.contentChanged(id) (the handle's collab, or the one of your own createCollabServer) after the write. If the document is open, the server merges the new content into it right away as a diff-update: connected clients see an ordinary edit, unchanged blocks keep their Yjs items, nobody reconnects. It is a no-op when nobody has the document open or the version did not move. It needs contentVersion: without it a rewrite cannot be told from the server's own writes. Without the call an open document picks the rewrite up only on its next store (the next edit or the last client leaving).

Reading stored content

The ports return content exactly as the host stored it, possibly written by an earlier release. Server code that reads it (a host route, a script) uses the one load operation of @skaile/forge-editor/server, which applies the stored-content migration once and completely: loadCurrentContent(ports.documents, id) (one document, null when missing), listCurrentContents(ports.documents) (all documents) and currentContent(json) for stored content that did not come from the port (a .cdoc package a host imports, a document a host's own route loads next to its metadata). The collaboration load, GET /api/editor/documents/:id, the tag index and the reference app's import, .cdoc export and GET/PUT /api/documents/:id all read through them; no caller picks a migration itself.

What the collab server does on load

  1. A stored Yjs state without old persisted node names (composite*, section*) is used; blocks without an ID get one assigned on the server, before any client syncs, and the state is persisted at once (so a backfill after a restart cannot assign other IDs than clients already hold).
  2. Otherwise (no state, a legacy state containing the old section node, or a restored document) the Y.Doc is seeded from loadContent, read as current content (loadCurrentContent: old persisted names, the earliest section* family and a flat meta.transcript migrated) and given block IDs, then persisted immediately.

Agent texts for host-driven agent chats

Model-facing agent texts of /ai/protocol (checkAgentOps(raw, { …context, locale }), agentShownMessage(ops, dropped, { locale }), agentFeedbackText(feedback, { locale }), agentRetryMessage, agentOpSummary) are German by default and English with locale: 'en'; the package's own /api/ai/chat prompts and results stay German.

A host that exposes the edit operations under its own tool name builds the retry result with agentRetryMessage(problems, scoped, { locale, tool, partial }), so the text tells the model to call tool (default edit_document) again rather than the editor's tool. Without partial the text says nothing was applied and asks for the whole corrected list (the package's own all-or-nothing retry); a host that already shows the valid operations passes partial: true, and the text asks only for corrected versions of the listed operations.

Copyright © 2026