EditorPorts: auth and storage
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:
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:
| Class | Meaning | Capabilities |
|---|---|---|
local | works in the page; a server only makes it durable or shared | documents, 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) |
relay | a server only holds a secret and enforces a limit; the work itself can run in the page | ai: 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 |
session | a server owns live state that several clients share | collaboration (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
| Port | Member | Signature | Called | Must return |
|---|---|---|---|---|
identity | authenticate | (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 user | The 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). |
documents | loadContent | (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) |
documents | storeContent | (id, content) => Promise<void> | onStoreDocument (debounced after edits, and on shutdown via the Nitro close hook) | Mirrors the JSON; tolerate ids deleted meanwhile |
documents | listContents | () => Promise<{ id, content }[]> | GET /api/editor/tags | Stored JSON of every document, as stored |
directory | listUsers | () => Promise<MentionUser[]> ({ id, username, color }) | GET /api/editor/users, POST /api/ai/generate for tasks that need users | Every user that can be @-mentioned, no credentials |
collabState | loadState | (id) => Promise<Uint8Array | null> | onLoadDocument | The 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 |
collabState | storeState | (id, state) => Promise<void> | onStoreDocument, and right after a Y.Doc was seeded or block IDs were backfilled | Persist; tolerate deleted ids |
collabState | removeState | (id) => Promise<void> | EditorServer.forgetDocument, and onLoadDocument when the stored state is stale (contentVersion) | Drop the state of a deleted or rewritten document |
identity | authorize? | (user, id) => Promise<{ readOnly: boolean } | null> | Hocuspocus onConnect, with the object authenticate returned | null rejects the connection (permission denied); { readOnly: true } lets it sync but applies none of its edits. Omit: every authenticated user may edit every document |
documents | contentVersion? | (id) => Promise<string | null> | onLoadDocument; right after every storeContent; before every store of an open document | Opaque 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 |
assets | storeAsset | (id, asset: AssetRecord & { data: Buffer }) => Promise<void> | POST /api/editor/assets/:id | Persist the file under asset.name with its record. id is a document id or scratch; it need not name an existing document. |
assets | loadAsset | (id, name) => Promise<{ record, data } | null> | GET /api/editor/assets/:id/:name | The stored file; null → 404 |
settings | loadSettings | (user) => Promise<unknown | null> | GET /api/editor/settings | Stored settings (normalized by the route), null = none yet. Port absent: the settings routes are not mounted, settings stay in localStorage |
settings | storeSettings | (user, settings) => Promise<void> | PUT /api/editor/settings | Persist the validated settings |
themes | listThemes | () => Promise<ThemeRecord[]> | GET /api/editor/themes | The shared theme library ({ id, name, version?, author?, authorUrl?, builtin }), host-seeded themes marked builtin: true. Port absent: the themes routes are not mounted |
themes | loadTheme | (id) => Promise<{ record, css } | null> | GET /api/editor/themes/:id | The theme's theme.css text with its record; null → 404 |
themes | storeTheme | (record: Omit<ThemeRecord, 'builtin'>, css) => Promise<void> | POST /api/editor/themes | Persist an uploaded theme. The route has already validated the content and chosen a unique record.id. |
themes | removeTheme | (id) => Promise<boolean> | DELETE /api/editor/themes/:id | Delete an upload; false = unknown id or builtin (the route has checked listThemes() first and answers 403 / 404 itself) |
models | languageModel? | (ref: ModelRef, user) => Promise<LanguageModel | null> | Every AI endpoint (/api/ai/{generate,chat,complete,transcript-cleanup}), before the runtime-config Model presets | The 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) |
models | listModels? | (user) => Promise<ModelProfile[] | null> | GET /api/ai/models | The 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 |
comments | the CommentStore | see Storage adapters | the comments routes (Nitro host only) | The comment store |
images | promptBlocks | (user) => Promise<ImageStudioPromptBlock[]> | GET /api/editor/images/prompt-blocks | The prompt blocks ({ id, label, text }: style, brand, …) the host manages, in its order. Port absent: the image routes are not mounted |
images | generate | (body: ImageStudioGenerateBody, user) => Promise<ImageStudioImage> | POST /api/editor/images/generate | Generates and stores one image from { description, instruction, promptBlockIds, referenceUrls }; the host builds the prompt. Answers { id, url, title, active } |
images | upload | (documentId, file: File, user) => Promise<ImageStudioImage> | POST /api/editor/images/upload | Stores 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
- 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). - Otherwise (no state, a legacy state containing the old
sectionnode, or a restored document) the Y.Doc is seeded fromloadContent, read as current content (loadCurrentContent: old persisted names, the earliestsection*family and a flatmeta.transcriptmigrated) 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.
Workspace: find, read, reference and embed Blocks across Documents
The Block index, Workspace search, Block references and Embeds that name a Block address, Document routes with the DocumentMutator, Tool providers for the document chat, and change sets for edits to other Documents.
Collections: an ordered list of Blocks
The collection Block type: its items and stored query, the node view with reorder and „Aus Abfrage füllen“, the block action that adds a Block, and the Markdown export.