diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 07212941..75c62871 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,5206 +1,93 @@ -import FormData from "form-data"; -import axios, { AxiosInstance } from "axios"; -import { basename, extname } from "path"; -import { - filterWorkspace, - filterSpace, - filterPage, - filterComment, - filterSearchResult, -} from "./lib/filters.js"; -import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js"; -import { - collectInternalFileNodes, - normalizeFileUrl, - resolveInternalFilePath, -} from "./lib/internal-file-urls.js"; -import { - updatePageContentRealtime, - replacePageContent, - markdownToProseMirror, - markdownToProseMirrorCanonical, - mutatePageContent, - assertYjsEncodable, - MutationResult, -} from "./lib/collaboration.js"; -import { acquireCollabSession } from "./lib/collab-session.js"; -import { footnoteWarningsField } from "./lib/footnote-analyze.js"; -import { buildPageTree } from "./lib/tree.js"; -import { - serializeDocmostMarkdown, - parseDocmostMarkdown, -} from "./lib/markdown-document.js"; -import { - replaceNodeById, - replaceNodeByIdWithMany, - reassignCollidingBlockIds, - deleteNodeById, - assertUnambiguousMatch, - insertNodeRelative, - insertNodesRelative, - blockPlainText, - buildOutline, - getNodeByRef, - readTable, - insertTableRow, - deleteTableRow, - updateTableCell, - findInvalidNode, -} from "@docmost/prosemirror-markdown"; -import { - importMarkdownFragment, - canBeDocChild, - findUnrepresentableTableAttrs, -} from "./lib/markdown-fragment.js"; -import { searchInDoc, SearchOptions } from "./lib/page-search.js"; -import { withPageLock, isUuid } from "./lib/page-lock.js"; -import { - prepareModel, - decodeDrawioSvg, - buildDrawioSvg, - mxHash, - normalizeXml, - countUserCells, -} from "./lib/drawio-xml.js"; -import { renderDiagramShapes } from "./lib/drawio-preview.js"; -import { applyElkLayout } from "./lib/drawio-layout.js"; -import { - buildFromGraph, - type Graph, - type LayoutMode as GraphLayoutMode, -} from "./lib/drawio-graph.js"; -import { applyCellOps, type CellOp } from "./lib/drawio-cell-ops.js"; -import { mermaidToGraph } from "./lib/drawio-mermaid.js"; -import { parseCells as parseDrawioCells } from "./lib/drawio-xml.js"; -import { - applyTextEdits, - TextEdit, - TextEditResult, - TextEditFailure, -} from "./lib/json-edit.js"; -import { getCollabToken, performLogin } from "./lib/auth-utils.js"; -import { diffDocs, summarizeChange } from "./lib/diff.js"; -import { - applyAnchorInDoc, - countAnchorMatches, - getAnchoredText, - resolveAnchorSelection, - normalizeForMatch, -} from "./lib/comment-anchor.js"; -import { closestBlockHint } from "./lib/text-normalize.js"; -import { - blockText, - walk, - getList, - insertMarkerAfter, - setCalloutRange, - noteItem, - mdToInlineNodes, - commentsToFootnotes, - canonicalizeFootnotes, - insertInlineFootnote, - mergeFootnoteDefinitions, -} from "./lib/transforms.js"; -import { normalizeAndMergeFootnotes } from "./lib/footnote-normalize-merge.js"; -import vm from "node:vm"; +// DocmostClient — thin facade assembled from domain mixins (issue #450). +// +// The 5206-line god-object was split into cohesive domain modules under +// `client/`, each a mixin layered on the shared `DocmostClientContext` base +// (which owns the axios client, auth, apiUrl, the resolvePageId cache and the +// core HTTP/write seams). The mixins compose into ONE prototype chain, so: +// - every public method name/signature is UNCHANGED (the external contract — +// `DocmostClientLike` = `Pick`, the in-app adapter, and +// both transports — resolves exactly as before); +// - `this.` dispatches virtually across modules, so a test subclass +// that overrides a seam (`mutatePage`, `resolvePageId`, `getPageRaw`, +// `getCollabTokenWithReauth`, `uploadAttachmentBuffer`, `fetchAttachmentText`) +// is still seen by every caller — the load-bearing #449/#425 patterns +// (single held page-lock, self-resolving write seams, the no-await critical +// window in collab-session) are preserved byte-for-byte in their modules. +// +// This file only wires the chain and re-exports the package's public surface. +import { DocmostClientContext } from "./client/context.js"; +import { ReadMixin, type IReadMixin } from "./client/read.js"; +import { PagesMixin, type IPagesMixin } from "./client/pages.js"; +import { NodesWriteMixin, type INodesWriteMixin } from "./client/nodes-write.js"; +import { TablesMixin, type ITablesMixin } from "./client/tables.js"; +import { DocValidateMixin } from "./client/doc-validate.js"; +import { MediaMixin, type IMediaMixin } from "./client/media.js"; +import { StashMixin, type IStashMixin } from "./client/stash.js"; +import { DrawioMixin, type IDrawioMixin } from "./client/drawio.js"; +import { CommentsMixin, type ICommentsMixin } from "./client/comments.js"; +import { TransformsMixin, type ITransformsMixin } from "./client/transforms.js"; -// Supported image types, kept as two lookup tables so both a local file -// extension and a remote Content-Type can be mapped to the same canonical set. -const EXT_TO_MIME: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", -}; -const MIME_TO_EXT: Record = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "image/svg+xml": ".svg", -}; +// Re-export the package's public types + helpers from their new homes so every +// existing importer (index.ts, http.ts, stdio.ts, the in-app host) keeps working +// with ZERO changes. +export type { DocmostMcpConfig, SandboxPut } from "./client/context.js"; +export { formatDocmostAxiosError, assertFullUuid } from "./client/errors.js"; + +// The full public + shared instance surface of the assembled client. Built by +// INTERSECTING each domain mixin's public interface (each DERIVED from its class +// and enforced by that class's `implements` clause — issue #446, no hand-mirror) +// with the shared context. Named explicitly so the emitted `.d.ts` refers to +// this type rather than the anonymous mixin-composed class (which carries the +// base's `protected` shared state and would otherwise trip TS4094). DocValidate +// exposes no PUBLIC methods (all its helpers are protected), so it contributes +// nothing to the public surface and needs no interface here. +// +// Signature fidelity: every method's NAME and PARAMETER types are preserved +// exactly (the contract surface both hosts type-check against — `DocmostClientLike +// = Pick`, the in-app adapter). Explicit source return types +// (e.g. `stashPage`, `exportPageMarkdown`, the drawio tools) are carried through +// verbatim; methods that relied on inference in the monolith surface as `any` +// return here — a deliberate, contract-safe trade-off, since the execute +// wrappers and the adapter consume the arguments, not the awaited return shape. +type DocmostClientInstance = DocmostClientContext & + IReadMixin & + IPagesMixin & + INodesWriteMixin & + ITablesMixin & + IMediaMixin & + IStashMixin & + IDrawioMixin & + ICommentsMixin & + ITransformsMixin; + +// Compose the domain mixins over the shared context. Order is irrelevant to +// behaviour (cross-module calls go through `this`, resolved at runtime on the +// assembled instance); it only affects the prototype-chain nesting. The +// explicit constructor-type annotation keeps the exported `.d.ts` referring to +// the NAMED `DocmostClientInstance` rather than the anonymous composed class. +const DocmostClientBase: new (...args: any[]) => DocmostClientInstance = + TransformsMixin( + CommentsMixin( + DrawioMixin( + StashMixin( + MediaMixin( + DocValidateMixin( + TablesMixin( + NodesWriteMixin(PagesMixin(ReadMixin(DocmostClientContext))), + ), + ), + ), + ), + ), + ), + ) as unknown as new (...args: any[]) => DocmostClientInstance; /** - * Configuration for a DocmostClient / MCP server instance. A discriminated - * union: either service-account credentials (email/password — the client calls - * performLogin, powering the external /mcp HTTP endpoint and the stdio CLI) OR - * a token getter (getToken — the client uses the returned BARE access JWT as - * the Bearer and never calls performLogin; used for the internal per-user path). - * - * Both branches may ALSO carry an optional `getCollabToken` provider. When set, - * content mutations (which go over the collaboration websocket) use the token it - * returns INSTEAD of calling `POST /auth/collab-token`. The internal per-user - * agent path uses this to hand the client a provenance collab token (signed - * `actor:'agent'`+`aiChatId`), so agent content edits are attributed without a - * spoofable client-side field. When absent the client keeps the original - * `/auth/collab-token` path (service-account/stdio unchanged). - * - * Housed here (not in index.ts) so client.ts has no type dependency on index.ts; - * index.ts re-exports it for the package's public surface. + * The Docmost API client used by both the standalone MCP server and the in-app + * AI-SDK host. A thin, concrete assembly of the domain mixins above; carries no + * logic of its own. See `client/` for the per-domain implementations. Its public + * method surface is UNCHANGED from the original monolith, so the external + * contract (`DocmostClientLike = Pick`, the in-app adapter, + * both transports' execute-wrappers) resolves identically. */ -// Sink the stash tool writes blobs into. The host app binds this to its in-RAM -// SandboxStore and composes the public `uri` (the package never sees the store -// or any env). `put` returns the anonymous read URL plus integrity metadata. -export type SandboxPut = ( - buf: Buffer, - mime: string, -) => { uri: string; sha256: string; size: number }; - -export type DocmostMcpConfig = { apiUrl: string } & ( - | { email: string; password: string } - | { getToken: () => Promise } // returns a BARE JWT; the client adds "Bearer " -) & { - // Optional collab-token provider (returns a ready collab JWT). Common to - // both branches; see the type doc above. - getCollabToken?: () => Promise; - // Optional blob sandbox sink. Present only where the stash tool is wired; - // when absent, stashPage throws a clear "not configured" error. The - // optional `has`/`evict` probes let stashPage keep its mirror counts honest - // under the store's FIFO eviction (see stashPage); older sinks omit them. - sandbox?: { - put: SandboxPut; - has?: (uri: string) => boolean; - evict?: (uri: string) => void; - }; - // Dependency-neutral metrics sink. When present, the client emits generic - // (name, value, labels) samples; the HOST maps those names onto its own - // metrics registry (the package never depends on prom-client or the server). - // Absent in standalone/stdio mode → the client is a complete no-op here. - onMetric?: ( - name: string, - value: number, - labels?: Record, - ) => void; - }; - -// Canonical UUID predicate. Single source of truth lives in page-lock.ts (the -// module that ASSERTS the mutex key is a UUID, issue #449) and is reused here so -// resolvePageId's "already a UUID?" short-circuit and withPageLock's fail-fast -// assert can never diverge. page.repo.ts treats any non-UUID pageId as a slugId, -// so the MCP detects a UUID locally and skips a /pages/info round-trip in -// resolvePageId. A 10-char nanoid slugId never contains dashes, so it can never -// be misread as a UUID here. -// (isUuid imported from ./lib/page-lock.js; see import block above.) - -/** - * Collab-token cache TTL in milliseconds (issue #435). Read fresh from the - * environment on every mint — like collab-session.ts readConfig — so tests and a - * live rollback can change it without reloading the module. - * - * Why a cache at all: the live CollabSession registry (#400/#431) keys sessions - * on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH - * collab-token sources mint a FRESH token per mutation — the in-app provider - * re-signs a JWT whose iat/exp (seconds) changes every second, and the external - * MCP POSTs /auth/collab-token each call — so the token in the key changed on - * every op and the session was almost never reused (connect-storms, 25s - * timeouts, zombie sessions). Caching the token per-client keeps the key stable - * across a burst of mutations so ONE session is reused. - * - * Default 5 min: well under the 24h collab-token lifetime AND <= the collab - * session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the - * permission-staleness window is not widened beyond what #431 already accepted. - * The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the - * cache — an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables - * the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls - * back to the 5-min default with the cache ON — parseInt yields NaN, which is - * treated as "not configured", not as "disabled". So to turn the cache off you - * must set the value to exactly 0, not to garbage. - */ -function readCollabTokenTtlMs(): number { - const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10); - return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000; -} - -// --- Issue #437: central error diagnostics ------------------------------- -// The agent only ever sees the thrown exception's `error.message`, so a failed -// tool must return an ACTIONABLE message (method, path, status, and the -// server's own validation text) instead of the opaque "Request failed with -// status code 400". These helpers + the response interceptor in the -// constructor are the single authoritative place that text is composed. - -// Overall cap on the composed diagnostic message so the model context stays -// compact and a (whitelisted) server string can never blow up the text. -const ERROR_MESSAGE_CAP = 300; -// Only attempt to JSON.parse an arraybuffer body under this size: a larger -// binary body is never a JSON error envelope, so parsing it just wastes memory -// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch -// carries the JSON error envelope as raw bytes here). -const ERROR_BUFFER_PARSE_CAP = 4096; - -// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant- -// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the -// canonical shape/length is enforced, not the version/variant nibble. -const FULL_UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** - * Throw an actionable error BEFORE any network call when `value` is not a full - * canonical UUID. Absorbs #436: a truncated/short comment id used to reach the - * server and bounce back as an opaque 400/404 the agent could not self-correct; - * failing fast here names the exact fix. - */ -export function assertFullUuid( - tool: string, - param: string, - value: string, -): void { - if (typeof value !== "string" || !FULL_UUID_RE.test(value)) { - throw new Error( - `${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` + - `019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` + - `verbatim from listComments / createComment output.`, - ); - } -} - -// Keep ONLY the pathname of a request (no host, no query string, no fragment) -// so the message never leaks a host or query params. Resolves a relative -// config.url against config.baseURL, then discards everything but the path. -function requestPath(config: any): string { - const rawUrl = typeof config?.url === "string" ? config.url : ""; - const base = - typeof config?.baseURL === "string" ? config.baseURL : undefined; - try { - // A dummy base makes an absolute config.url parse too; its host is dropped. - return new URL(rawUrl, base ?? "http://localhost").pathname; - } catch { - // Malformed url: still strip any query/fragment manually. - return rawUrl.split(/[?#]/)[0] || rawUrl; - } -} - -/** - * Compose the server-facing message from `error.response.data`, using ONLY the - * whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the - * raw response body, headers (Authorization!) and config are NEVER read here — - * a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in - * favour of the statusText. - */ -function extractServerMessage(data: any, statusText: string): string { - // class-validator envelope: { message: string | string[], error?: string }. - if ( - data && - typeof data === "object" && - !Buffer.isBuffer(data) && - !(data instanceof ArrayBuffer) - ) { - const msg = (data as any).message; - if (Array.isArray(msg)) { - const joined = msg.filter((m) => typeof m === "string").join("; "); - if (joined) return joined; - } else if (typeof msg === "string" && msg) { - return msg; - } - const err = (data as any).error; - if (typeof err === "string" && err) return err; - return statusText; - } - - // Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a - // failed arraybuffer fetch still surfaces the server's validation text. - if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); - if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) { - try { - return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText); - } catch { - return statusText; - } - } - return statusText; - } - - // A raw string / HTML body is never surfaced (may echo server internals). - return statusText; -} - -/** - * Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic: - * ` failed ( ): ` - * or, when the request never got a response: - * ` failed: (no response from server)`. - * - * Mutates the SAME error object (never a custom subclass) so the live - * axios.isAxiosError / error.response?.status / config._retry checks around the - * client keep working, and sets `_docmostFormatted` as a double-processing - * guard. A no-op on a non-axios or already-formatted error. - */ -export function formatDocmostAxiosError(error: any): void { - if (!error || error._docmostFormatted) return; - if (!axios.isAxiosError(error)) return; - - const config: any = error.config ?? {}; - const method = - typeof config.method === "string" ? config.method.toUpperCase() : ""; - const methodPath = `${method} ${requestPath(config)}`.trim(); - const response = error.response; - - let message: string; - if (response) { - const statusText = - typeof response.statusText === "string" ? response.statusText : ""; - const serverMessage = extractServerMessage(response.data, statusText); - message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`; - // Full body only to stderr under DEBUG (parity with downloadImage). - if (process.env.DEBUG) { - console.error( - "Docmost request failed; response body:", - JSON.stringify(response.data), - ); - } - } else { - // No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout). - // Use ONLY error.code, never the raw error.message: axios network messages - // embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo - // ENOTFOUND host") and #437's invariant is that the host never reaches the - // model-visible message. code is set for essentially every real no-response - // error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full - // native message still goes to stderr under DEBUG. - const reason = error.code ?? "network error"; - message = `${methodPath} failed: ${reason} (no response from server)`; - if (process.env.DEBUG) { - console.error("Docmost request failed; no response:", error.message); - } - } - - if (message.length > ERROR_MESSAGE_CAP) { - message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…"; - } - - error.message = message; - (error as any)._docmostFormatted = true; -} - -export class DocmostClient { - private client: AxiosInstance; - private token: string | null = null; - private apiUrl: string; - // email/password are only set on the service-account (credentials) variant; - // null on the getToken variant (where there are no credentials to log in with). - private email: string | null = null; - private password: string | null = null; - // Per-user token provider. When set, login() calls it to obtain a BARE access - // JWT instead of performLogin, and the 401/403 re-auth path re-calls it. - private getTokenFn: (() => Promise) | null = null; - // Optional collab-token provider. When set, getCollabTokenWithReauth() returns - // its token instead of calling POST /auth/collab-token; on a 401/403 it is - // re-invoked once. Used by the internal agent to carry signed provenance. - private getCollabTokenFn: (() => Promise) | null = null; - // Optional blob-sandbox sink for the stash tool. Null when not configured. - private sandboxPut: SandboxPut | null = null; - // Optional probes paired with the sink. `has` lets stashPage detect a blob - // FIFO-evicted by a LATER put in the same stash; `evict` lets it free this - // op's image blobs if the final doc put throws. Null when the sink omits them. - private sandboxHas: ((uri: string) => boolean) | null = null; - private sandboxEvict: ((uri: string) => void) | null = null; - // Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric). - // Null on the legacy positional form and whenever the host omits it → no-op. - private onMetricFn: - | ((name: string, value: number, labels?: Record) => void) - | null = null; - // In-flight login dedup: when the token expires, the 401 interceptor, - // ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries - // can all call login() at once. Memoizing a single promise collapses that - // thundering herd into ONE /auth/login request that everyone awaits. - private loginPromise: Promise | null = null; - // Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the - // page's canonical UUID, so repeated collab edits on the same page do not - // re-fetch /pages/info. A UUID input short-circuits before this cache (see - // resolvePageId), so only slugId->uuid entries are stored/read here. - private pageIdCache = new Map(); - - // Collab-token cache (issue #435): the last minted collab token plus the - // wall-clock time it was minted, so a burst of content mutations reuses ONE - // token and therefore ONE live CollabSession (whose registry key includes the - // token — #400 invariant 4). Per-instance: a DocmostClient is built per - // user/per chat request, so a cached token can never leak across identities. - // Reset whenever the client's identity changes (login() / this.token cleared); - // bypassed on a forced refresh (the 401/403 reauth path). null = no token yet. - private collabTokenCache: { token: string; mintedAt: number } | null = null; - - // Two construction forms: - // - new DocmostClient(config) // discriminated union (current) - // - new DocmostClient(baseURL, email, password) // legacy positional creds - // The positional form is retained so existing callers/tests keep working; it - // is exactly equivalent to the credentials branch of the object form. - constructor(config: DocmostMcpConfig); - constructor(baseURL: string, email: string, password: string); - constructor( - configOrBaseURL: DocmostMcpConfig | string, - email?: string, - password?: string, - ) { - // Normalize the legacy positional form into the object union. - const config: DocmostMcpConfig = - typeof configOrBaseURL === "string" - ? { apiUrl: configOrBaseURL, email: email!, password: password! } - : configOrBaseURL; - - this.apiUrl = config.apiUrl; - if ("getToken" in config) { - // Token variant: carry the user's JWT via getToken; no credentials, so - // login() must never call performLogin (there is nothing to log in with). - this.getTokenFn = config.getToken; - } else { - // Service-account variant: behaves exactly as before (performLogin). - this.email = config.email; - this.password = config.password; - } - // Optional, available to both variants. When present, content mutations get - // their collab token from here instead of POST /auth/collab-token. - if (config.getCollabToken) { - this.getCollabTokenFn = config.getCollabToken; - } - if (config.sandbox) { - this.sandboxPut = config.sandbox.put; - this.sandboxHas = config.sandbox.has ?? null; - this.sandboxEvict = config.sandbox.evict ?? null; - } - // Legacy positional form carries no onMetric → null (complete no-op). - this.onMetricFn = config.onMetric ?? null; - this.client = axios.create({ - baseURL: this.apiUrl, - // Default request timeout so a hung connection cannot wedge a per-page - // lock or block the server indefinitely. Multipart uploads override this - // with a longer per-request timeout. - timeout: 30000, - headers: { - "Content-Type": "application/json", - }, - }); - - // Re-authenticate transparently on a 401/403 once: the JWT authToken can - // expire while the server is long-running, after which every cached-token - // request would otherwise fail until a manual restart. On such a response, - // clear the stale token, perform a fresh login, and replay the original - // request exactly once (guarded by config._retry to avoid infinite loops; - // the login request itself is never retried). - this.client.interceptors.response.use( - (response) => response, - async (error) => { - const config = error.config; - const status = error.response?.status; - const isAuthError = status === 401 || status === 403; - const isLoginRequest = - typeof config?.url === "string" && config.url.includes("/auth/login"); - - if (config && isAuthError && !config._retry && !isLoginRequest) { - config._retry = true; - // Drop the stale token + Authorization header before re-login. Also - // clear the collab-token cache (#435): a new identity/login must not - // keep serving a collab token minted under the old one. - this.token = null; - this.collabTokenCache = null; - delete this.client.defaults.headers.common["Authorization"]; - try { - await this.login(); - } catch (loginError) { - // Re-login failed: surface the original error to the caller. - return Promise.reject(error); - } - // Re-issue the original request with the freshly minted Bearer token. - // Read it from the default header that login() just set, not from - // this.token, to avoid a theoretical "Bearer null" if this.token was - // cleared between login() resolving and this point. - config.headers = config.headers || {}; - config.headers["Authorization"] = - this.client.defaults.headers.common["Authorization"]; - return this.client.request(config); - } - - return Promise.reject(error); - }, - ); - - // Diagnostics interceptor (issue #437). Registered AFTER the re-login - // interceptor so a successful re-login retry (which resolves to a real - // response) is never seen here as an error; only a genuine failure reaches - // this rejection handler. It reformats error.message IN PLACE (see - // formatDocmostAxiosError — kept as a mutation, not a custom Error class, so - // the surrounding axios.isAxiosError / error.response?.status / config._retry - // checks keep working) and re-rejects the SAME error. The _docmostFormatted - // flag makes a re-processed retry-failure a no-op. - this.client.interceptors.response.use( - (response) => response, - (error) => { - formatDocmostAxiosError(error); - return Promise.reject(error); - }, - ); - } - - /** Application base URL (API URL without the /api suffix). */ - get appUrl(): string { - return this.apiUrl.replace(/\/api\/?$/, ""); - } - - async login() { - // Reuse an in-flight login if one is already running so concurrent callers - // share a single token fetch instead of each issuing their own. - if (!this.loginPromise) { - // Token variant: re-fetch a BARE JWT via getToken() (there are no - // credentials to log in with — on a 401/403 the interceptor below calls - // login() again, which re-invokes getToken()). Credentials variant: - // performLogin against /auth/login exactly as before. - const fetchToken = this.getTokenFn - ? this.getTokenFn() - : performLogin(this.apiUrl, this.email!, this.password!); - this.loginPromise = fetchToken - .then((token) => { - // Guard against an empty/invalid token (e.g. a getToken provider that - // resolves to "" or null): without this an empty token would set a - // literal "Authorization: Bearer null"/"Bearer " header and every - // request would 401 with a confusing error. Fail loudly instead. - if (typeof token !== "string" || token.length === 0) { - throw new Error("getToken returned an empty token"); - } - this.token = token; - // Identity (re)established: drop any collab token minted under a - // previous identity so the #435 cache can never outlive it. - this.collabTokenCache = null; - this.client.defaults.headers.common["Authorization"] = - `Bearer ${token}`; - }) - .finally(() => { - this.loginPromise = null; - }); - } - return this.loginPromise; - } - - async ensureAuthenticated() { - if (!this.token) { - await this.login(); - } - } - - /** - * Fetch a collaboration token, transparently re-authenticating once on a - * 401/403. getCollabToken() uses bare axios internally, so it is NOT covered - * by this.client's response interceptor; this helper replicates that - * behaviour for collab-token requests: ensure a token, try once, and on an - * expired-token auth error perform a fresh login and retry exactly once. - * - * Collab-token cache (issue #435): both sources — the getCollabToken provider - * (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) — mint - * a FRESH token per call, whose string therefore changes every op. Since the - * live CollabSession registry keys on the token string (#400/#431 invariant 4), - * that churned the key and defeated session reuse. So we cache the last minted - * token per-client for readCollabTokenTtlMs() and hand it back for a burst of - * mutations, keeping the session key stable. `forceRefresh` bypasses the cache - * (the 401/403 reauth retry uses it, so the retry cannot be handed the same - * stale token that just failed — otherwise reauth would be a no-op). TTL 0 - * disables the cache: exact fetch-per-call legacy behaviour. - */ - private async getCollabTokenWithReauth( - forceRefresh = false, - ): Promise { - const ttl = readCollabTokenTtlMs(); - // Serve the cached collab token while it is still fresh (identity isolation - // is preserved: the cache is a per-instance field on a client built per - // user/per chat request, and it is cleared on every identity change). - if ( - !forceRefresh && - ttl > 0 && - this.collabTokenCache && - Date.now() - this.collabTokenCache.mintedAt < ttl - ) { - return this.collabTokenCache.token; - } - - // Collab-token PROVIDER path: when a getCollabToken provider was supplied - // (the internal agent's provenance collab token), use it instead of the - // REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the - // signed token expired between content mutations in a long agent turn). - if (this.getCollabTokenFn) { - try { - const token = await this.getCollabTokenFn(); - if (typeof token !== "string" || token.length === 0) { - throw new Error("getCollabToken returned an empty token"); - } - return this.rememberCollabToken(token, ttl); - } catch (e) { - // On an auth error retry EXACTLY once, forcing a refresh so the retry - // re-invokes the provider (bypassing the cache) for a genuinely fresh - // token. `!forceRefresh` bounds it to a single retry (no loop). - if (this.isCollabAuthError(e) && !forceRefresh) { - return this.getCollabTokenWithReauth(true); - } - throw e; - } - } - - await this.ensureAuthenticated(); - try { - const token = await getCollabToken(this.apiUrl, this.token!); - return this.rememberCollabToken(token, ttl); - } catch (e) { - // getCollabToken wraps the AxiosError in a plain Error but attaches the - // HTTP status as `.status`, so isCollabAuthError detects an auth failure - // via either the raw AxiosError shape OR the attached status. - if (this.isCollabAuthError(e) && !forceRefresh) { - // Fresh login (which clears this.token AND the collab-token cache), then - // retry exactly once with the cache bypassed via forceRefresh. - await this.login(); - return this.getCollabTokenWithReauth(true); - } - throw e; - } - } - - /** - * Store a freshly minted collab token in the per-client cache (issue #435) and - * return it unchanged. No-op write when the cache is disabled (ttl<=0) or the - * token is empty, so a disabled cache is exact fetch-per-call legacy behaviour - * and a bad token is never cached. - */ - private rememberCollabToken(token: string, ttl: number): string { - if (ttl > 0 && typeof token === "string" && token.length > 0) { - this.collabTokenCache = { token, mintedAt: Date.now() }; - } - return token; - } - - /** - * True when an error carries a 401/403 — either as a raw AxiosError - * (`error.response.status`) or as the plain-Error `.status` that - * lib/auth-utils.getCollabToken attaches after wrapping the AxiosError. - */ - private isCollabAuthError(e: unknown): boolean { - const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined; - const attachedStatus = (e as any)?.status; - return ( - axiosStatus === 401 || - axiosStatus === 403 || - attachedStatus === 401 || - attachedStatus === 403 - ); - } - - /** - * Connect to the collaboration websocket, read the live doc, apply - * `transform`, write the result, and wait for the server to persist it — - * WITHOUT acquiring the per-page lock. - * - * This mirrors collaboration.mutatePageContent EXCEPT that it does not call - * withPageLock. It exists solely so replaceImage can hold ONE withPageLock - * across its scan -> upload -> write sequence: the per-page mutex is NOT - * reentrant, so calling the normal (self-locking) mutatePageContent inside an - * outer withPageLock for the same pageId would deadlock. The caller MUST hold - * the page lock for the whole operation; this helper assumes that invariant. - * - * `transform` receives the live ProseMirror doc and returns the NEW full doc - * to write, or `null` to abort with no write. Errors thrown by `transform` - * propagate to the caller. - * - * Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so - * every content mutator (including replaceImage) can return a verifiable - * change report. The report is computed AFTER the atomic read->write and - * never throws. - */ - private async mutateLiveContentUnlocked( - pageId: string, - collabToken: string, - transform: (liveDoc: any) => any | null, - ): Promise { - // Reuse a live CollabSession for the page (issue #400) instead of opening a - // fresh provider per op. acquireCollabSession does NOT take the per-page - // lock — the caller (replaceImage) already holds ONE withPageLock across its - // scan -> upload -> write sequence, and the mutex is not reentrant, so - // taking it here would deadlock. The synchronous read->write section and the - // unsyncedChanges/connectionLost ack logic live in CollabSession.mutate, - // preserved verbatim from the old inline machine (incl. the #152 structural - // diff that keeps a live editor's cursor anchored). - const session = await acquireCollabSession(pageId, collabToken, this.apiUrl, { - // Only the actual 25s collab connect timeout emits this — the connect-vs- - // unload signal; the other failure paths must NOT emit it. - onConnectTimeout: () => - this.onMetricFn?.("collab_connect_timeouts_total", 1), - }); - try { - return await session.mutate(transform); - } catch (e) { - // Drop the session on any failure so the next call reconnects fresh. - session.destroy("mutate failed"); - throw e; - } - } - - /** - * Generic pagination handler for Docmost API endpoints - */ - async paginateAll( - endpoint: string, - basePayload: Record = {}, - limit: number = 100, - ): Promise { - await this.ensureAuthenticated(); - - const clampedLimit = Math.max(1, Math.min(100, limit)); - - // Hard ceiling on the number of pages to fetch: guards against a server - // that returns a perpetually-true hasNextPage (which would otherwise loop - // forever and accumulate duplicates). - const MAX_PAGES = 50; - - let cursor: string | undefined; - let allItems: T[] = []; - let truncated = false; - - for (let page = 0; page < MAX_PAGES; page++) { - const payload: Record = { - ...basePayload, - limit: clampedLimit, - }; - if (cursor) payload.cursor = cursor; - - const response = await this.client.post(endpoint, payload); - - const data = response.data; - const items = data.data?.items || data.items || []; - const meta = data.data?.meta || data.meta; - - allItems = allItems.concat(items); - - // Advance strictly via the server-issued cursor. A missing nextCursor (or - // hasNextPage false) means we reached the end. A cursor identical to the - // one we just sent means the server did not understand our pagination - // param — stop instead of re-fetching page one forever and duplicating. - const next = meta?.hasNextPage ? meta?.nextCursor : null; - if (!next || next === cursor) { - // If the server still reports more pages but stopped issuing a usable - // cursor at the ceiling, flag the result as truncated below. - if (page === MAX_PAGES - 1 && meta?.hasNextPage) truncated = true; - break; - } - cursor = next; - - // Reaching the ceiling with more pages still available means the result - // set is truncated. - if (page === MAX_PAGES - 1) truncated = true; - } - - // If the loop stopped because it hit the MAX_PAGES ceiling while the server - // still reported more results, the result set is truncated — warn so the - // caller is not silently handed an incomplete list. - if (truncated) { - console.warn( - `paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`, - ); - } - - return allItems; - } - - async getWorkspace() { - await this.ensureAuthenticated(); - const response = await this.client.post("/workspace/info", {}); - return { - data: filterWorkspace(response.data?.data ?? response.data), - success: response.data.success, - }; - } - - async getSpaces() { - const spaces = await this.paginateAll("/spaces", {}); - return spaces.map((space) => filterSpace(space)); - } - - /** - * List pages in one of two modes. - * - * Default (`tree` false): most recent pages by updatedAt (descending), - * bounded. Fetching the whole space can exceed MCP response/time limits on - * large instances, so a single bounded page of results is returned (default - * 50, max 100) via the `/pages/recent` feed. - * - * Tree (`tree` true): DEPRECATED — prefer `getTree`, which shares this exact - * code path (a single `/pages/tree` request via `enumerateSpacePages` + - * `buildPageTree`) but returns the compact `{pageId, title, children?, - * hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is - * kept for backward compatibility; it REQUIRES `spaceId` (a page tree is - * scoped to one space) and IGNORES `limit` — the whole hierarchy is returned. - * It fetches the tree via `enumerateSpacePages`, which on the fork server - * resolves to a single `/pages/tree` request returning the whole - * permission-filtered flat page set (soft-deleted pages excluded - * server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for - * stock upstream servers that lack `/pages/tree`. - */ - async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) { - await this.ensureAuthenticated(); - - if (tree) { - if (!spaceId) { - throw new Error( - "listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.", - ); - } - const { pages } = await this.enumerateSpacePages(spaceId); - return buildPageTree(pages); - } - - const clampedLimit = Math.max(1, Math.min(100, limit)); - const payload: Record = { limit: clampedLimit, page: 1 }; - if (spaceId) payload.spaceId = spaceId; - const response = await this.client.post("/pages/recent", payload); - const data = response.data; - const items = data.data?.items || data.items || []; - return items.map((page: any) => filterPage(page)); - } - - /** - * Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE - * request — the #443 `getTree` tool. Shares its whole code path with - * `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree` - * (with the cursor-BFS only as a fallback for stock upstream servers that lack - * the endpoint), then `buildPageTree` nests the flat, permission-filtered, - * position-ordered list. No second tree fetch, no per-node BFS. - * - * - `rootPageId` — restrict to that page's subtree; the server seeds the CTE - * with the page itself, so the result is exactly ONE root (the page and its - * descendants). Omit it for the whole space. - * - `maxDepth` — trim the response to that many levels (roots = depth 1) to - * save tokens; the server still returns everything in one request, the cut - * is applied in `buildPageTree` AFTER the full tree is built. A node whose - * children were cut carries `hasChildren: true` (source of truth = the flat - * item's server `hasChildren`) so the caller can descend with a follow-up - * `getTree(spaceId, rootPageId=that node)` call. - * - * Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID - * `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId` - * (a page tree is scoped to one space). - */ - async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) { - await this.ensureAuthenticated(); - if (!spaceId) { - throw new Error( - "getTree: spaceId is required (a page tree is scoped to one space).", - ); - } - const { pages } = await this.enumerateSpacePages(spaceId, rootPageId); - return buildPageTree(pages, { shape: "getTree", maxDepth }); - } - - /** - * "Where am I / what's around" for a single page — the #443 `getPageContext` - * tool. Metadata only (no page content), using exactly TWO server requests: - * - * 1. `POST /pages/breadcrumbs` — a recursive CTE that walks UP from the page. - * The server returns the chain root->page order (it `.reverse()`s the - * child-first walk before responding), INCLUDING the page itself as the - * LAST element. So the last element is the page and everything before it - * is the ancestor chain root->parent. This carries the page's own title - * and spaceId, so no extra page-info fetch is needed for a UUID input. - * 2. `listSidebarPages(spaceId, pageId)` — the page's DIRECT children, - * cursor-paginated (a page with >20 children returns ALL of them, no - * dupes) and in sidebar `position` order, each carrying `hasChildren`. - * - * The input may be a slugId (agents copy them from URLs); it is run through - * `resolvePageId` first, exactly like the other page tools. A UUID input adds - * no request there (short-circuit), keeping the total at two; a slugId input - * adds one unavoidable resolve round-trip. - * - * INVARIANT: only the UUID `pageId` is exposed anywhere — server `id` is - * mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible - * pageId makes the server 404/403, which propagates as a clear tool error - * (never a hollow empty object). - */ - async getPageContext(pageId: string) { - await this.ensureAuthenticated(); - - // Resolve a possibly-slugId input to the canonical UUID (no round-trip for a - // UUID). Errors here (bad/inaccessible id) propagate as a clear tool error. - const pageUuid = await this.resolvePageId(pageId); - - // Request 1: the ancestor chain, root->page, page included as the LAST item. - const response = await this.client.post("/pages/breadcrumbs", { - pageId: pageUuid, - }); - const chain: any[] = (response.data?.data ?? response.data) ?? []; - if (!Array.isArray(chain) || chain.length === 0) { - // The endpoint always includes the page itself, so an empty chain means - // the page is gone/inaccessible — surface a clear error, not {}. - throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`); - } - - // Split: the last element is the page, the rest (root->parent) are the - // breadcrumbs. A root page has no ancestors -> breadcrumbs is []. - const self = chain[chain.length - 1]; - const ancestors = chain.slice(0, -1); - - const page = { - pageId: self.id, - title: self.title, - spaceId: self.spaceId, - }; - const breadcrumbs = ancestors.map((n: any) => ({ - pageId: n.id, - title: n.title, - })); - - // Request 2: direct children in sidebar order, each with hasChildren. - const childItems = await this.listSidebarPages(self.spaceId, pageUuid); - const children = childItems.map((c: any) => ({ - pageId: c.id, - title: c.title, - hasChildren: Boolean(c.hasChildren), - })); - - return { page, breadcrumbs, children }; - } - - /** - * List sidebar pages for a space. With no pageId the request returns the - * space ROOT pages; with a pageId it returns the direct CHILDREN of that - * page. pageId is therefore optional and is only included in the POST body - * when provided (an empty/undefined pageId would otherwise change the - * semantics on the server). - */ - async listSidebarPages(spaceId: string, pageId?: string) { - await this.ensureAuthenticated(); - - // Paginate via the server-issued cursor. The server switched from OFFSET - // (`page`) to CURSOR (`cursor`/`nextCursor`) pagination, and the global - // ValidationPipe(whitelist:true) SILENTLY STRIPS the obsolete `page` field - // — so the old offset loop got the SAME first page every time (with - // hasNextPage stuck true) and dropped every child beyond the first page. - const MAX_PAGES = 50; - let cursor: string | undefined; - let allItems: any[] = []; - let truncated = false; - - for (let i = 0; i < MAX_PAGES; i++) { - // limit: 100 is the server-side Max; cuts request count 5x vs the default 20. - const payload: Record = { spaceId, limit: 100 }; - // Only send pageId when scoping to a page's children; omit it for roots. - if (pageId) payload.pageId = pageId; - if (cursor) payload.cursor = cursor; - - const data = (await this.client.post("/pages/sidebar-pages", payload)).data - ?.data; - allItems = allItems.concat(data?.items ?? []); - - // Advance strictly via the server-issued cursor; a missing/repeated cursor - // means the protocol drifted again — stop instead of looping on page one. - const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null; - if (!next || next === cursor) break; - cursor = next; - - // Reaching the ceiling with more pages still available means the child - // list is truncated (mirrors paginateAll). - if (i === MAX_PAGES - 1) truncated = true; - } - - // Warn on real truncation (ceiling hit while the server still had pages) so - // the caller is not silently handed an incomplete child list. - if (truncated) { - console.warn( - `listSidebarPages: children of "${pageId ?? spaceId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`, - ); - } - - return allItems; - } - - /** - * Enumerate EVERY page in a space (or in a subtree, when rootPageId is given). - * - * Primary path (fork server): a SINGLE `POST /pages/tree` returns the whole - * space (or a subtree) as a flat, permission-filtered list in one request, in - * the exact node shape buildPageTree consumes. This replaces the old - * per-node BFS, which issued N sidebar requests and — after the server moved - * to cursor pagination — silently lost every child past the first sidebar - * page (the obsolete `page` param was stripped by ValidationPipe). - * - * The subtree variant (rootPageId given) INCLUDES the root node itself - * (getPageAndDescendants seeds with id = rootPageId), unlike the old BFS - * which started from the root's children. - * - * Fallback path (stdio mode may target STOCK upstream Docmost, which lacks - * `/pages/tree`): on a 404/405 it falls back to the cursor-based BFS below, - * walking direct children via the fixed cursor listSidebarPages. Safeguards: - * a `visited` Set of page ids prevents re-processing a node (cycles / - * duplicate references), and a hard node cap bounds pathological trees so the - * walk always terminates. - * - * Returns `{ pages, truncated }`. `truncated` is true ONLY when the fallback - * BFS stopped at its MAX_NODES cap — the primary /pages/tree path is uncapped - * and always returns the complete set, so it never reports truncation. - */ - private async enumerateSpacePages( - spaceId: string, - rootPageId?: string, - ): Promise<{ pages: any[]; truncated: boolean }> { - await this.ensureAuthenticated(); - - // Single request replaces the whole BFS: /pages/tree returns the full - // permission-filtered flat page set of a space (or a subtree) at once. This - // path is uncapped, so it is never truncated. - const payload = rootPageId ? { pageId: rootPageId } : { spaceId }; - try { - const response = await this.client.post("/pages/tree", payload); - const pages = (response.data?.data ?? response.data)?.items ?? []; - return { pages, truncated: false }; - } catch (e: any) { - // Only fall back when the endpoint is absent (stock upstream Docmost); - // any other error is a genuine failure and must propagate. - if ( - !axios.isAxiosError(e) || - (e.response?.status !== 404 && e.response?.status !== 405) - ) { - throw e; - } - } - - // Fallback: cursor-based breadth-first walk via listSidebarPages. - const MAX_NODES = 10000; - const result: any[] = []; - const visited = new Set(); - - // Seed with the root node itself when scoping to a subtree, so its own - // comments aren't dropped: the primary /pages/tree seeds - // getPageAndDescendants with id = rootPageId (root included), but - // listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN. - // The `visited` set below prevents a double-add if the root also appears - // among the children. getPageRaw returns a page whose id/title/spaceId are - // exactly what buildPageTree and checkNewComments consume. - if (rootPageId) { - try { - const root = await this.getPageRaw(rootPageId); - if (root?.id) { - result.push(root); - visited.add(root.id); - } - } catch { - // Non-fatal: if the root can't be read, fall through to children-only. - } - } - - // Seed the queue with the starting level (subtree children or roots). - const queue: any[] = await this.listSidebarPages(spaceId, rootPageId); - - while (queue.length > 0 && result.length < MAX_NODES) { - const node = queue.shift(); - if (!node || typeof node !== "object" || !node.id) continue; - - // Skip already-seen ids to guard against cycles / duplicate references. - if (visited.has(node.id)) continue; - visited.add(node.id); - - result.push(node); - - if (node.hasChildren) { - try { - const children = await this.listSidebarPages(spaceId, node.id); - for (const child of children) queue.push(child); - } catch (e: any) { - // A failure fetching one node's children must not abort the whole - // walk: skip this branch and keep enumerating the rest. - } - } - } - - // Truncated only when the cap was hit with the queue still non-empty (real - // truncation, not a natural end at exactly MAX_NODES). - return { - pages: result, - truncated: result.length >= MAX_NODES && queue.length > 0, - }; - } - - /** Raw page info including the ProseMirror JSON content and slugId. */ - async getPageRaw(pageId: string) { - await this.ensureAuthenticated(); - const response = await this.client.post("/pages/info", { pageId }); - return response.data?.data ?? response.data; - } - - /** - * Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`), - * so every collaboration document the MCP opens is named `page.` — the - * SAME name the web editor always uses (`page.${page.id}`). - * - * The agent commonly passes a 10-char public slugId (from URLs/listings) as - * the pageId. The web editor opens the collab doc by UUID, but the MCP used to - * pass that slugId straight into the collab doc name (`page.`). For one - * DB row that produced TWO independent Yjs documents whose debounced stores - * clobbered each other — the agent's edit was silently lost (#260). - * - * A UUID input short-circuits with no network round-trip. A slugId is resolved - * once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so - * repeated edits on the same page add no extra request. - */ - private async resolvePageId(pageId: string): Promise { - if (isUuid(pageId)) return pageId; - const cached = this.pageIdCache.get(pageId); - if (cached) return cached; - const data = await this.getPageRaw(pageId); - const uuid = data?.id; - if (typeof uuid !== "string" || !uuid) { - throw new Error( - `Could not resolve a canonical page id for "${pageId}"`, - ); - } - this.pageIdCache.set(pageId, uuid); - return uuid; - } - - async getPage(pageId: string) { - await this.ensureAuthenticated(); - const resultData = await this.getPageRaw(pageId); - - // Agent read: hide resolved-comment anchors so the agent sees only active - // discussions. Active anchors are kept. (The lossless exportPageMarkdown - // round-trip deliberately does NOT pass this flag — resolved anchors there - // must be preserved.) - let content = resultData.content - ? convertProseMirrorToMarkdown(resultData.content, { - dropResolvedCommentAnchors: true, - }) - : ""; - - // Always fetch subpages to provide context to the agent - let subpages: any[] = []; - try { - // `pageId` may be a slugId, but the sidebar-pages endpoint requires the - // UUID; `resultData.id` holds the resolved UUID returned by getPageRaw. - subpages = await this.listSidebarPages(resultData.spaceId, resultData.id); - } catch (e: any) { - console.warn("Failed to fetch subpages:", e); - } - - // Resolve subpages if the placeholder exists - if (content && content.includes("{{SUBPAGES}}")) { - if (subpages && subpages.length > 0) { - const list = subpages - .map((p: any) => `- [${p.title}](page:${p.id})`) - .join("\n"); - content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`); - } else { - content = content.replace("{{SUBPAGES}}", ""); - } - } - - return { - data: filterPage(resultData, content, subpages), - success: true, - }; - } - - /** Page info + raw ProseMirror JSON content (lossless representation). */ - async getPageJson(pageId: string) { - const data = await this.getPageRaw(pageId); - return { - id: data.id, - slugId: data.slugId, - title: data.title, - parentPageId: data.parentPageId, - spaceId: data.spaceId, - updatedAt: data.updatedAt, - content: data.content || { type: "doc", content: [] }, - }; - } - - /** - * Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring. - * `src` is normalized to `/api/files//`; `this.client.baseURL` - * already ends in `/api`, so we strip the leading `/api` and request the - * relative path with the client's Authorization header. Returns the raw bytes - * and the response Content-Type (mime), defaulting to octet-stream. - * - * The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory; - * the authoritative per-blob cap is enforced by the sandbox `put`. The path is - * resolved via resolveInternalFilePath, which REJECTS (throws) any traversal - * or percent-encoded src that would let an attacker-controlled `attrs.src` - * escape `/api/files/` and reach another internal endpoint (SSRF). That throw - * happens before this.client.get, so a malicious src is counted as a failed - * mirror — it never reaches the network. - */ - private async fetchInternalFile( - src: string, - ): Promise<{ buffer: Buffer; mime: string }> { - const HARD_CEILING = 64 * 1024 * 1024; // 64 MiB memory guard - const relPath = resolveInternalFilePath(src); - const response = await this.client.get(relPath, { - responseType: "arraybuffer", - timeout: 30000, - maxContentLength: HARD_CEILING, - maxBodyLength: HARD_CEILING, - }); - const buffer = Buffer.from(response.data); - if (buffer.length === 0) { - throw new Error(`Empty file response from "${src}"`); - } - const rawCt = response.headers?.["content-type"]; - const mime = - typeof rawCt === "string" && rawCt.length > 0 - ? rawCt.split(";")[0].trim().toLowerCase() - : "application/octet-stream"; - return { buffer, mime }; - } - - /** - * Stash a page's full content into the in-RAM blob sandbox and return ONLY a - * short anonymous URL — the body never enters the model context (this is the - * whole point: ~30KB+ ProseMirror docs blow the model context if passed as a - * tool argument). Every INTERNAL file/image src (the type-agnostic criterion, - * so drawio/excalidraw/video/file nodes are covered too) is mirrored into the - * sandbox and its `src` rewritten to the sandbox URL, so an external consumer - * can fetch the images anonymously. External http(s) srcs are left untouched. - * - * Blobs live in RAM with a short TTL and are cleared on restart — consume the - * URLs within the TTL and one uptime. A failed image fetch never aborts the - * doc: the original src is kept and the failure counted. - * - * Returns { uri, sha256, size, images:{mirrored, failed} }. `uri` and `sha256` - * are for the document blob; `sha256` is also the blob's ETag (integrity). - */ - async stashPage(pageId: string): Promise<{ - uri: string; - sha256: string; - size: number; - images: { mirrored: number; failed: number }; - }> { - if (!this.sandboxPut) { - throw new Error( - "stashPage is unavailable: the blob sandbox is not configured on this server", - ); - } - await this.ensureAuthenticated(); - - // Stash the SAME shape getPageJson returns (id/title/.../content), with a - // deep clone so the rewrite never mutates anything shared. - const pageJson = await this.getPageJson(pageId); - const cloned: any = structuredClone(pageJson); - - // Group internal-file nodes by normalized src so each unique resource is - // fetched + stored ONCE (dedup), and every node sharing that src points at - // the one sandbox blob. Capture each node's ORIGINAL raw src per-node: - // dedup groups nodes whose normalized src is equal even when their raw srcs - // differ (e.g. `/api/files/...` vs the bare `/files/...`), so on a revert we - // must restore each node's own original value, not the group key. - const bySrc = new Map>(); - for (const node of collectInternalFileNodes(cloned.content)) { - const origSrc = String(node.attrs.src); - const src = normalizeFileUrl(origSrc); - const entry = { node, origSrc }; - const group = bySrc.get(src); - if (group) group.push(entry); - else bySrc.set(src, [entry]); - } - - let mirrored = 0; - let failed = 0; - // Record every successful mirror so it can be (a) reverted if its blob gets - // FIFO-evicted by a LATER put in this same stash, and (b) freed if the final - // doc put throws. - const mirrors: Array<{ - uri: string; - entries: Array<{ node: any; origSrc: string }>; - }> = []; - const MAX_CONCURRENCY = 5; - const groups = [...bySrc.entries()]; - for (let i = 0; i < groups.length; i += MAX_CONCURRENCY) { - const batch = groups.slice(i, i + MAX_CONCURRENCY); - await Promise.all( - batch.map(async ([src, entries]) => { - try { - const { buffer, mime } = await this.fetchInternalFile(src); - // put may throw if the blob exceeds the per-blob/total caps. - const stored = this.sandboxPut!(buffer, mime); - for (const entry of entries) entry.node.attrs.src = stored.uri; - mirrors.push({ uri: stored.uri, entries }); - mirrored++; - } catch (err) { - // One bad/oversized image (or a rejected traversal src) must not - // abort the document. Logged unconditionally (never the blob body), - // matching the package's ungated console.warn convention. - failed++; - console.warn( - `stashPage: failed to mirror "${src}": ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - }), - ); - } - - // Revert one mirror's nodes to their original internal srcs and re-count it - // as failed (its blob was FIFO-evicted before the doc could reference it - // safely). - const revertMirror = (mirror: { - uri: string; - entries: Array<{ node: any; origSrc: string }>; - }) => { - for (const entry of mirror.entries) entry.node.attrs.src = entry.origSrc; - mirrored--; - failed++; - console.warn( - `stashPage: mirrored blob ${mirror.uri} was evicted before the doc ` + - `could safely reference it; reverted its src and counted it as failed`, - ); - }; - - // Pre-put reconciliation: an image put earlier in THIS stash can FIFO-evict - // an even-earlier image of the same stash. Drop those from the live set - // first so the first serialized doc is already mostly correct. - let liveMirrors = mirrors; - if (this.sandboxHas) { - liveMirrors = []; - for (const mirror of mirrors) { - if (this.sandboxHas(mirror.uri)) liveMirrors.push(mirror); - else revertMirror(mirror); - } - } - - // Put the document, then reconcile against eviction caused by the doc put - // ITSELF (the doc is newest, FIFO drops oldest = this stash's images). Each - // iteration reverts >=1 mirror, so the loop terminates (worst case: all - // images reverted and the doc references no sandbox image URLs). - let stored: { uri: string; sha256: string; size: number }; - for (;;) { - const docBuf = Buffer.from(JSON.stringify(cloned), "utf8"); - let docStored: { uri: string; sha256: string; size: number }; - try { - docStored = this.sandboxPut(docBuf, "application/json"); - } catch (err) { - // The doc put failed (e.g. doc exceeds the cap). Free this op's image - // blobs instead of leaking them in RAM for the whole TTL, then - // re-throw. - if (this.sandboxEvict) { - for (const mirror of liveMirrors) this.sandboxEvict(mirror.uri); - } - throw err; - } - - if (!this.sandboxHas) { - stored = docStored; - break; - } - const evictedNow = liveMirrors.filter((m) => !this.sandboxHas!(m.uri)); - if (evictedNow.length === 0) { - stored = docStored; - break; - } - // The doc we just stored references now-dead blobs. Revert those nodes, - // drop the stale doc blob, and loop to re-serialize + re-put the - // corrected doc. - for (const mirror of evictedNow) revertMirror(mirror); - liveMirrors = liveMirrors.filter((m) => this.sandboxHas!(m.uri)); - if (this.sandboxEvict) this.sandboxEvict(docStored.uri); - } - return { - uri: stored.uri, - sha256: stored.sha256, - size: stored.size, - images: { mirrored, failed }, - }; - } - - /** - * Compact outline of a page's top-level blocks (no full document body). - * Cheap way to locate sections/tables and grab block ids before drilling in - * with getNode / patchNode / insertNode. - */ - async getOutline(pageId: string) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - return { - pageId, - slugId: data.slugId, - title: data.title, - outline: buildOutline(data.content ?? { type: "doc", content: [] }), - }; - } - - /** - * Fetch a single block for editing by reference: a block id (headings/ - * paragraphs/callouts/images), or `#` to select a top-level block by its - * outline index (the only way to reach tables/rows/cells, which carry no id). - * - * `format` (#413): - * - `"markdown"` (DEFAULT): serialize the block via the canonical converter - * (`{type:"doc",content:[node]}` -> `convertProseMirrorToMarkdown`) — a read - * "for editing": pair it with `patchNode({markdown})` to rewrite the block. - * Comment anchors (``, INCLUDING resolved ones) are - * NOT stripped here (unlike getPage): losing them on write-back would - * orphan the thread. Returns `{ ..., format:"markdown", markdown }`. - * - `"json"`: return the raw ProseMirror subtree as-is (lossless; the previous - * default). Returns `{ ..., format:"json", node }`. - * - * AUTO fallback: a type that cannot be a document top-level child - * (tableRow/tableCell/tableHeader, addressed by `#`) is NOT expressible - * as a standalone markdown document, so a `"markdown"` request for such a node - * transparently falls back to JSON with an explicit `format:"json"` field. The - * check derives from the schema's `doc` contentMatch, so it tracks the schema. - */ - async getNode( - pageId: string, - nodeId: string, - format: "markdown" | "json" = "markdown", - ) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - const hit = getNodeByRef( - data.content ?? { type: "doc", content: [] }, - nodeId, - ); - if (!hit) { - throw new Error( - `getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#" for a top-level block such as a table)`, - ); - } - - // JSON requested (or a non-top-level type that markdown cannot represent as a - // standalone document): return the subtree verbatim. - if (format === "json" || !canBeDocChild(hit.type)) { - return { - pageId, - ref: nodeId, - path: hit.path, - type: hit.type, - format: "json" as const, - node: hit.node, - }; - } - - // Markdown: wrap the node as a one-block doc and run the canonical converter. - // Comment anchors are DELIBERATELY preserved (converter default) so a - // getNode(markdown) -> edit -> patchNode(markdown) round trip does not orphan - // a comment thread; this differs from getPage, which strips them. - const markdown = convertProseMirrorToMarkdown({ - type: "doc", - content: [hit.node], - }); - return { - pageId, - ref: nodeId, - path: hit.path, - type: hit.type, - format: "markdown" as const, - markdown, - }; - } - - /** - * Find every occurrence of `query` on a page IN MEMORY, over the plain text of - * each text container (reusing the same `getPageRaw` fetch as the other read - * tools) — no server search endpoint, no whole-document round-trip through the - * model. Returns `{ total, truncated, matches }`; each match carries a ref for - * getNode/patchNode (the `#` form resolves with getNode but NOT - * patchNode — see SearchMatch.nodeId), plus the top-level block index and a - * short context window used to build a unique text `selection` for - * createComment (createComment has no nodeId param). The pure engine - * (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine - * and the empty-query / invalid-or-unsupported-regex errors. - */ - async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - const result = searchInDoc( - data.content ?? { type: "doc", content: [] }, - query, - opts, - ); - return { pageId, query, ...result }; - } - - /** - * Read a table as a matrix. `tableRef` is `#` (from getOutline) or a - * block id of any node inside the table. Returns the cell texts plus a - * parallel cellIds matrix (each cell's first paragraph id, or null) so a - * caller can patchNode a cell for rich-formatted edits. Throws when no table - * resolves for the reference. - */ - async getTable(pageId: string, tableRef: string) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef); - if (!t) { - throw new Error( - `tableGet: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, - ); - } - return { - pageId, - table: tableRef, - rows: t.rows, - cols: t.cols, - path: t.path, - cells: t.cells, - cellIds: t.cellIds, - }; - } - - /** - * Insert a row of plain-text cells into a table on the LIVE collab document. - * `tableRef` is `#` or a block id inside the target table. `cells` is - * padded to the table's column count (more cells than columns throws); `index` - * is a 0-based insert position (omit/out-of-range to append). Throws when no - * table resolves for the reference. - */ - async tableInsertRow( - pageId: string, - tableRef: string, - cells: string[], - index?: number, - ) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - // Track insertion in an outer var, reset per-transform, so a collab retry - // recomputes it cleanly (mirrors insertNode's pattern). - let inserted = false; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - inserted = false; - const { doc: nd, inserted: ins } = insertTableRow( - liveDoc, - tableRef, - cells, - index, - ); - inserted = ins; - if (!inserted) return null; // table not found -> skip the write entirely - return nd; - }, - ); - - if (!inserted) { - throw new Error( - `tableInsertRow: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, - ); - } - return { - success: true, - table: tableRef, - inserted: true, - verify: mutation.verify, - }; - } - - /** - * Delete the row at 0-based `index` from a table on the LIVE collab document. - * `tableRef` is `#` or a block id inside the target table. The helper's - * out-of-range and last-row errors propagate; a missing table throws here. - */ - async tableDeleteRow(pageId: string, tableRef: string, index: number) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - let deleted = false; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - deleted = false; - const { doc: nd, deleted: del } = deleteTableRow( - liveDoc, - tableRef, - index, - ); - deleted = del; - if (!deleted) return null; // table not found -> skip the write entirely - return nd; - }, - ); - - if (!deleted) { - throw new Error( - `tableDeleteRow: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, - ); - } - return { - success: true, - table: tableRef, - deleted: true, - verify: mutation.verify, - }; - } - - /** - * Set the plain-text content of cell `[row, col]` (0-based) in a table on the - * LIVE collab document, replacing the cell's content with a single text - * paragraph (the cell's first-paragraph id is preserved). `tableRef` is - * `#` or a block id inside the target table. The helper's out-of-range - * error propagates; a missing table throws here. - */ - async tableUpdateCell( - pageId: string, - tableRef: string, - row: number, - col: number, - text: string, - ) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - let updated = false; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - updated = false; - const { doc: nd, updated: upd } = updateTableCell( - liveDoc, - tableRef, - row, - col, - text, - ); - updated = upd; - if (!updated) return null; // table not found -> skip the write entirely - return nd; - }, - ); - - if (!updated) { - throw new Error( - `tableUpdateCell: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, - ); - } - return { - success: true, - table: tableRef, - row, - col, - verify: mutation.verify, - }; - } - - /** - * Create a new page with title and content. - * Uses the /pages/import workaround (the only endpoint accepting content), - * then moves the page and restores the exact title: the import endpoint - * derives the title from the FILENAME and replaces spaces with - * underscores, so we explicitly re-set it via /pages/update afterwards. - */ - async createPage( - title: string, - content: string, - spaceId: string, - parentPageId?: string, - ) { - await this.ensureAuthenticated(); - - if (parentPageId) { - try { - await this.getPage(parentPageId); - } catch (e) { - throw new Error(`Parent page with ID ${parentPageId} not found.`); - } - } - - // 1. Create content via Import (using multipart/form-data). - // Build a FRESH FormData per send attempt: a FormData body is a single-use - // stream consumed on the first send, so it cannot be replayed by - // this.client's response interceptor (replay fails with 'socket hang up'). - // Multipart re-auth is therefore done here with bare axios and an explicit - // one-shot 401/403 retry that rebuilds the body. - const fileContent = Buffer.from(content, "utf-8"); - const buildForm = () => { - const form = new FormData(); - form.append("spaceId", spaceId); - form.append("file", fileContent, { - filename: `${title || "import"}.md`, - contentType: "text/markdown", - }); - return form; - }; - - const importUrl = `${this.apiUrl}/pages/import`; - let response; - try { - // Call buildForm() ONCE per attempt and reuse the instance for both - // getHeaders() and the body so the Content-Type boundary matches the body. - const form = buildForm(); - // Read the Authorization header from this.client's defaults (set by - // login(), only ever deleted — never set to null) instead of building - // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token - // mid-flight, which would otherwise produce a literal "Bearer null". - // ensureAuthenticated() above guarantees login() ran, so the default - // header exists here. - response = await axios.post(importUrl, form, { - headers: { - ...form.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } catch (error) { - // On an expired-token auth error, re-login and retry exactly once with a - // freshly-rebuilt FormData (the previous one was already consumed). - if ( - axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403) - ) { - await this.login(); - const form2 = buildForm(); - response = await axios.post(importUrl, form2, { - headers: { - ...form2.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } else { - throw error; - } - } - const newPageId = (response.data?.data ?? response.data).id; - - // 2. Move to parent if needed - if (parentPageId) { - await this.movePage(newPageId, parentPageId); - } - - // 3. Restore the exact title (import mangles spaces into underscores) - if (title) { - await this.client.post("/pages/update", { pageId: newPageId, title }); - } - - const page = await this.getPage(newPageId); - // Surface non-fatal footnote problems (dangling refs, empty/duplicate - // definitions, markers in tables) so the agent can fix its markup (#166). - return { ...page, ...footnoteWarningsField(content) }; - } - - /** - * Update a page's content from markdown and optionally its title. - * NOTE: full re-import — block ids regenerate. For surgical changes - * use editPageText / updatePageJson instead. - */ - async updatePage(pageId: string, content: string, title?: string) { - await this.ensureAuthenticated(); - // Open the collab doc by the canonical UUID, never the slugId (#260). The - // REST /pages/update title write below keeps the agent-supplied id (the - // server resolves a slugId there). - const pageUuid = await this.resolvePageId(pageId); - - // Write the BODY first, then the title (#159 split-brain). If the collab - // body write fails (e.g. a persist timeout), the title must be left - // UNTOUCHED so the page never ends up with a new title over its old body. - // A title write failing AFTER a successful body is rarer (REST is fast) and - // leaves correct content under a stale title — the lesser inconsistency. - let collabToken = ""; - let mutation; - try { - collabToken = await this.getCollabTokenWithReauth(); - mutation = await updatePageContentRealtime( - pageUuid, - content, - collabToken, - this.apiUrl, - ); - } catch (error: any) { - // Verbose diagnostics (incl. anything that could expose a token prefix) - // are gated behind DEBUG; the thrown Error below carries no token data. - if (process.env.DEBUG) { - console.error( - "Failed to update page content via realtime collaboration:", - error, - ); - const tokenPreview = collabToken - ? collabToken.substring(0, 15) + "..." - : "null"; - console.error(`Collab token preview: ${tokenPreview}`); - } - throw new Error(`Failed to update page content: ${error.message}`); - } - - // Body persisted successfully — now it is safe to set the title. - if (title) { - await this.client.post("/pages/update", { pageId, title }); - } - - return { - success: true, - modified: true, - message: "Page updated successfully.", - pageId: pageId, - verify: mutation.verify, - // Non-fatal footnote diagnostics (#166); omitted when there are none. - ...footnoteWarningsField(content), - }; - } - - /** - * Validate a URL string against a scheme allowlist for a given context. - * - * The markdown link path enforces safe schemes via TipTap, but the raw - * JSON path (updatePageJson) bypasses that — so this is the sanitization - * choke point for ProseMirror JSON written directly by the caller. - * - * - "link": reject javascript:, vbscript:, data: (any scheme that can - * execute or smuggle script when the href is clicked). - * - "src": allow only http(s):, mailto:, /api/files paths, or a - * scheme-less relative/absolute path; reject - * javascript:/vbscript:/data:/file:. - */ - private isSafeUrl(url: unknown, context: "link" | "src"): boolean { - if (typeof url !== "string") return false; - const trimmed = url.trim(); - if (trimmed === "") return true; // empty href/src is harmless - - // Extract a leading "scheme:" if present. A scheme must start with a - // letter and contain only letters/digits/+/-/. before the colon. Strip - // whitespace and ASCII control chars first so a tab/newline embedded in - // the scheme cannot smuggle a dangerous scheme past the check. - const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, ""); - const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned); - const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; - - const dangerous = new Set(["javascript", "vbscript", "data", "file"]); - - if (context === "link") { - if (scheme === null) return true; // relative/anchor link is fine - // For links, data: is also blocked (can carry script payloads). - return !new Set(["javascript", "vbscript", "data"]).has(scheme); - } - - // context === "src" - if (scheme === null) return true; // relative/absolute path (incl. /api/files) - if (dangerous.has(scheme)) return false; - return scheme === "http" || scheme === "https" || scheme === "mailto"; - } - - /** - * Recursively walk a ProseMirror doc and reject any unsafe URL on a link - * mark href or on a media node's src/url. Media nodes covered: image, - * attachment, video, plus embed (rendered as an iframe), youtube, drawio - * and excalidraw — all of which carry a user-controlled URL that Docmost - * renders. Throws a clear error on the first violation. A max-depth guard - * turns an over-deep document into a clean error instead of a RangeError - * stack overflow. - */ - private validateDocUrls(node: any, depth: number = 0): void { - const MAX_DEPTH = 200; - if (depth > MAX_DEPTH) { - throw new Error( - `document nesting exceeds the maximum depth of ${MAX_DEPTH}`, - ); - } - if (!node || typeof node !== "object") return; - - // Link marks on text nodes: validate the href. - if (Array.isArray(node.marks)) { - for (const mark of node.marks) { - if (mark && mark.type === "link" && mark.attrs) { - if (!this.isSafeUrl(mark.attrs.href, "link")) { - throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`); - } - } - } - } - - // Media nodes: validate src/url against the stricter src allowlist. - // embed renders as an iframe (highest risk); youtube/drawio/excalidraw - // likewise carry a user-controlled URL Docmost renders, so they get the - // same scheme check as image/attachment/video. - if ( - node.type === "image" || - node.type === "attachment" || - node.type === "video" || - node.type === "embed" || - node.type === "youtube" || - node.type === "drawio" || - node.type === "excalidraw" || - node.type === "audio" || - node.type === "pdf" - ) { - const attrs = node.attrs || {}; - for (const key of ["src", "url"]) { - if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) { - throw new Error( - `unsafe ${node.type} ${key} rejected: "${attrs[key]}"`, - ); - } - } - } - - if (Array.isArray(node.content)) { - for (const child of node.content) { - this.validateDocUrls(child, depth + 1); - } - } - } - - /** - * Recursively validate the STRUCTURE of a ProseMirror node (reuses the - * recursion shape of validateDocUrls). Every node must be an object with a - * string `type`; when present, `content` must be an array, `marks` must be - * an array of objects each with a string `type`, and a text node's `text` - * must be a string. Throws a clear "invalid ProseMirror document" error on - * the first violation. A max-depth guard turns an over-deep document into a - * clean error instead of a RangeError stack overflow. - */ - private validateDocStructure(node: any, depth: number = 0): void { - const MAX_DEPTH = 200; - if (depth > MAX_DEPTH) { - throw new Error( - `invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`, - ); - } - if (!node || typeof node !== "object" || typeof node.type !== "string") { - throw new Error( - "invalid ProseMirror document: every node must be an object with a string `type`", - ); - } - if ( - "text" in node && - node.type === "text" && - typeof node.text !== "string" - ) { - throw new Error( - "invalid ProseMirror document: a text node must have a string `text`", - ); - } - if (node.marks !== undefined) { - if (!Array.isArray(node.marks)) { - throw new Error( - "invalid ProseMirror document: `marks` must be an array", - ); - } - for (const mark of node.marks) { - if ( - !mark || - typeof mark !== "object" || - typeof mark.type !== "string" - ) { - throw new Error( - "invalid ProseMirror document: every mark must be an object with a string `type`", - ); - } - } - } - if (node.content !== undefined) { - if (!Array.isArray(node.content)) { - throw new Error( - "invalid ProseMirror document: `content` must be an array when present", - ); - } - for (const child of node.content) { - this.validateDocStructure(child, depth + 1); - } - } - } - - /** - * Pre-write SHAPE gate (#409). Walk the WHOLE node tree with the shared - * `findInvalidNode` and throw a rich, path-anchored error the instant a nested - * node has an absent/unknown `type` (or an unknown mark) — the exact shape that - * otherwise surfaces DEEP in the Yjs encode as the cryptic - * `Unknown node type: undefined`, but only AFTER a collab session was opened - * and a page lock taken. Calling this BEFORE `getCollabTokenWithReauth` / - * `mutatePageContent` fails fast: no collab connection, no lock, deterministic - * message. `op` names the tool for the message prefix (e.g. "patchNode"). - * - * `findInvalidNode` derives its "known type" set from the very same - * `docmostExtensions` the encode path uses, so a node this gate accepts is one - * the encoder will accept too. - */ - private assertValidNodeShape(op: string, node: any): void { - const bad = findInvalidNode(node); - if (bad) { - throw new Error(`${op}: invalid node — ${bad.summary}`); - } - } - - /** - * Replace page content with a raw ProseMirror JSON document (lossless) and/or - * update its title. Both `doc` and `title` are optional, but at least one must - * be supplied: - * - `doc` provided -> validate + full-overwrite the body (and update the - * title too when `title` is also given). - * - `doc` omitted, `title` given -> title-only update; the body is NOT - * touched/resent (no collab write happens). - * - neither given -> throws (nothing to update). - */ - async updatePageJson(pageId: string, doc?: any, title?: string) { - await this.ensureAuthenticated(); - - // Title-only / no-op handling: when no document is supplied, do NOT write - // the body. Update the title if one was given; otherwise there is nothing - // to do, so fail loudly rather than silently no-op. - if (doc == null) { - if (!title) { - throw new Error( - "updatePageJson: nothing to update (provide content and/or title)", - ); - } - await this.client.post("/pages/update", { pageId, title }); - return { - success: true, - modified: true, - message: "Page title updated (content left unchanged).", - pageId, - }; - } - - // Validate the document shape before a full overwrite: a malformed doc - // would otherwise silently corrupt the page (full-overwrite is the - // documented behaviour; no optimistic-concurrency is applied here). - if ( - typeof doc !== "object" || - doc.type !== "doc" || - !Array.isArray(doc.content) - ) { - throw new Error( - 'content must be a ProseMirror document ({"type":"doc","content":[...]}) ' + - "where content is an array of nodes each having a string `type`", - ); - } - - // Recurse the WHOLE document so a malformed nested node (e.g. a node with a - // non-string type, a non-array content/marks, or a text node missing its - // string text) is rejected up front rather than silently corrupting the - // page on overwrite. - this.validateDocStructure(doc); - - // #409: beyond the string-`type` check above, reject a nested node whose - // `type` is a string but NOT a known Docmost schema node (a typo/unknown - // block) — the same `Unknown node type` the encoder throws — with a rich, - // path-anchored message, still BEFORE any collab connection. - this.assertValidNodeShape("updatePageJson", doc); - - // Sanitize URLs before writing. This closes the JSON-path bypass: unlike - // the markdown link path (which TipTap sanitizes), raw JSON could otherwise - // inject javascript:/data: link hrefs or media srcs straight into the doc. - this.validateDocUrls(doc); - - // Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot - // leave footnotes out of order, orphaned, or in multiple lists — the bottom - // list + numbering are always derived from reference order. No-op when the - // footnotes are already canonical. - // #419: normalize + merge glyph-forked definitions before canonicalizing. - doc = normalizeAndMergeFootnotes(doc); - doc = canonicalizeFootnotes(doc); - - // Write the BODY first, then the title (#159 split-brain): a failed body - // write (e.g. persist timeout) must not leave a new title over the old body. - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - const mutation = await this.replacePage( - pageUuid, - doc, - collabToken, - this.apiUrl, - ); - - // Body persisted successfully — now it is safe to set the title. - if (title) { - await this.client.post("/pages/update", { pageId, title }); - } - - return { - success: true, - modified: true, - message: "Page content replaced from ProseMirror JSON.", - pageId, - verify: mutation.verify, - }; - } - - /** - * AUTHOR-INLINE footnote insertion. The agent supplies only WHERE - * (`anchorText`, a snippet of body text to attach the marker after) and WHAT - * (`text`, the footnote content as markdown). Numbering and the bottom - * `footnotesList` are derived deterministically server-side - * (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees, - * assigns, or edits a footnote number or the list, so it CANNOT desync. - * - * Content DEDUP: when an existing definition has the same content, its id is - * reused (one number, one definition, several references). The write is atomic - * via `mutatePageContent` (single-writer, page-locked); if the anchor text is - * not found the transform aborts with a clear error and no write happens. - */ - async insertFootnote(pageId: string, anchorText: string, text: string) { - await this.ensureAuthenticated(); - if (!anchorText || !anchorText.trim()) { - throw new Error("insertFootnote: anchorText is required"); - } - if (text == null || `${text}`.trim() === "") { - throw new Error("insertFootnote: text is required"); - } - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - let result: { footnoteId: string; reused: boolean } | null = null; - const mutation = await this.mutatePage( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc: any) => { - const r = insertInlineFootnote(liveDoc, { anchorText, text }); - if (!r.inserted) { - // Abort the page-locked write by throwing: mutatePageContent does not - // persist when the transform throws, so a missing anchor leaves the - // page untouched (no partial write). - throw new Error( - `insertFootnote: anchor text not found: ${JSON.stringify( - anchorText.slice(0, 80), - )}`, - ); - } - result = { footnoteId: r.footnoteId, reused: r.reused }; - return r.doc; - }, - ); - // The not-found path throws inside the transform (aborting mutatePage), so by - // here `result` is always set. - const r = result!; - return { - success: true, - modified: true, - pageId, - footnoteId: r.footnoteId, - reused: r.reused, - message: r.reused - ? "Footnote inserted (reused an existing same-content definition)." - : "Footnote inserted.", - verify: mutation.verify, - }; - } - - /** - * Page-locked write seam over collaboration.mutatePageContent. Production just - * delegates; it exists as an overridable method so the insertFootnote wrapper - * (transform abort-on-not-found + response shaping) can be unit-tested without - * standing up a live Hocuspocus collab socket. - * - * SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then- - * lock"): every write must lock and key its CollabSession by the UUID, never a - * raw slugId (#260). resolvePageId is cached/idempotent, so a caller that - * already resolved pays no extra round-trip; centralizing it here means a - * caller that reaches this seam with a raw slugId still locks correctly instead - * of silently splitting the mutex key. withPageLock also asserts the key is a - * UUID as a hard backstop. - */ - protected async mutatePage( - pageId: string, - collabToken: string, - apiUrl: string, - transform: (doc: any) => any, - ): Promise<{ doc?: any; verify?: any }> { - const pageUuid = await this.resolvePageId(pageId); - return mutatePageContent(pageUuid, collabToken, apiUrl, transform); - } - - /** - * Full-document write seam over collaboration.replacePageContent. Production - * just delegates; it exists as an overridable method so the full-doc write - * tools (updatePageJson, copyPageContent) can have their footnote- - * canonicalization binding unit-tested without a live Hocuspocus collab socket. - * - * SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then- - * lock") for the same reason as mutatePage above — the lock/CollabSession key - * is guaranteed canonical here, not left to the caller's discipline. - */ - protected async replacePage( - pageId: string, - doc: any, - collabToken: string, - apiUrl: string, - ): Promise<{ doc?: any; verify?: any }> { - const pageUuid = await this.resolvePageId(pageId); - return replacePageContent(pageUuid, doc, collabToken, apiUrl); - } - - /** - * Export a page to a single self-contained Docmost-flavoured markdown file: - * meta block + body (with inline comment anchors + diagrams) + comment - * threads. Lossless round-trip target; see importPageMarkdown for the inverse. - */ - async exportPageMarkdown(pageId: string): Promise { - await this.ensureAuthenticated(); - const page = await this.getPageRaw(pageId); - const body = page.content ? convertProseMirrorToMarkdown(page.content) : ""; - let comments: any[] = []; - try { - // Lossless export: include RESOLVED threads so the export -> import - // round-trip preserves every comment. This is exactly why the active-only - // filter is an opt-in (default false) on listComments. - comments = (await this.listComments(pageId, true)).items; - } catch (e) { - // A comments fetch failure must not lose the body; export with [] and let - // the caller see the (empty) comments block. Log under DEBUG only. - if (process.env.DEBUG) console.error("export: listComments failed", e); - } - const meta = { - version: 1, - pageId: page.id, - slugId: page.slugId, - title: page.title, - spaceId: page.spaceId, - parentPageId: page.parentPageId ?? null, - }; - return serializeDocmostMarkdown(meta, body, comments); - } - - /** - * Import a self-contained Docmost markdown file back into a page. Parses out - * the meta + comments metadata blocks, converts the body to ProseMirror - * (restoring comment marks + diagrams from their inline HTML), and replaces - * the page content. Comment THREAD records are NOT written to the server in - * this version — they are preserved in the file and the inline marks are - * re-applied so the highlights survive; managing comment records stays with - * the comment tools/UI. - */ - async importPageMarkdown(pageId: string, fullMarkdown: string): Promise { - await this.ensureAuthenticated(); - const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown); - // PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical). - const doc = await markdownToProseMirrorCanonical(body); - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - const mutation = await replacePageContent( - pageUuid, - doc, - collabToken, - this.apiUrl, - ); - // Collect distinct comment ids that actually became comment marks in the doc. - const collectCommentIds = (node: any, acc: Set): Set => { - if (!node || typeof node !== "object") return acc; - if (Array.isArray(node.marks)) { - for (const mk of node.marks) { - if (mk && mk.type === "comment" && mk.attrs?.commentId) { - acc.add(mk.attrs.commentId); - } - } - } - if (Array.isArray(node.content)) { - for (const child of node.content) collectCommentIds(child, acc); - } - return acc; - }; - // Count reflects the comment marks present in the written document, so an id - // that only appears as inert text (e.g. inside a fenced code block) is not - // counted because it never becomes a comment mark. - const anchoredIds = collectCommentIds(doc, new Set()); - const result: any = { - success: true, - pageId, - anchoredCommentCount: anchoredIds.size, - commentsInFile: Array.isArray(comments) ? comments.length : 0, - verify: mutation.verify, - }; - // Warn (non-fatal) if the file was exported from a DIFFERENT page. - if (meta?.pageId && meta.pageId !== pageId) { - result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`; - } - // Non-fatal footnote diagnostics (#166), analyzed on the BODY (the part after - // the docmost:meta / docmost:comments blocks) — so a `[^x]`-like token inside - // those JSON blocks never produces a false warning, while real markers in the - // body do. `body` comes from parseDocmostMarkdown(fullMarkdown) above. - Object.assign(result, footnoteWarningsField(body)); - return result; - } - - /** - * Rename a page (change its title only) without touching or resending its - * content. The slug is derived from the page record, not the body, so it is - * left intact too. - */ - async renamePage(pageId: string, title: string) { - await this.ensureAuthenticated(); - await this.client.post("/pages/update", { pageId, title }); - return { success: true, pageId, title }; - } - - /** - * Copy the WHOLE content of one page onto another, entirely server-side: the - * source's ProseMirror document is read and written verbatim onto the target - * via the live collab path, so the document never passes through the model. - * - * Only the target's BODY is replaced — its title and slug live on the page - * record (not in the content), so they are untouched. The source page is not - * modified at all. - */ - async copyPageContent(sourcePageId: string, targetPageId: string) { - await this.ensureAuthenticated(); - - // A self-copy would be a no-op overwrite; reject it explicitly so a caller - // mistake surfaces as a clear error rather than a silent round-trip. - if (sourcePageId === targetPageId) { - throw new Error( - "copyPageContent: sourcePageId and targetPageId are the same page (no-op copy)", - ); - } - - const source = await this.getPageRaw(sourcePageId); - const content = source?.content; - if ( - !content || - typeof content !== "object" || - content.type !== "doc" || - !Array.isArray(content.content) - ) { - throw new Error( - `copyPageContent: source page ${sourcePageId} has no usable ProseMirror content to copy`, - ); - } - - // Defense-in-depth: run the same URL-scheme sanitizer the JSON write path - // uses, so copying never lands a javascript:/data: href/src on the target - // (parity with updatePageJson; harmless for already-stored source content). - this.validateDocUrls(content); - - // Defense-in-depth (#228): this is a FULL-document write, so canonicalize - // footnotes before copying — a no-op on already-canonical source content, but - // it guarantees a copy can never propagate a non-canonical footnote topology - // to the target (parity with the other full-doc write paths). - // #419: normalize + merge glyph-forked definitions before canonicalizing. - const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content)); - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the TARGET collab doc by its canonical UUID, never the slugId (#260). - const targetUuid = await this.resolvePageId(targetPageId); - const mutation = await this.replacePage( - targetUuid, - canonical, - collabToken, - this.apiUrl, - ); - - return { - success: true, - sourcePageId, - targetPageId, - copiedNodes: canonical.content.length, - verify: mutation.verify, - }; - } - - /** - * Surgical text edits: find/replace inside text nodes of the live - * document. Preserves all block ids, marks, callouts and tables. - */ - async editPageText(pageId: string, edits: TextEdit[]) { - await this.ensureAuthenticated(); - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - // Apply the edits against the LIVE synced document, not the debounced REST - // snapshot, so concurrent human edits/comments are preserved. applyTextEdits - // records per-edit match problems in `failed` instead of throwing, and - // applies whatever it can; we abort the write only when nothing applied. - let results: TextEditResult[] | undefined; - let failed: TextEditFailure[] | undefined; - // Whether we actually wrote new content. Set inside the transform: a - // degenerate edit (e.g. find === replace, or a batch that nets to no change) - // can "apply" yet leave the document byte-for-byte identical, in which case - // we must NOT write (no spurious history version) and must not claim a write - // happened. - let wrote = false; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - wrote = false; - const r = applyTextEdits(liveDoc, edits); - results = r.results; - failed = r.failed; - // Nothing applied -> abort the write (mutatePageContent treats a null - // return from the transform as "write nothing"). - if (r.results.length === 0) return null; - // Edits "applied" but produced an identical document: skip the write so - // no new history version is created. Stable structural comparison via - // JSON.stringify (both docs come from the same deep-copied source, so - // key order is stable). - if (JSON.stringify(r.doc) === JSON.stringify(liveDoc)) return null; - wrote = true; - return r.doc; - }, - ); - - if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) { - // No edit applied: surface an aggregated, actionable error so the caller - // does not mistake a no-op for a partial success. - throw new Error( - "editPageText: no edits were applied (nothing written). " + - failed!.map((f) => `"${f.find}": ${f.reason}`).join("; "), - ); - } - - // Edits matched but produced no content change (identical document): report - // a successful no-op — NOT a failure — and do not falsely claim a write. - if (!wrote) { - return { - success: true, - pageId, - applied: results, - failed, - message: "No changes written (edits produced identical content).", - verify: mutation.verify, - }; - } - - const result: any = { - success: true, - pageId, - applied: results, - failed, - message: - (failed?.length ?? 0) - ? `Applied ${results?.length ?? 0} edit(s); ${failed!.length} failed (see failed[]). Node ids and formatting preserved.` - : "Text edits applied (node ids and formatting preserved).", - verify: mutation.verify, - }; - - // If any applied edit matched only after stripping markdown (the - // normalized fallback), warn that editPageText preserved existing marks - // and did NOT change formatting — so a caller who intended a formatting - // change is pointed at patchNode. - if (results?.some((r) => r.normalized === true)) { - result.warning = - "Some edits matched only after stripping markdown from your find string; " + - "editPageText preserved existing marks (it did not change bold/strike/etc.). " + - "If you intended a formatting change, use patchNode."; - } - - return result; - } - - /** - * Replace the block whose attrs.id === nodeId. Operates on the LIVE collab - * document so comments and concurrent edits are preserved. - * - * Exactly one of `input.markdown` / `input.node` (#413): - * - `markdown` (RECOMMENDED): the block is rewritten from a canonical markdown - * fragment. The fragment may import to N blocks (a "1 -> N" splice: rewrite a - * whole section in one call). The FIRST resulting block INHERITS the target's - * `attrs.id` (so an existing comment anchoring the block by id survives); the - * rest get FRESH ids. `^[...]` footnotes in the fragment are first-class: - * their definitions merge into the page's TAIL footnote list (content-key - * dedup + canonicalize), same machinery insertFootnote uses. REJECTED when - * the TARGET block carries a table-cell attribute markdown cannot represent - * (colspan/rowspan/colwidth/background) — use the table tools or `node`. - * - `node`: a raw ProseMirror node for precise attr/mark work. The replacement - * keeps the target id (if `node.attrs.id` is missing it is set to nodeId). - * - * #159 ambiguous-id semantics are unchanged: 0 matches -> "no node"; >1 matches - * -> "ambiguous, refused" (nothing written), on BOTH paths — the markdown path - * runs a dry `replaceNodeById` count first, so a duplicated id never splices. - */ - async patchNode( - pageId: string, - nodeId: string, - input: { markdown?: string; node?: any }, - ) { - await this.ensureAuthenticated(); - - // XOR: exactly one of markdown / node. Both optional in the schema; the - // runtime enforces the recommendation ("markdown for prose, node for fine - // work") without letting an ambiguous both-or-neither call through. - const hasMd = - input != null && - typeof input.markdown === "string" && - input.markdown.trim() !== ""; - const hasNode = input != null && input.node != null; - if (hasMd === hasNode) { - throw new Error( - "patchNode: provide exactly one of `markdown` (recommended, for prose) " + - "or `node` (a raw ProseMirror node, for precise attr/mark work)", - ); - } - - if (hasMd) { - return this.patchNodeMarkdown(pageId, nodeId, input.markdown as string); - } - return this.patchNodeJson(pageId, nodeId, input.node); - } - - /** - * patchNode with a raw ProseMirror `node` (the pre-#413 behavior). Replaces - * EVERY node whose attrs.id === nodeId; the swapped-in node keeps the target - * id. #159 ambiguity refused. Split out so the markdown path can reuse the - * shared collab/guard plumbing without a giant branch. - */ - private async patchNodeJson(pageId: string, nodeId: string, node: any) { - if (!node || typeof node !== "object" || typeof node.type !== "string") { - throw new Error( - "patchNode: `node` must be an object with a string `type`", - ); - } - // Preserve the block id WITHOUT mutating the caller's object: build a local - // copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the - // node it replaces). - const target = { - ...node, - attrs: { - ...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}), - }, - }; - if (target.attrs.id == null) { - target.attrs.id = nodeId; - } - - // #409: fail fast on a malformed node SHAPE (a nested child with an - // absent/unknown `type`, e.g. a text leaf written as `{"text":"foo"}` with - // no `"type":"text"`) BEFORE opening a collab session or taking the page - // lock — the root-only `typeof node.type === "string"` check above never - // sees nested children, and the encoder's `Unknown node type: undefined` - // would otherwise only surface after the connection. - this.assertValidNodeShape("patchNode", target); - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - // Track the replacement count in an outer var, reset per-transform, so a - // collab retry recomputes it cleanly (mirrors replaceImage's pattern). - let replaced = 0; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - replaced = 0; - const { doc: nd, replaced: r } = replaceNodeById( - liveDoc, - nodeId, - target, - ); - replaced = r; - // 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost - // duplicates block ids on copy/paste (and copyPageContent writes them - // verbatim), so replacing "the node with id X" would silently clobber - // EVERY duplicate (#159). Refuse: skip the write and throw below so the - // model re-targets with a more specific anchor instead of corrupting the - // page. Only an unambiguous single match is written. - if (replaced !== 1) return null; - return nd; - }, - ); - - // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped - // the write for any count !== 1). Single shared guard (#159, #185 review). - assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId); - - return { success: true, replaced, nodeId, verify: mutation.verify }; - } - - /** - * patchNode with a MARKDOWN fragment (#413). Imports the fragment through the - * canonical importer, then 1 -> N splices the resulting blocks in place of the - * target block on the LIVE collab doc: - * - the FIRST block inherits the target's id; the rest get FRESH ids (minted - * by the importer/id-remap, so neighbour blocks are untouched); - * - `^[...]` footnote definitions merge into the page's tail list; - * - REJECTED when the target block carries a markdown-unrepresentable table - * attr (colspan/rowspan/colwidth/background) — guarding against silent loss; - * - #159 ambiguity is enforced by a dry `replaceNodeById` count BEFORE the - * splice, so a duplicated id never writes. - */ - private async patchNodeMarkdown( - pageId: string, - nodeId: string, - markdown: string, - ) { - // Import the fragment up front (network-free, canonical) so a bad fragment - // fails before any collab connection or page lock. - const { blocks, definitions } = await importMarkdownFragment(markdown); - - // The first imported block inherits the target id; the rest keep the fresh - // ids the importer assigned. Build the thread now so it is stable across a - // collab retry (the transform below is pure over its inputs). - const threaded = blocks.map((b, i) => { - if (i !== 0) return b; - return { - ...b, - attrs: { - ...(b && typeof b.attrs === "object" ? b.attrs : {}), - id: nodeId, - }, - }; - }); - - // Shape-validate every imported block up front (parity with the JSON path): - // the importer only emits schema nodes, but the check is cheap insurance and - // yields the same rich #409 diagnostics if the schema ever drifts. - for (const b of threaded) { - this.assertValidNodeShape("patchNode", b); - } - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - let replaced = 0; - let guardAttrs: string | null = null; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - replaced = 0; - guardAttrs = null; - - // #159: count matches with the same recursive walk the JSON path uses; - // only an UNAMBIGUOUS single match may write. A dry count keeps the - // ambiguity semantics identical across both paths. - const { replaced: count } = replaceNodeById(liveDoc, nodeId, { - type: "paragraph", - }); - replaced = count; - if (count !== 1) return null; - - // Guard against SILENT LOSS: if the target block carries a table-cell - // attribute markdown cannot represent (colspan/rowspan/colwidth/ - // background), refuse the markdown rewrite so those attrs are not - // dropped. Simple tables (no such attrs) rewrite fine. - const hit = getNodeByRef(liveDoc, nodeId); - guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null; - if (guardAttrs != null) return null; - - // Re-mint any minted block id that collides with an existing page id - // (skip index 0: its id is intentionally the target nodeId, unique by - // the #159 dry-count above), so the 1 -> N splice stays page-wide unique. - reassignCollidingBlockIds(liveDoc, threaded, 0); - - // 1 -> N splice, then merge any fragment footnote definitions into the - // page's tail list and re-derive canonical footnote numbering. - const { doc: spliced } = replaceNodeByIdWithMany( - liveDoc, - nodeId, - threaded, - ); - return mergeFootnoteDefinitions(spliced, definitions); - }, - ); - - // Surface the guard rejection with an actionable message (nothing written). - if (guardAttrs != null) { - throw new Error( - `patchNode: the target block has table-cell attributes markdown cannot ` + - `represent (${guardAttrs}) — a markdown rewrite would drop them. Use ` + - `the table tools (tableUpdateCell/tableInsertRow) or pass a raw ` + - `ProseMirror \`node\` instead of \`markdown\`.`, - ); - } - - // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform skipped the write - // for any count !== 1). Shared #159 guard, identical to the JSON path. - assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId); - - return { - success: true, - replaced, - nodeId, - blocks: threaded.length, - verify: mutation.verify, - }; - } - - /** - * Insert content relative to an anchor (or append it at the top level). - * Operates on the LIVE collab document so comments and concurrent edits are - * preserved. - * - * Exactly one of `input.markdown` / `input.node` (#413): - * - `markdown` (RECOMMENDED): a canonical markdown fragment. It may import to - * SEVERAL blocks — they are inserted IN ORDER at the anchor. `^[...]` - * footnote definitions merge into the page's tail list (same machinery as - * insertFootnote). Every inserted block gets a fresh id. - * - `node`: a raw ProseMirror node for precise attr/mark work, or to insert - * table structure (a bare tableRow/tableCell/tableHeader — NOT expressible in - * markdown, so those stay JSON-only). - * - * opts.position: - * - "append": push the content at the end of the top-level content. - * - "before"/"after": insert as a sibling of the anchor, just before/after it. - * Exactly one of anchorNodeId / anchorText must be given; anchorNodeId - * locates a node anywhere by attrs.id, anchorText matches the first top-level - * block whose plain text includes it. - * - * Throws if the anchor cannot be found. - */ - async insertNode( - pageId: string, - input: { markdown?: string; node?: any }, - opts: { - position: "before" | "after" | "append"; - anchorNodeId?: string; - anchorText?: string; - }, - ) { - await this.ensureAuthenticated(); - - // XOR: exactly one of markdown / node (both optional in the schema). - const hasMd = - input != null && - typeof input.markdown === "string" && - input.markdown.trim() !== ""; - const hasNode = input != null && input.node != null; - if (hasMd === hasNode) { - throw new Error( - "insertNode: provide exactly one of `markdown` (recommended, for prose) " + - "or `node` (a raw ProseMirror node, for precise attr/mark work or table structure)", - ); - } - - if ( - !opts || - (opts.position !== "before" && - opts.position !== "after" && - opts.position !== "append") - ) { - throw new Error( - 'insertNode: `position` must be one of "before", "after", "append"', - ); - } - if (opts.position === "before" || opts.position === "after") { - // before/after require EXACTLY ONE anchor (an id or a text fragment). - const hasId = - typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0; - const hasText = - typeof opts.anchorText === "string" && opts.anchorText.length > 0; - if (hasId === hasText) { - throw new Error( - `insertNode: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`, - ); - } - } - - // Resolve the ordered list of blocks to insert plus any footnote definitions - // to merge. The markdown path imports canonically (so an inserted block is - // byte-identical to the same content in a full-page import); the node path is - // a single block with no footnote merge (raw JSON `^[...]` is not touched). - let blocks: any[]; - let definitions: any[] = []; - if (hasMd) { - const frag = await importMarkdownFragment(input.markdown as string); - blocks = frag.blocks; - definitions = frag.definitions; - } else { - const node = input.node; - if (!node || typeof node !== "object" || typeof node.type !== "string") { - throw new Error( - "insertNode: `node` must be an object with a string `type`", - ); - } - blocks = [node]; - } - - // #409: fail fast on a malformed node SHAPE (a nested child with an - // absent/unknown `type`) BEFORE opening a collab session or taking the page - // lock — the root-only check above never sees nested children. - for (const b of blocks) { - this.assertValidNodeShape("insertNode", b); - } - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - // Track insertion in an outer var, reset per-transform, so a collab retry - // recomputes it cleanly (mirrors replaceImage's pattern). - let inserted = false; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - inserted = false; - // Re-mint any minted block id that collides with an existing page id - // (all inserted blocks are fresh, no skip) so the splice stays unique. - if (hasMd) reassignCollidingBlockIds(liveDoc, blocks); - // Single-block node path keeps `insertNodeRelative` (it owns the - // structural table-node splicing); the markdown path uses the array - // splice so N blocks land in order at one anchor. - const res = hasMd - ? insertNodesRelative(liveDoc, blocks, opts) - : insertNodeRelative(liveDoc, blocks[0], opts); - inserted = res.inserted; - if (!inserted) return null; // anchor not found -> skip the write entirely - // Merge any fragment footnote definitions into the page tail list and - // re-derive canonical numbering (no-op when there are none). - return mergeFootnoteDefinitions(res.doc, definitions); - }, - ); - - if (!inserted) { - const anchorDesc = opts.anchorNodeId - ? `anchorNodeId "${opts.anchorNodeId}"` - : `anchorText "${opts.anchorText}"`; - // anchorText is matched against the block's literal RENDERED plain text; - // markdown/emoji are tolerated only as a strip-and-retry fallback, so a - // miss usually means the text differs from what's on the page. - const hint = opts.anchorText - ? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from getPageJson is more reliable." - : ""; - throw new Error( - `insertNode: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`, - ); - } - - return { - success: true, - inserted: true, - position: opts.position, - blocks: blocks.length, - verify: mutation.verify, - }; - } - - /** - * Remove EVERY node whose attrs.id === nodeId (recursively, including nodes - * nested in callouts/tables) from its parent content array. Operates on the - * LIVE collab document so comments and concurrent edits are preserved. - * Throws if no node matches. - */ - async deleteNode(pageId: string, nodeId: string) { - await this.ensureAuthenticated(); - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - - // Track the deletion count in an outer var, reset per-transform, so a - // collab retry recomputes it cleanly (mirrors replaceImage's pattern). - let deleted = 0; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - deleted = 0; - const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId); - deleted = d; - // 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block - // ids are duplicated on copy/paste, #159): deleting "the node with id X" - // would silently remove EVERY duplicate. Refuse: skip the write and throw - // below so the model re-targets. Only an unambiguous single match is - // deleted. - if (deleted !== 1) return null; - return nd; - }, - ); - - // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped - // the write for any count !== 1). Single shared guard (#159, #185 review). - assertUnambiguousMatch("deleteNode", "delete", deleted, nodeId, pageId); - - return { success: true, deleted, nodeId, verify: mutation.verify }; - } - - /** Build the public share URL for a page. */ - private shareUrl(shareKey: string, slugId: string): string { - return `${this.appUrl}/share/${shareKey}/p/${slugId}`; - } - - /** Share a page publicly (idempotent) and return the public URL. */ - async sharePage(pageId: string, searchIndexing: boolean = true) { - await this.ensureAuthenticated(); - const response = await this.client.post("/shares/create", { - pageId, - includeSubPages: false, - searchIndexing, - }); - const share = response.data?.data ?? response.data; - const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId; - return { - shareId: share.id, - key: share.key, - pageId: share.pageId, - publicUrl: this.shareUrl(share.key, slugId), - searchIndexing: share.searchIndexing, - }; - } - - /** List all public shares in the workspace with their URLs. */ - async listShares() { - const shares = await this.paginateAll("/shares", {}); - return shares.map((s: any) => ({ - shareId: s.id, - key: s.key, - pageId: s.pageId, - pageTitle: s.page?.title, - publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null, - searchIndexing: s.searchIndexing, - createdAt: s.createdAt, - })); - } - - /** Remove the public share of a page. */ - async unsharePage(pageId: string) { - await this.ensureAuthenticated(); - const shares = await this.listShares(); - const share = shares.find((s: any) => s.pageId === pageId); - if (!share) { - throw new Error(`Page ${pageId} is not shared.`); - } - await this.client.post("/shares/delete", { shareId: share.shareId }); - return { success: true, removedShareId: share.shareId, pageId }; - } - - async search( - query: string, - spaceId?: string, - limit?: number, - opts: { parentPageId?: string; titleOnly?: boolean } = {}, - ) { - await this.ensureAuthenticated(); - // Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid - // substring + FTS branch that returns path + snippet + score. A stock - // upstream server strips these unknown DTO fields (whitelist:true) and - // silently degrades to plain FTS — see the tool-registration comment. - const payload: Record = { - query, - spaceId, - substring: true, - }; - if (opts.parentPageId) payload.parentPageId = opts.parentPageId; - if (opts.titleOnly) payload.titleOnly = true; - // Clamp an optional caller-supplied limit into the lookup range (1..50) - // before forwarding; omit it when not provided so the server default applies. - if (limit !== undefined) { - payload.limit = Math.max(1, Math.min(50, limit)); - } - const response = await this.client.post("/search", payload); - - // Normalize both response shapes: bare array and paginated { items: [...] } - const data = response.data?.data; - const items = Array.isArray(data) ? data : data?.items || []; - const filteredItems = items.map((item: any) => filterSearchResult(item)); - - return { - items: filteredItems, - success: response.data?.success || false, - }; - } - - async movePage( - pageId: string, - parentPageId: string | null, - position?: string, - ) { - await this.ensureAuthenticated(); - // Docmost requires position >= 5 chars. - const validPosition = position || "a00000"; - - return this.client - .post("/pages/move", { - pageId, - parentPageId, - position: validPosition, - }) - .then((res) => res.data); - } - - async deletePage(pageId: string) { - await this.ensureAuthenticated(); - return this.client - .post("/pages/delete", { pageId }) - .then((res) => res.data); - } - - // --- Comment methods (ported from upstream PR #3 by Max Nikitin) --- - - /** - * Normalize a comment's `content` into a ProseMirror doc object before - * markdown conversion. createComment/updateComment send content as a - * JSON.stringify(...) STRING, and the server stores it as-is, so on read it - * comes back as a string. convertProseMirrorToMarkdown returns "" for a - * string, so parse it first (guarded — fall back to the raw value on any - * parse failure so a non-JSON legacy value is still handled gracefully). - */ - private parseCommentContent(content: any): any { - if (typeof content !== "string") return content; - try { - return JSON.parse(content); - } catch { - return content; - } - } - - /** - * List comments on a page (cursor-paginated), content as markdown. - * - * DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the - * agent sees only active discussions: a top-level comment with `resolvedAt` - * set AND every reply under it (a reply of a closed thread is part of the - * closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how - * many resolved top-level threads were hidden so the agent can re-query with - * `includeResolved: true` to see everything. Active threads always stay. - * - * Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) — callers that - * need the full feed (lossless export, transformPage, checkNewComments) pass - * `includeResolved: true` and read `.items`. - */ - async listComments(pageId: string, includeResolved = false) { - await this.ensureAuthenticated(); - let allComments: any[] = []; - let cursor: string | null = null; - - // Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments - // ever stops advancing the cursor (the exact #442 drift scenario) this loop - // would otherwise spin forever accumulating duplicates. - const MAX_PAGES = 50; - let truncated = false; - - for (let page = 0; page < MAX_PAGES; page++) { - const payload: Record = { pageId, limit: 100 }; - if (cursor) payload.cursor = cursor; - - const response = await this.client.post("/comments", payload); - const data = response.data.data || response.data; - const items = data.items || []; - allComments = allComments.concat(items); - - // Advance strictly via the server-issued cursor. A missing nextCursor or a - // cursor identical to the one we just sent means the end (or a server that - // ignores our pagination param) — stop instead of re-fetching page one. - const next: string | null = data.meta?.nextCursor || null; - if (!next || next === cursor) break; - cursor = next; - - // Reaching the ceiling with a still-advancing cursor means truncation. - if (page === MAX_PAGES - 1) truncated = true; - } - - if (truncated) { - console.warn( - `listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`, - ); - } - - const mapped = allComments.map((comment: any) => { - const markdown = comment.content - ? convertProseMirrorToMarkdown( - this.parseCommentContent(comment.content), - ) - : ""; - return filterComment(comment, markdown); - }); - - if (includeResolved) { - return { items: mapped, resolvedThreadsHidden: 0 }; - } - - // Ids of RESOLVED top-level threads (a top-level comment has no - // parentCommentId). A whole thread is hidden when its root is resolved. - const resolvedRootIds = new Set( - mapped - .filter((c) => !c.parentCommentId && c.resolvedAt != null) - .map((c) => c.id), - ); - - const items = mapped.filter((c) => { - // Hide the resolved root itself and every reply anchored to it. A reply's - // own resolvedAt is irrelevant — its membership follows the parent thread. - // ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId - // always points at the thread ROOT (no reply-of-reply nesting), so a single - // level of parent lookup covers a whole thread. If nested replies are ever - // introduced, a deep reply of a resolved thread would need a root-walk here. - if (!c.parentCommentId) return !resolvedRootIds.has(c.id); - return !resolvedRootIds.has(c.parentCommentId); - }); - - return { items, resolvedThreadsHidden: resolvedRootIds.size }; - } - - async getComment(commentId: string) { - // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("get_comment", "commentId", commentId); - await this.ensureAuthenticated(); - const response = await this.client.post("/comments/info", { commentId }); - const comment = response.data.data || response.data; - const markdown = comment.content - ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) - : ""; - return { - data: filterComment(comment, markdown), - success: true, - }; - } - - /** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */ - private topLevelBlockTexts(doc: any): string[] { - const content = doc && Array.isArray(doc.content) ? doc.content : []; - return content - .map((b: any) => blockPlainText(b)) - .filter((t: string) => t.length > 0); - } - - /** - * True when per-block anchoring failed but the (normalized) selection DOES - * appear in the blocks' joined plain text — i.e. it straddles a block - * boundary. Blocks are joined with a newline (collapsed to one space by - * normalizeForMatch) so a selection whose parts are separated by a paragraph - * break still matches. Callers only reach here after single-block anchoring - * (incl. the markdown-strip fallback) has already failed. - */ - private selectionSpansMultipleBlocks( - blockTexts: string[], - selection: string, - ): boolean { - const normSel = normalizeForMatch(selection).norm.trim(); - if (normSel.length === 0) return false; - const joined = normalizeForMatch(blockTexts.join("\n")).norm; - return joined.indexOf(normSel) !== -1; - } - - /** - * Build the actionable error for a createComment anchor MISS, porting - * editPageText's self-correction affordances: an explicit "spans multiple - * blocks" message when the selection straddles a block boundary, otherwise a - * "closest block text" hint quoting the block that holds the selection's - * longest token. `live` switches the wording between the pre-check (reading the - * persisted page) and the post-create live-anchor failure (which rolls back). - */ - private anchorNotFoundError( - doc: any, - selection: string, - live: boolean, - ): Error { - const blockTexts = this.topLevelBlockTexts(doc); - const rolled = live ? " The comment was rolled back." : ""; - if (this.selectionSpansMultipleBlocks(blockTexts, selection)) { - return new Error( - "createComment: the selection spans multiple blocks; anchor on a " + - "contiguous fragment within a SINGLE paragraph/block (<=250 chars)." + - rolled, - ); - } - const where = live ? "in the live document" : "in the page"; - return new Error( - `createComment: could not find the selection text ${where} to anchor ` + - "the comment. Provide the EXACT contiguous text from a single " + - "paragraph/block (<=250 chars)." + - closestBlockHint(blockTexts, selection) + - rolled, - ); - } - - /** - * Create an inline comment anchored to its `selection` text, or a reply. - * - * Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a - * `selection`: the `type` argument is kept for interface compatibility but the - * effective type is coerced to "inline". The selection has to anchor in the - * document; if it cannot, the comment is rolled back and an error is thrown so - * the caller is forced to supply a proper inline selection rather than leaving - * an orphan, unanchored comment behind. Replies (parentCommentId set) inherit - * their parent's anchor: they take NO selection and are not anchored. - */ - async createComment( - pageId: string, - content: string, - type: "page" | "inline" = "page", - selection?: string, - parentCommentId?: string, - suggestedText?: string, - ) { - // Fail fast (#436): a provided parent id must be a full UUID before any - // network call. Validate only when truthy — a falsy parentCommentId means - // "top-level comment" (mirrors the isReply computation below), not a reply. - if (parentCommentId) { - assertFullUuid("createComment", "parentCommentId", parentCommentId); - } - await this.ensureAuthenticated(); - - const isReply = !!parentCommentId; - const hasSuggestion = - suggestedText !== undefined && suggestedText !== null; - // Defense in depth mirroring the server DTO/service: a suggested edit rewrites - // the exact anchored text, so it is only meaningful on a top-level inline - // comment that carries a selection. - if (hasSuggestion) { - if (isReply) { - throw new Error( - "createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.", - ); - } - if (!selection || !selection.trim()) { - throw new Error( - "createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.", - ); - } - } - // Only top-level comments are inline-anchored, so they are stored as - // "inline". Replies carry no inline selection, so they keep the historical - // general ("page") type — both backward-compatible and semantically correct. - // The `type` argument is kept for interface compatibility; createComment - // normalizes the effective type internally, so callers may pass "inline". - const effectiveType: "page" | "inline" = isReply ? "page" : "inline"; - if (!isReply && (!selection || !selection.trim())) { - throw new Error( - "createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment", - ); - } - - // For a SUGGESTION, the value we store as the comment's `selection` must be - // the RAW document substring the mark lands on (typographic quotes/dashes, - // nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is - // placed via normalization, so when the doc was auto-converted to - // typographic the raw substring differs from the agent input; apply-time - // compares the stored selection to the marked doc text STRICTLY, so storing - // the raw substring is what makes "Apply" succeed instead of a spurious 409. - // Captured in the pre-check below (which already reads the page) and used as - // payload.selection. Ordinary comments keep sending the raw agent selection. - let anchoredSelection: string | null = null; - // Set when the anchor matched only after stripping markdown from the - // selection (the strip fallback); surfaced as a soft warning like - // editPageText does, so a stale-markdown selection is flagged. - let anchorNormalized = false; - - // For a top-level comment, fail BEFORE creating anything when the selection - // is not present in the persisted document — this avoids leaving an orphan - // comment + notification behind. A read failure (network) is non-fatal: the - // live anchor step below still enforces the anchoring invariant. - if (!isReply && selection) { - try { - const page = await this.getPageJson(pageId); - if (hasSuggestion) { - // A suggestion's anchor MUST be unambiguous: applying it rewrites the - // exact anchored text, and ordinary anchoring silently takes the first - // occurrence, so 0 matches -> not found and >=2 -> ambiguous, both - // rejected BEFORE creating the comment. - const matches = countAnchorMatches(page.content, selection); - if (matches === 0) { - throw this.anchorNotFoundError(page.content, selection, false); - } - if (matches >= 2) { - throw new Error( - `createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` + - "A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " + - "(still <=250 chars) so it appears exactly once.", - ); - } - // Exactly one match: capture the RAW anchored substring to store as the - // comment selection (so apply-time equality holds). If this returns - // null despite countAnchorMatches===1 (shouldn't happen), fall back to - // the raw agent selection below rather than crash. - anchoredSelection = getAnchoredText(page.content, selection); - anchorNormalized = resolveAnchorSelection( - page.content, - selection, - ).normalized; - } else { - const resolved = resolveAnchorSelection(page.content, selection); - if (!resolved.found) { - throw this.anchorNotFoundError(page.content, selection, false); - } - anchorNormalized = resolved.normalized; - } - } catch (e) { - // Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors; - // swallow read/network errors so the live anchor step can still try (and - // enforce) anchoring. - if ( - e instanceof Error && - (e.message.startsWith("createComment: could not find the selection") || - e.message.startsWith( - "createComment: the selection spans multiple blocks", - ) || - e.message.startsWith( - "createComment: the suggestion's selection is ambiguous", - )) - ) { - throw e; - } - if (process.env.DEBUG) { - console.error( - "Pre-check getPageJson failed; deferring to live anchor step:", - e, - ); - } - } - } - - // Convert through the full Docmost schema. Deliberately the NON-canonicalizing - // variant: a comment body may carry a footnote definition with no matching - // reference, and canonicalization would drop it (data loss). See - // markdownToProseMirror vs markdownToProseMirrorCanonical. - const jsonContent = await markdownToProseMirror(content); - const payload: Record = { - pageId, - content: JSON.stringify(jsonContent), - type: effectiveType, - }; - // For a suggestion, store the RAW anchored substring (anchoredSelection) so - // the stored selection === the text under the mark === apply-time - // expectedText. Ordinary comments (and the null fallback) keep the raw - // agent selection — their selection is only display/anchor and never used - // by apply, so their behavior is unchanged. - if (!isReply && selection) - payload.selection = anchoredSelection ?? selection; - if (parentCommentId) payload.parentCommentId = parentCommentId; - // Only a top-level inline comment (with a selection) may carry a suggestion. - if (!isReply && selection && hasSuggestion) { - payload.suggestedText = suggestedText; - } - - const response = await this.client.post("/comments/create", payload); - const comment = response.data.data || response.data; - const markdown = comment.content - ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) - : content; - const result: any = { - data: filterComment(comment, markdown), - success: true, - }; - - // Replies inherit the parent's anchor: no selection, no anchoring. - if (isReply) { - return result; - } - - // Anchor the comment in the document. The /comments/create API records the - // comment + its `selection` text, but it does NOT insert the comment MARK - // into the page content, so without this the inline comment has no - // highlight/anchor and is not clickable. If anchoring fails the comment is - // rolled back (deleted) and an error is thrown — never an orphan comment. - const newCommentId: string = comment.id; - // Guard: a create response without an id would mean writing a comment mark - // with commentId: undefined and a later delete of a falsy id. We have no id - // to roll back here (nothing was created with an id), so just fail loudly. - if (!newCommentId) { - throw new Error( - "createComment: the server returned no comment id, so the comment could not be anchored", - ); - } - let anchored = false; - // Set inside the transform when a suggestion's live anchor is ambiguous - // (>=2 occurrences), so the rollback path can surface the right error. - let ambiguousInLiveDoc = false; - // Captured inside the transform on a not-found abort, so the rollback path - // can surface the closest-block / spans-multiple-blocks hint built from the - // LIVE document (the pre-check page is not in scope there). - let liveNotFoundError: Error | null = null; - try { - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). The - // /comments/create REST call above keeps the agent-supplied id. - const pageUuid = await this.resolvePageId(pageId); - // Route through the mutatePage seam (not the free function) so this - // wrapper's uniqueness gate + rollback can be unit-tested without a live - // Hocuspocus collab socket. - const mutation = await this.mutatePage( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - const doc = - liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (hasSuggestion) { - // Authoritative uniqueness check against the LIVE document: a - // suggestion must anchor to EXACTLY ONE occurrence, otherwise - // "Apply" would rewrite the wrong/ambiguous text. If the live doc - // no longer has exactly one occurrence (it changed since the - // pre-check), abort so the just-created comment is rolled back - // rather than mis-anchored to the first occurrence. - const liveCount = countAnchorMatches(doc, selection as string); - if (liveCount !== 1) { - ambiguousInLiveDoc = liveCount >= 2; - if (liveCount === 0) { - liveNotFoundError = this.anchorNotFoundError( - doc, - selection as string, - true, - ); - } - return null; - } - } - if (applyAnchorInDoc(doc, selection as string, newCommentId)) { - anchored = true; - return doc; - } - // Selection text not found in the LIVE document: abort the write. The - // rollback + throw below turns this into a hard error. - liveNotFoundError = this.anchorNotFoundError( - doc, - selection as string, - true, - ); - return null; - }, - ); - result.verify = mutation.verify; - } catch (e) { - // The comment record already exists; roll it back so we never leave an - // orphan, then rethrow the original anchoring error. - await this.safeDeleteComment(newCommentId); - throw e; - } - - if (!anchored) { - // Mutation aborted because the selection was not found (or, for a - // suggestion, was ambiguous) in the live document. Roll back the comment - // and surface a hard error. - await this.safeDeleteComment(newCommentId); - if (ambiguousInLiveDoc) { - throw new Error( - "createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.", - ); - } - throw ( - liveNotFoundError ?? - new Error( - "createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back", - ) - ); - } - - // Soft warning (like editPageText): the selection only matched after - // stripping markdown, so the caller likely quoted a styled fragment. - if (anchorNormalized) { - result.warning = - "The selection matched only after stripping markdown syntax; the comment " + - "was anchored on the document's plain text. Copy the selection verbatim " + - "from getPage / searchInPage output to avoid this."; - } - - result.anchored = true; - return result; - } - - /** - * Best-effort rollback of a just-created comment. Swallows any delete failure - * (logging under DEBUG) so a failed cleanup never masks the original error. - */ - private async safeDeleteComment(commentId: string): Promise { - // Defense in depth: never call the delete API with a falsy id — there is - // nothing to roll back, and deleteComment(undefined) would hit a bad route. - if (!commentId) return; - try { - await this.deleteComment(commentId); - } catch (delErr) { - if (process.env.DEBUG) { - console.error( - "Failed to roll back comment after anchoring error:", - delErr, - ); - } - } - } - - async updateComment(commentId: string, content: string) { - // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("updateComment", "commentId", commentId); - await this.ensureAuthenticated(); - // NON-canonicalizing on purpose (comment body — see createComment). - const jsonContent = await markdownToProseMirror(content); - await this.client.post("/comments/update", { - commentId, - content: JSON.stringify(jsonContent), - }); - return { - success: true, - commentId, - message: "Comment updated successfully.", - }; - } - - async deleteComment(commentId: string) { - // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("deleteComment", "commentId", commentId); - await this.ensureAuthenticated(); - return this.client - .post("/comments/delete", { commentId }) - .then((res) => res.data); - } - - /** - * Resolve or reopen a top-level comment thread (reversible — `resolved` - * toggles the state). Only top-level comments can be resolved; the server - * rejects resolving a reply. Hits POST /comments/resolve. - */ - async resolveComment(commentId: string, resolved: boolean) { - // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("resolveComment", "commentId", commentId); - await this.ensureAuthenticated(); - const response = await this.client.post("/comments/resolve", { - commentId, - resolved, - }); - const comment = response.data?.data ?? response.data; - return { - success: true, - commentId, - resolved, - comment, - }; - } - - /** - * Check for new comments across pages in a space (optionally scoped to a - * subtree): pages updated after `since` are scanned and their comments - * filtered by createdAt > since. - */ - async checkNewComments( - spaceId: string, - since: string, - parentPageId?: string, - ) { - await this.ensureAuthenticated(); - - const sinceDate = new Date(since); - - // Reject an unparseable `since`: comparing against an Invalid Date silently - // yields zero new comments (every `>` against NaN is false), which would - // mask a malformed input as "nothing new" instead of erroring. - if (Number.isNaN(sinceDate.getTime())) { - throw new Error( - `checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`, - ); - } - - // 1. Enumerate the FULL set of pages in scope via the page tree (a complete - // page index), NOT the bounded "/pages/recent" feed which caps at ~5000 - // recent items and silently misses comments on older pages. - // - // Subtree scope: when parentPageId is given, the scope is that page ITSELF - // plus every descendant. Otherwise the scope is the whole space (all roots - // and their descendants). - // - // NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not - // bump it (verified on a live server), so such a filter silently misses - // comments on pages that were not otherwise edited. The complete tree walk - // already restricts the scope correctly, so no recent-feed allow-list is - // needed any more. - // - // The subtree scope (parentPageId given) already INCLUDES the root node - // itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so - // no separate getPageRaw fetch for the parent is needed. - const { pages: pagesInScope, truncated } = await this.enumerateSpacePages( - spaceId, - parentPageId, - ); - - // 2. Fetch comments for each page, keep ones created after since - const results: any[] = []; - for (const page of pagesInScope) { - try { - // Full feed (incl. resolved): a "new comments since" scan reports all - // recent activity; the active-only filter is scoped to listComments. - const comments = (await this.listComments(page.id, true)).items; - const newComments = comments.filter( - (c: any) => new Date(c.createdAt) > sinceDate, - ); - if (newComments.length > 0) { - results.push({ - pageId: page.id, - pageTitle: page.title, - comments: newComments, - }); - } - } catch (e: any) { - // Skip pages with errors (e.g. deleted between calls) - } - } - - const totalNewComments = results.reduce( - (sum, r) => sum + r.comments.length, - 0, - ); - - // `truncated` is reported by enumerateSpacePages: it is true ONLY when the - // stdio fallback BFS hit its node cap. The primary /pages/tree path is - // uncapped, so a space with legitimately many pages is not falsely flagged. - return { - since, - scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`, - checkedPages: pagesInScope.length, - pagesWithNewComments: results.length, - totalNewComments, - truncated, - comments: results, - }; - } - - // --- Image upload / embedding --- - - /** Map a Content-Type string to a supported MIME type, or null if unsupported. */ - private supportedImageMime(ct: string): string | null { - return MIME_TO_EXT[ct] ? ct : null; - } - - /** - * Download a remote image from a caller-supplied URL and resolve its bytes, - * MIME and a filename. - * - * SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is - * fetched BY THE SERVER, so it must be guarded before and after the request. - * The guards mirror the local-file trust boundary in uploadImage: - * - scheme allowlist (http/https only) — rejects file:, data:, ftp:, etc., - * so the caller cannot use this path to read local files or other schemes; - * - a size cap enforced both via axios maxContentLength/maxBodyLength AND a - * post-download buffer.length re-check (defends against a missing/lying - * Content-Length), so a huge response cannot exhaust memory; - * - a 30s timeout. The timeout matters because replaceImage holds the - * per-page lock across this upload, so a hung download would wedge the - * lock for that page. - * We deliberately do NOT block private IP ranges: the MCP caller is already - * trusted to read arbitrary host files via the filePath path, so the marginal - * trust granted by fetching internal URLs is comparable, and blocking would - * break legitimate internal-image use. - */ - private async fetchRemoteImage( - url: string, - maxBytes: number, - ): Promise<{ buffer: Buffer; mime: string; fileName: string }> { - // Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes - // (file:, data:, ftp:, ...) before any network request is made. - let parsed: URL; - try { - parsed = new URL(url); - } catch (e: any) { - throw new Error(`Invalid image URL "${url}": ${e.message}`); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error( - `unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`, - ); - } - - let response; - try { - response = await axios.get(url, { - responseType: "arraybuffer", - timeout: 30000, - maxContentLength: maxBytes, - maxBodyLength: maxBytes, - headers: { Accept: "image/*" }, - }); - } catch (error) { - // Keep the thrown message free of the raw response body (it may echo - // server internals); surface only status/statusText. The full body is - // logged under DEBUG for diagnostics. - if (axios.isAxiosError(error)) { - if (process.env.DEBUG) { - console.error( - "Image download failed; response body:", - JSON.stringify(error.response?.data), - ); - } - throw new Error( - `Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim(), - ); - } - throw error; - } - - // axios returns an ArrayBuffer for responseType: "arraybuffer". - const buffer = Buffer.from(response.data); - // Re-check the size: maxContentLength relies on Content-Length, which may be - // absent or lie, so guard against the actual byte count too. - if (buffer.length === 0) { - throw new Error(`Empty image response from "${url}"`); - } - if (buffer.length > maxBytes) { - throw new Error( - `Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`, - ); - } - - // Resolve MIME: prefer the response Content-Type (strip any "; charset=..." - // parameter, lowercase, trim) mapped through the supported set; if the - // header is generic/missing/unsupported, fall back to the URL path - // extension via the existing extension->MIME logic. - const rawCt = response.headers?.["content-type"]; - let mime: string | null = null; - if (typeof rawCt === "string" && rawCt.length > 0) { - const ct = rawCt.split(";")[0].trim().toLowerCase(); - mime = this.supportedImageMime(ct); - } - if (!mime) { - // Fall back to the URL path extension. Use the pathname so the query - // string never contaminates the extension lookup. - const ext = extname(parsed.pathname).toLowerCase(); - mime = EXT_TO_MIME[ext] ?? null; - } - if (!mime) { - throw new Error( - `cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`, - ); - } - - // Build a filename from the URL path basename (ignore the query string), - // defaulting to "image" when empty, and ensure it ends with the canonical - // extension for the resolved MIME (append it when missing/mismatched). - const canonicalExt = MIME_TO_EXT[mime]; - let fileName = basename(parsed.pathname) || "image"; - if (extname(fileName).toLowerCase() !== canonicalExt) { - fileName += canonicalExt; - } - - return { buffer, mime, fileName }; - } - - /** Build a Docmost ProseMirror image node from an uploaded attachment. */ - private buildImageNode( - att: { id: string; fileName: string; fileSize?: number }, - align?: "left" | "center" | "right", - alt?: string, - ): any { - // Clean file URL, matching Docmost's native behaviour. No cache-busting - // query: the server serves the bare URL correctly, and replacement creates - // a new attachment id (a new URL) which busts caches naturally. - const src = `/api/files/${att.id}/${att.fileName}`; - const node: any = { - type: "image", - attrs: { - src, - attachmentId: att.id, - // Default to null when the server omits fileSize so the attr is never - // undefined (undefined would be dropped on serialization / break the - // ProseMirror image schema which expects size present). - size: att.fileSize ?? null, - align: align || "center", - width: null, - }, - }; - if (alt) node.attrs.alt = alt; - return node; - } - - /** - * Download a remote image from an http(s) URL and upload it as an attachment - * of a page, returning the attachment metadata plus a ready-to-insert - * ProseMirror image node. Local file paths are intentionally not supported: - * the MCP caller is a remote AI with no access to this server's filesystem. - */ - async uploadImage(pageId: string, url: string) { - await this.ensureAuthenticated(); - - const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB - - // Fetch + validate the remote image (scheme allowlist, size cap, timeout). - // See fetchRemoteImage for the SSRF / resource trust boundary. - const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES); - const fileBuffer = fetched.buffer; - const mime = fetched.mime; - const fileName = fetched.fileName; - - // Build a FRESH FormData for every send attempt. A FormData body is a - // single-use stream that is CONSUMED on the first send, so it cannot be - // replayed by this.client's response interceptor (replaying a consumed - // stream fails with 'socket hang up'). Multipart re-auth is therefore done - // here with bare axios and an explicit one-shot 401/403 retry that rebuilds - // the body. Field order matters: text fields must precede the file part so - // the server reads them; the server always generates a fresh attachment id. - const buildForm = () => { - const form = new FormData(); - form.append("pageId", pageId); - form.append("file", fileBuffer, { - filename: fileName, - contentType: mime, - }); - return form; - }; - - // Local name distinct from the `url` parameter (the source image URL): this - // is the /files/upload endpoint we POST the multipart body to. - const uploadUrl = `${this.apiUrl}/files/upload`; - let response; - try { - // Call buildForm() ONCE per attempt and reuse the instance for both - // getHeaders() and the body so the Content-Type boundary matches the body. - const form = buildForm(); - // Read the Authorization header from this.client's defaults (set by - // login(), only ever deleted — never set to null) instead of building - // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token - // mid-flight, which would otherwise produce a literal "Bearer null". - // ensureAuthenticated() above guarantees login() ran, so the default - // header exists here. A 60s timeout keeps a hung upload from wedging the - // per-page lock (replaceImage holds withPageLock across this call). - response = await axios.post(uploadUrl, form, { - headers: { - ...form.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } catch (error) { - // On an expired-token auth error, re-login and retry exactly once with a - // freshly-rebuilt FormData (the previous one was already consumed). - if ( - axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403) - ) { - await this.login(); - const form2 = buildForm(); - response = await axios.post(uploadUrl, form2, { - headers: { - ...form2.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } else if (axios.isAxiosError(error)) { - // Keep the thrown message free of the raw response body (it may echo - // request data or server internals); surface only status/statusText. - // The full body is logged under DEBUG for diagnostics. - if (process.env.DEBUG) { - console.error( - "Image upload failed; response body:", - JSON.stringify(error.response?.data), - ); - } - throw new Error( - `Image upload failed: ${error.response?.status} ${error.response?.statusText}`, - ); - } else { - throw error; - } - } - // The attachment may arrive bare or wrapped in a { data } envelope. - const att = response.data?.data ?? response.data; - if (!att?.id || !att?.fileName) { - throw new Error( - "Unexpected /files/upload response: " + JSON.stringify(response.data), - ); - } - - // Some Docmost versions omit fileSize from the upload response. Fall back - // to the fetched byte length (the bytes we just uploaded) so callers never - // get an undefined size. - const resolvedSize = att.fileSize ?? fileBuffer.length; - - return { - attachmentId: att.id, - fileName: att.fileName, - fileSize: resolvedSize, - src: `/api/files/${att.id}/${att.fileName}`, - imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }), - }; - } - - /** - * Upload an image from a web (http/https) URL and insert it into a page in - * one step. - * By default the image is appended at the end. With replaceText, the first - * top-level block whose text contains the string is replaced; with afterText, - * the image is inserted right after the first matching block. All other - * block ids are preserved (only one top-level block is added or swapped). - */ - async insertImage( - pageId: string, - url: string, - opts: { - align?: "left" | "center" | "right"; - alt?: string; - replaceText?: string; - afterText?: string; - } = {}, - ) { - const up = await this.uploadImage(pageId, url); - // Reuse the node from uploadImage (clean /api/files// src), then - // apply align/alt onto a shallow attrs copy. - const node: any = { ...up.imageNode, attrs: { ...up.imageNode.attrs } }; - if (opts.align) node.attrs.align = opts.align; - if (opts.alt) node.attrs.alt = opts.alt; - - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). The - // uploadImage /files/upload call above keeps the agent-supplied id. - const pageUuid = await this.resolvePageId(pageId); - - // Recursively collect the plain text of a top-level block. - const blockText = (n: any): string => { - let out = ""; - if (n.type === "text") out += n.text || ""; - for (const child of n.content || []) out += blockText(child); - return out; - }; - - // Insert into the LIVE synced document, not the debounced REST snapshot, so - // concurrent edits/comments/images are preserved and parallel insertImage - // calls (serialized by the per-page lock) each see the previous insertion. - let placement: "replaced" | "after" | "appended" | undefined; - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - const doc = - liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (!Array.isArray(doc.content)) doc.content = []; - - if (opts.replaceText) { - // Ambiguity guard (mirrors editPageText): count matching top-level - // blocks first, so a non-unique fragment cannot silently replace the - // wrong block (e.g. text that also appears inside a callout/table). - const matches = doc.content.filter((b: any) => - blockText(b).includes(opts.replaceText!), - ); - if (matches.length === 0) { - throw new Error(`replaceText not found: "${opts.replaceText}"`); - } - if (matches.length > 1) { - throw new Error( - `replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`, - ); - } - const idx = doc.content.findIndex((b: any) => - blockText(b).includes(opts.replaceText!), - ); - // Data-loss guard: replaceText swaps the WHOLE top-level block, so if - // the fragment only appears nested inside a container (table, callout, - // list, blockquote) the entire structure would be destroyed. Refuse - // when the matched block is a container rather than a leaf - // paragraph/heading and point the caller at a safer tool. - const CONTAINER_TYPES = new Set([ - "table", - "callout", - "bulletList", - "orderedList", - "taskList", - "blockquote", - ]); - const matchedBlock = doc.content[idx]; - if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) { - throw new Error( - `replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` + - `Use afterText to insert near it, or updatePageJson for surgical edits.`, - ); - } - doc.content.splice(idx, 1, node); - placement = "replaced"; - } else if (opts.afterText) { - // Ambiguity guard (mirrors editPageText): refuse a non-unique fragment. - const matches = doc.content.filter((b: any) => - blockText(b).includes(opts.afterText!), - ); - if (matches.length === 0) { - throw new Error(`afterText not found: "${opts.afterText}"`); - } - if (matches.length > 1) { - throw new Error( - `afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`, - ); - } - const idx = doc.content.findIndex((b: any) => - blockText(b).includes(opts.afterText!), - ); - doc.content.splice(idx + 1, 0, node); - placement = "after"; - } else { - doc.content.push(node); - placement = "appended"; - } - - return doc; - }, - ); - - return { - success: true, - pageId, - attachmentId: up.attachmentId, - src: up.src, - placement, - verify: mutation.verify, - }; - } - - /** - * Replace an existing image in a page with a new image fetched from a web - * (http/https) URL. Uploads the new file as a brand-new attachment, which - * yields a fresh clean URL that both renders correctly and busts browser - * caches (the URL changed). Finds every image node - * whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested - * in callouts/tables) and repoints its src/attachmentId/size, preserving - * comments, alignment and alt. Operates on the live collab document so comments - * and concurrent edits are preserved. Throws if no matching image is found. - * - * The OLD attachment is left in place as an unreferenced orphan: Docmost - * exposes NO HTTP API to delete a single content attachment (verified against - * the attachment controller/service and by probing the live API — deletion - * happens only by cascade when the page, space or user is removed). This is the - * same outcome as Docmost's own editor when an image is removed/replaced. - * In-place byte overwrite is deliberately NOT used because some Docmost - * versions corrupt the attachment (HTTP 500) when its bytes are overwritten. - */ - async replaceImage( - pageId: string, - oldAttachmentId: string, - url: string, - opts: { align?: "left" | "center" | "right"; alt?: string } = {}, - ) { - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). The - // page lock must ALSO key on the UUID so this operation serializes against - // other writes to the same page (mutatePageContent now locks by the resolved - // UUID too); locking by the raw slugId here would desync the mutex key and - // reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage - // keeps the agent-supplied id (it hits REST, not the collab doc). - const pageUuid = await this.resolvePageId(pageId); - - // Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write). - // Previously the scan and the write were two separate mutatePageContent - // calls, each acquiring + releasing the lock, with the upload happening in - // the UNLOCKED gap between them. A concurrent op could interleave there: it - // could remove the target image so the write pass matches nothing, leaving - // the freshly-uploaded attachment as an un-deletable orphan (Docmost has no - // API to delete a single content attachment). Acquiring the lock once and - // using the non-locking collab helper inside (the per-page mutex is NOT - // reentrant, so the self-locking mutatePageContent would deadlock here) - // closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP - // and does not touch the page lock, so it is safe to call while held. - return withPageLock(pageUuid, async () => { - // STEP 1: read-only live check. Scan the live document for any image node - // matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id - // throws without ever creating an orphan attachment. - let matchFound = false; - const scan = (nodes: any[]) => { - for (const node of nodes) { - if (!node) continue; - if ( - node.type === "image" && - node.attrs && - node.attrs.attachmentId === oldAttachmentId - ) { - matchFound = true; - } - if (Array.isArray(node.content)) scan(node.content); - } - }; - - await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => { - matchFound = false; // reset per-transform (collab may retry the read). - const doc = - liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (Array.isArray(doc.content)) scan(doc.content); - return null; // read-only: never write on the check pass. - }); - - if (!matchFound) { - throw new Error( - `replaceImage: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`, - ); - } - - // STEP 2: a match exists — upload the new file as a FRESH attachment (new - // id, new clean URL) and repoint every matching node in a second pass. - // Still inside the SAME lock, so no other op can have changed the page - // since the scan. - const up = await this.uploadImage(pageId, url); - - let replaced = 0; - - // Swap the source of one image node, preserving align/alt/title/geometry. - const repoint = (node: any) => { - node.attrs = { - ...node.attrs, - src: up.src, - attachmentId: up.attachmentId, - // Default to null when fileSize is unknown so the attr is never - // undefined. - size: up.fileSize ?? null, - }; - if (opts.align) node.attrs.align = opts.align; - if (opts.alt !== undefined) node.attrs.alt = opts.alt; - replaced++; - }; - - // Recursively repoint every image node (incl. ones nested in callouts/tables). - const walk = (nodes: any[]) => { - for (const node of nodes) { - if (!node) continue; - if ( - node.type === "image" && - node.attrs && - node.attrs.attachmentId === oldAttachmentId - ) { - repoint(node); - } - if (Array.isArray(node.content)) walk(node.content); - } - }; - - const mutation = await this.mutateLiveContentUnlocked( - pageUuid, - collabToken, - (liveDoc) => { - // Reset per-transform so collab retries recompute cleanly (no double-count). - replaced = 0; - const doc = - liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (!Array.isArray(doc.content)) doc.content = []; - walk(doc.content); - if (replaced === 0) return null; // no match -> skip the write entirely - return doc; - }, - ); - // KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no - // text/mark change) may still report verify.changed === false, because the - // text+marks+integrity-count model in summarizeChange does not inspect - // image `src`/attachmentId attributes. That is acceptable here — the - // replace is confirmed by `replaced` below, and verify is supplementary. - - if (replaced === 0) { - // The pass-1 SCAN found the target (matchFound was true) and we already - // uploaded the new attachment, but pass-2 matched nothing — a concurrent - // editor must have removed the node between the two passes. Do NOT throw - // here (that would leak the just-uploaded attachment AND report failure); - // instead report success with the upload flagged as an unreferenced - // orphan so the caller knows. (The early throw above still covers the - // case where pass-1 finds nothing, before any upload happens.) - return { - success: true, - replaced: 0, - pageId, - oldAttachmentId, - newAttachmentId: up.attachmentId, - src: up.src, - orphanedAttachmentId: up.attachmentId, - warning: - "target image was removed concurrently; uploaded attachment is unreferenced", - verify: mutation.verify, - }; - } - - return { - success: true, - pageId, - replaced, - oldAttachmentId, - newAttachmentId: up.attachmentId, - src: up.src, - verify: mutation.verify, - }; - }); - } - - // --- draw.io diagrams (issue #423) --- - - /** - * Upload a ready-made byte buffer as a page attachment via the same - * multipart /files/upload endpoint uploadImage uses. Split out as its own - * (overridable) seam so drawioCreate/update can upload the generated - * `.drawio.svg` without going through the URL-fetch path, and so tests can - * stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403 - * re-auth handling (a FormData body is single-use, so it must be rebuilt per - * attempt). - */ - protected async uploadAttachmentBuffer( - pageId: string, - buffer: Buffer, - fileName: string, - mime: string, - ): Promise<{ id: string; fileName: string; fileSize: number }> { - await this.ensureAuthenticated(); - const buildForm = () => { - const form = new FormData(); - form.append("pageId", pageId); - form.append("file", buffer, { filename: fileName, contentType: mime }); - return form; - }; - const uploadUrl = `${this.apiUrl}/files/upload`; - let response; - try { - const form = buildForm(); - response = await axios.post(uploadUrl, form, { - headers: { - ...form.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } catch (error) { - if ( - axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403) - ) { - await this.login(); - const form2 = buildForm(); - response = await axios.post(uploadUrl, form2, { - headers: { - ...form2.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } else if (axios.isAxiosError(error)) { - if (process.env.DEBUG) { - console.error( - "Attachment upload failed; response body:", - JSON.stringify(error.response?.data), - ); - } - throw new Error( - `Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`, - ); - } else { - throw error; - } - } - const att = response.data?.data ?? response.data; - if (!att?.id || !att?.fileName) { - throw new Error( - "Unexpected /files/upload response: " + JSON.stringify(response.data), - ); - } - return { - id: att.id, - fileName: att.fileName, - fileSize: att.fileSize ?? buffer.length, - }; - } - - /** - * Fetch a stored `.drawio.svg` attachment as text. Overridable seam over - * fetchInternalFile (the authed loopback fetch, which also rejects any - * traversal/SSRF src) so drawioGet/update can read the current diagram and - * tests can stub the bytes. - */ - protected async fetchAttachmentText(src: string): Promise { - const { buffer } = await this.fetchInternalFile(src); - return buffer.toString("utf-8"); - } - - /** - * Resolve a drawio node on a page by `attrs.id` or `#` and return the - * node plus its ref. Throws a clear error if the ref does not resolve to a - * drawio node. - */ - private async resolveDrawioNode( - pageId: string, - node: string, - ): Promise<{ node: any; ref: string }> { - const data = await this.getPageRaw(pageId); - const hit = getNodeByRef( - data.content ?? { type: "doc", content: [] }, - node, - ); - if (!hit) { - throw new Error( - `drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#" from getOutline)`, - ); - } - if (hit.type !== "drawio") { - throw new Error( - `drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`, - ); - } - return { node: hit.node, ref: node }; - } - - /** - * Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`. - * Runs the decode chain (base64/entity content= → drawio file → nested XML or - * pako-inflated compressed ). The returned `hash` is the - * optimistic-lock key for drawioUpdate. - */ - async drawioGet( - pageId: string, - node: string, - format: "xml" | "svg" = "xml", - ): Promise<{ - pageId: string; - nodeId: string; - format: "xml" | "svg"; - content: string; - meta: { - attachmentId: string | null; - title: string | null; - width: number | null; - height: number | null; - cellCount: number; - hash: string; - }; - }> { - await this.ensureAuthenticated(); - const { node: drawio } = await this.resolveDrawioNode(pageId, node); - const attrs = drawio.attrs || {}; - const src = attrs.src; - if (!src) { - throw new Error( - `drawio: node "${node}" on page ${pageId} has no src to read`, - ); - } - const svg = await this.fetchAttachmentText(src); - const modelXml = decodeDrawioSvg(svg); - const meta = { - attachmentId: attrs.attachmentId ?? null, - title: attrs.title ?? null, - width: attrs.width != null ? Number(attrs.width) : null, - height: attrs.height != null ? Number(attrs.height) : null, - cellCount: countUserCells(modelXml), - hash: mxHash(modelXml), - }; - return { - pageId, - nodeId: attrs.id ?? node, - format, - content: format === "svg" ? svg : normalizeXml(modelXml), - meta, - }; - } - - /** - * Create a drawio diagram from mxGraph XML: lint → schematic SVG preview - * (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the - * attachment → insert a `drawio` node before/after an anchor or appended. - * `xml` is a bare `` or a list of `` (the server wraps - * it and adds the id=0/id=1 sentinels). - */ - async drawioCreate( - pageId: string, - where: { - position: "before" | "after" | "append"; - anchorNodeId?: string; - anchorText?: string; - }, - xml: string, - title?: string, - layout?: "elk", - ): Promise<{ - success: boolean; - nodeId: string; - attachmentId: string; - warnings: string[]; - verify?: any; - }> { - await this.ensureAuthenticated(); - if ( - !where || - (where.position !== "before" && - where.position !== "after" && - where.position !== "append") - ) { - throw new Error( - 'drawioCreate: `where.position` must be one of "before", "after", "append"', - ); - } - if (where.position === "before" || where.position === "after") { - const hasId = - typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0; - const hasText = - typeof where.anchorText === "string" && where.anchorText.length > 0; - if (hasId === hasText) { - throw new Error( - `drawioCreate: position "${where.position}" requires exactly one of anchorNodeId or anchorText`, - ); - } - } - - // Optional server-side ELK auto-layout: the model declares structure with - // rough coords, ELK computes the pixels (best-effort — returns the input - // unchanged on any layout failure). - const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml; - // Pre-write pipeline (throws a structured DrawioLintError on any violation). - const prepared = prepareModel(laidOutXml); - const inner = renderDiagramShapes(prepared.cells, prepared.bbox); - const diagramTitle = title || "Page-1"; - const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); - - const att = await this.uploadAttachmentBuffer( - pageId, - Buffer.from(svg, "utf-8"), - "diagram.drawio.svg", - "image/svg+xml", - ); - - // NOTE: no `id` attribute is set here. The vendored `drawio` node schema - // (diagramAttributes) declares no `id`, so any block id would be silently - // dropped by PMNode.fromJSON on save and the returned handle would fail to - // resolve. The addressable handle is the node's "#" (like image/table - // nodes), computed after the insert below. - const drawioNode: any = { - type: "drawio", - attrs: { - src: `/api/files/${att.id}/${att.fileName}`, - attachmentId: att.id, - width: prepared.bbox.width, - height: prepared.bbox.height, - align: "center", - }, - }; - if (title) drawioNode.attrs.title = title; - // Reuse the existing URL trust boundary (rejects unsafe src schemes). - this.validateDocUrls(drawioNode); - - const collabToken = await this.getCollabTokenWithReauth(); - const pageUuid = await this.resolvePageId(pageId); - - let inserted = false; - let insertedIndex = -1; - const mutation = await this.mutatePage( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - inserted = false; - insertedIndex = -1; - const { doc: nd, inserted: ins } = insertNodeRelative( - liveDoc, - drawioNode, - where, - ); - inserted = ins; - if (!inserted) return null; // anchor not found -> skip the write - // Locate the freshly-inserted node to derive its "#" handle. The - // just-uploaded attachmentId is unique, so it identifies our node. - if (Array.isArray(nd.content)) { - insertedIndex = nd.content.findIndex( - (b: any) => - b && - b.type === "drawio" && - b.attrs && - b.attrs.attachmentId === att.id, - ); - } - return nd; - }, - ); - - if (!inserted) { - const anchorDesc = where.anchorNodeId - ? `anchorNodeId "${where.anchorNodeId}"` - : `anchorText "${where.anchorText}"`; - throw new Error( - `drawioCreate: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`, - ); - } - - if (insertedIndex < 0) { - // The node was inserted nested (e.g. inside a callout/table cell via an - // anchor), where "#" — which addresses only top-level blocks — - // cannot reference it. drawio nodes carry no persisted id, so there is no - // stable handle for a nested diagram. - throw new Error( - `drawioCreate: the diagram was inserted on page ${pageId} but not as a ` + - `top-level block, so it has no addressable "#" handle. Anchor ` + - `on a top-level block (or append) so the diagram can be re-read.`, - ); - } - - // The returned handle is POSITIONAL ("#"): valid for the immediate - // create -> get/update flow, but re-resolve via getOutline if the document - // structure changes (blocks added/removed before it shift the index). - const nodeId = `#${insertedIndex}`; - - return { - success: true, - nodeId, - attachmentId: att.id, - warnings: prepared.warnings, - verify: mutation.verify, - }; - } - - /** - * Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is - * compared against the hash of the diagram's CURRENT XML (from drawioGet); - * any mismatch means a human or another agent edited the diagram after the - * read, so the write is refused with a conflict error. On success the new - * `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is - * avoided — some Docmost versions corrupt an attachment on overwrite, exactly - * as replaceImage documents) and the node is repointed with new dimensions. - */ - async drawioUpdate( - pageId: string, - node: string, - xml: string, - baseHash: string, - layout?: "elk", - ): Promise<{ - success: boolean; - nodeId: string; - attachmentId: string; - warnings: string[]; - verify?: any; - }> { - await this.ensureAuthenticated(); - if (typeof baseHash !== "string" || baseHash.length === 0) { - throw new Error( - "drawioUpdate: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", - ); - } - - // Resolve the node and read the CURRENT diagram to enforce the optimistic - // lock before doing any write or upload. - const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); - const oldAttrs = drawio.attrs || {}; - const oldSrc = oldAttrs.src; - // The returned handle is the caller-supplied reference. drawio nodes carry - // no persisted id, so `ref` (an "#" or a rare legacy attrs.id) is the - // honest identifier to hand back. - const nodeId = oldAttrs.id ?? ref; - if (!oldSrc) { - throw new Error( - `drawioUpdate: node "${node}" on page ${pageId} has no src to compare against`, - ); - } - const currentSvg = await this.fetchAttachmentText(oldSrc); - const currentHash = mxHash(decodeDrawioSvg(currentSvg)); - if (currentHash !== baseHash) { - throw new Error( - `drawioUpdate: conflict — the diagram changed since it was read ` + - `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, - ); - } - - // Optional server-side ELK auto-layout (best-effort; see drawioCreate). - const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml; - // Pipeline for the new content (throws a structured DrawioLintError). - const prepared = prepareModel(laidOutXml); - const inner = renderDiagramShapes(prepared.cells, prepared.bbox); - const diagramTitle = oldAttrs.title || "Page-1"; - const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); - - const att = await this.uploadAttachmentBuffer( - pageId, - Buffer.from(svg, "utf-8"), - "diagram.drawio.svg", - "image/svg+xml", - ); - const newSrc = `/api/files/${att.id}/${att.fileName}`; - - const collabToken = await this.getCollabTokenWithReauth(); - const pageUuid = await this.resolvePageId(pageId); - - let repointed = 0; - const repoint = (n: any) => { - n.attrs = { - ...n.attrs, - src: newSrc, - attachmentId: att.id, - width: prepared.bbox.width, - height: prepared.bbox.height, - }; - repointed++; - }; - - const mutation = await this.mutatePage( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - repointed = 0; - const doc = - liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (!Array.isArray(doc.content)) doc.content = []; - // Repoint ONLY the resolved node — never every node that happens to - // share this attachmentId (a copied diagram is two nodes with one - // attachmentId; keying on it would clobber both). Re-resolve the same - // handle against the live doc and walk to its exact position. - const hit = getNodeByRef(doc, ref); - if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip - let target: any = doc; - for (const idx of hit.path) { - if (!target || !Array.isArray(target.content)) { - target = null; - break; - } - target = target.content[idx]; - } - if (!target || target.type !== "drawio") return null; - repoint(target); - if (repointed === 0) return null; // node vanished concurrently -> skip - return doc; - }, - ); - - if (repointed === 0) { - return { - success: true, - nodeId, - attachmentId: att.id, - warnings: [ - ...prepared.warnings, - "target drawio node was removed concurrently; uploaded attachment is unreferenced", - ], - verify: mutation.verify, - }; - } - - return { - success: true, - nodeId, - attachmentId: att.id, - warnings: prepared.warnings, - verify: mutation.verify, - }; - } - - // --- draw.io high-level semantic tools (issue #425) --- - - /** - * ID-based targeted edits of an existing drawio diagram (add / update / delete - * cells) instead of resending the whole XML. Reads the CURRENT diagram, checks - * the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies - * the operations to the parsed model (a `delete` CASCADES to container children - * and to every edge whose source/target is deleted), then runs the SAME #423 - * pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment -> - * repoint the node). Ids are stable so diffs stay meaningful across edits. - */ - async drawioEditCells( - pageId: string, - node: string, - operations: CellOp[], - baseHash: string, - ): Promise<{ - success: boolean; - nodeId: string; - attachmentId: string; - warnings: string[]; - verify?: any; - }> { - await this.ensureAuthenticated(); - if (typeof baseHash !== "string" || baseHash.length === 0) { - throw new Error( - "drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", - ); - } - if (!Array.isArray(operations) || operations.length === 0) { - throw new Error( - "drawioEditCells: operations must be a non-empty array of { op, ... }", - ); - } - - const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); - const oldAttrs = drawio.attrs || {}; - const oldSrc = oldAttrs.src; - const nodeId = oldAttrs.id ?? ref; - if (!oldSrc) { - throw new Error( - `drawioEditCells: node "${node}" on page ${pageId} has no src to edit`, - ); - } - const currentSvg = await this.fetchAttachmentText(oldSrc); - const currentModel = decodeDrawioSvg(currentSvg); - const currentHash = mxHash(currentModel); - if (currentHash !== baseHash) { - throw new Error( - `drawioEditCells: conflict — the diagram changed since it was read ` + - `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, - ); - } - - // Apply the operations to the parsed model, then run the standard pipeline. - const editedModel = applyCellOps(currentModel, operations); - const prepared = prepareModel(editedModel); - const inner = renderDiagramShapes(prepared.cells, prepared.bbox); - const diagramTitle = oldAttrs.title || "Page-1"; - const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); - - const att = await this.uploadAttachmentBuffer( - pageId, - Buffer.from(svg, "utf-8"), - "diagram.drawio.svg", - "image/svg+xml", - ); - const newSrc = `/api/files/${att.id}/${att.fileName}`; - - const collabToken = await this.getCollabTokenWithReauth(); - const pageUuid = await this.resolvePageId(pageId); - - let repointed = 0; - const mutation = await this.mutatePage( - pageUuid, - collabToken, - this.apiUrl, - (liveDoc) => { - repointed = 0; - const doc = - liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] }; - if (!Array.isArray(doc.content)) doc.content = []; - const hit = getNodeByRef(doc, ref); - if (!hit || hit.type !== "drawio") return null; - let target: any = doc; - for (const idx of hit.path) { - if (!target || !Array.isArray(target.content)) { - target = null; - break; - } - target = target.content[idx]; - } - if (!target || target.type !== "drawio") return null; - target.attrs = { - ...target.attrs, - src: newSrc, - attachmentId: att.id, - width: prepared.bbox.width, - height: prepared.bbox.height, - }; - repointed++; - return doc; - }, - ); - - if (repointed === 0) { - return { - success: true, - nodeId, - attachmentId: att.id, - warnings: [ - ...prepared.warnings, - "target drawio node was removed concurrently; uploaded attachment is unreferenced", - ], - verify: mutation.verify, - }; - } - return { - success: true, - nodeId, - attachmentId: att.id, - warnings: prepared.warnings, - verify: mutation.verify, - }; - } - - /** - * The main high-level tool: build a diagram from a SEMANTIC graph (nodes with - * a `kind`/`icon`, groups, edges) — the model never supplies coordinates or - * style strings. The server resolves icons via the shape catalog (#424), - * assigns palette colors from the preset, runs ELK layered layout (honouring - * `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups), - * and assembles linter-clean XML, then inserts it through the SAME create - * pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a - * target `node` is given (it preserves that diagram's existing coordinates and - * places only new cells); on a fresh insert it behaves like "full". - */ - async drawioFromGraph( - pageId: string, - where: { - position: "before" | "after" | "append"; - anchorNodeId?: string; - anchorText?: string; - }, - graph: Graph, - direction?: "LR" | "RL" | "TB" | "BT", - preset?: string, - layout?: GraphLayoutMode, - node?: string, - ): Promise<{ - success: boolean; - nodeId: string; - attachmentId: string; - warnings: string[]; - iconsResolved: number; - iconsMissing: string[]; - verify?: any; - }> { - await this.ensureAuthenticated(); - // Direction/preset supplied as separate params override the graph fields so - // both the flat tool schema and an inline graph can set them. - const merged: Graph = { - ...graph, - direction: direction ?? graph.direction, - preset: preset ?? graph.preset, - }; - const mode: GraphLayoutMode = layout ?? "full"; - - // Incremental into an EXISTING node: read its coords so ELK preserves them, - // and keep the full existing model so incremental MERGES (never drops) any - // cell the new graph doesn't re-list. - let existingCoords: Map | undefined; - let existingModelXml: string | undefined; - let editExisting = false; - let baseHash: string | undefined; - if (node && (mode === "incremental" || mode === "none")) { - const { node: drawio } = await this.resolveDrawioNode(pageId, node); - const src = (drawio.attrs || {}).src; - if (src) { - const svg = await this.fetchAttachmentText(src); - const model = decodeDrawioSvg(svg); - baseHash = mxHash(model); - existingModelXml = model; - existingCoords = new Map(); - for (const c of parseDrawioCells(model)) { - if (c.vertex && c.geometry.x != null && c.geometry.y != null) { - existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y }); - } - } - editExisting = true; - } - } - - const built = await buildFromGraph( - merged, - mode, - existingCoords, - existingModelXml, - ); - - if (editExisting && node && baseHash) { - // Re-target the existing diagram: replace it with the assembled model. - const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash); - return { - ...res, - iconsResolved: built.iconsResolved, - iconsMissing: built.iconsMissing, - }; - } - - const res = await this.drawioCreate(pageId, where, built.modelXml); - return { - ...res, - iconsResolved: built.iconsResolved, - iconsMissing: built.iconsMissing, - }; - } - - /** - * Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE - * parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the - * drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most - * common wiki case); other diagram types throw a clear error so the model can - * fall back to drawioFromGraph. - */ - async drawioFromMermaid( - pageId: string, - where: { - position: "before" | "after" | "append"; - anchorNodeId?: string; - anchorText?: string; - }, - mermaid: string, - preset?: string, - ): Promise<{ - success: boolean; - nodeId: string; - attachmentId: string; - warnings: string[]; - iconsResolved: number; - iconsMissing: string[]; - verify?: any; - }> { - await this.ensureAuthenticated(); - const graph = mermaidToGraph(mermaid); - if (preset) graph.preset = preset; - return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset); - } - - // --- Page history / diff / transform --- - - /** - * List the saved versions (history snapshots) of a page, newest first. - * Docmost auto-snapshots on every save. Returns one cursor-paginated page of - * results: `{ items, nextCursor }`. The history record's id field is `id`. - */ - async listPageHistory(pageId: string, cursor?: string) { - await this.ensureAuthenticated(); - const payload: Record = { pageId }; - if (cursor) payload.cursor = cursor; - const response = await this.client.post("/pages/history", payload); - const data = response.data?.data ?? response.data; - return { - items: data?.items ?? [], - nextCursor: data?.meta?.nextCursor ?? null, - }; - } - - /** - * Fetch a single page-history version including its lossless ProseMirror - * `content`. The version also carries pageId/title/createdAt. - */ - async getPageHistory(historyId: string) { - await this.ensureAuthenticated(); - const response = await this.client.post("/pages/history/info", { - historyId, - }); - return response.data?.data ?? response.data; - } - - /** - * "Restore" a version: Docmost has NO restore endpoint, so we take the - * version's `content` and write it as the page's current content via the live - * collab path (which itself creates a new history snapshot). Returns the - * affected pageId and the source historyId. - */ - async restorePageVersion(historyId: string) { - await this.ensureAuthenticated(); - const version = await this.getPageHistory(historyId); - if ( - !version || - !version.pageId || - !version.content || - typeof version.content !== "object" - ) { - throw new Error( - `restorePageVersion: history ${historyId} has no usable content`, - ); - } - // Defense-in-depth: sanitize URLs in the restored content (parity with the - // JSON write path) before writing it back. - this.validateDocUrls(version.content); - const collabToken = await this.getCollabTokenWithReauth(); - // version.pageId is the page entity id (already a UUID); resolvePageId - // short-circuits a UUID with no round-trip, so this is defensive only (#260). - const pageUuid = await this.resolvePageId(version.pageId); - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - () => version.content, - ); - return { - pageId: version.pageId, - restoredFrom: historyId, - verify: mutation.verify, - }; - } - - /** - * Diff two versions of a page and return a Docmost-equivalent change set. - * `from`/`to` each resolve to a ProseMirror doc: - * - null / undefined / "current" -> the page's CURRENT content; - * - any other string -> that historyId's content. - * Returns the diff plus the resolved version metadata for each side. - */ - async diffPageVersions(pageId: string, from?: string, to?: string) { - await this.ensureAuthenticated(); - - const isCurrent = (v?: string) => v == null || v === "" || v === "current"; - - const resolveSide = async ( - v?: string, - ): Promise<{ doc: any; meta: any }> => { - if (isCurrent(v)) { - const raw = await this.getPageRaw(pageId); - return { - doc: raw.content || { type: "doc", content: [] }, - meta: { - kind: "current", - pageId, - title: raw.title, - updatedAt: raw.updatedAt, - }, - }; - } - const version = await this.getPageHistory(v as string); - return { - doc: version.content || { type: "doc", content: [] }, - meta: { - kind: "history", - historyId: version.id, - pageId: version.pageId, - title: version.title, - createdAt: version.createdAt, - }, - }; - }; - - const fromSide = await resolveSide(from); - const toSide = await resolveSide(to); - const diff = diffDocs(fromSide.doc, toSide.doc); - return { from: fromSide.meta, to: toSide.meta, diff }; - } - - /** - * Edit a page by running an arbitrary user-supplied JS transform against the - * live document, with a diff preview + page-history safety net. - * - * The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm - * sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals, - * a 5s timeout, and NO access to require/process/fs/network. It must return a - * `{ type: "doc" }` node, which is validated structurally before any write. - * - * `ctx` exposes: - * - comments: the page's comments (fetched before the live read); - * - log: an array the transform can push diagnostics to (via console.log); - * - consume(id): mark a comment id as consumed (for deleteComments); - * - helpers: the transforms.ts primitives + commentsToFootnotes. - * - * Footnote convention used by the helpers: footnote markers are plain "[N]" - * text in the body, and the notes are an orderedList under a heading whose - * text is "Примечания переводчика". - * - * dryRun (default true): read the page's current content, run the transform, - * and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket. - * Otherwise the transform runs atomically inside mutatePageContent, optionally - * deletes consumed comments, and returns the new historyId + diff + log. - */ - async transformPage( - pageId: string, - transformJs: string, - opts: { dryRun?: boolean; deleteComments?: boolean } = {}, - ) { - const dryRun = opts.dryRun ?? true; - const deleteComments = opts.deleteComments ?? false; - - await this.ensureAuthenticated(); - // Full feed (incl. resolved): a page transform (e.g. comments -> footnotes) - // must operate on every comment, so it opts into the unfiltered feed. - const comments = (await this.listComments(pageId, true)).items; - - // ctx handed to the sandbox. consume() records ids; helpers are the pure - // transform primitives. log is captured from console.log inside the sandbox. - const ctx = { - comments, - log: [] as string[], - consumed: new Set(), - consume(id: string) { - this.consumed.add(id); - }, - helpers: { - blockText, - walk, - getList, - insertMarkerAfter, - setCalloutRange, - noteItem, - mdToInlineNodes, - commentsToFootnotes, - canonicalizeFootnotes, - insertInlineFootnote, - }, - }; - - // Captured oldDoc / newDoc for the diff (set inside runTransform). - let oldDoc: any; - let newDoc: any; - - // SYNCHRONOUS transform runner — safe to call inside mutatePageContent's - // onSynced (no await between the live read and the write). - const runTransform = (liveDoc: any): any => { - oldDoc = structuredClone(liveDoc); - const sandbox: Record = { - doc: structuredClone(liveDoc), - ctx, - structuredClone, - console: { - log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")), - }, - }; - // Wrap the provided string in parentheses so both an expression-arrow - // (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a - // fresh context with no require/process/module so the transform cannot - // touch fs/network/process. 5s wall-clock timeout. - let fn: any; - try { - fn = vm.runInNewContext("(" + transformJs + ")", sandbox, { - timeout: 5000, - }); - } catch (e: any) { - throw new Error(`transform did not compile: ${e?.message ?? e}`); - } - if (typeof fn !== "function") { - throw new Error( - "transform must evaluate to a function (doc, ctx) => doc", - ); - } - const raw = vm.runInNewContext( - "f(d, c)", - { f: fn, d: sandbox.doc, c: ctx }, - { timeout: 5000 }, - ); - if ( - !raw || - typeof raw !== "object" || - raw.type !== "doc" || - !Array.isArray(raw.content) - ) { - throw new Error( - 'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })', - ); - } - // Validate the RAW transform output FIRST (structure — including the - // MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer - // recurses without a depth limiter, so validating after it would turn a - // too-deep doc into an opaque "Maximum call stack size exceeded" instead of - // the intended "nesting exceeds the maximum depth" error. - this.validateDocStructure(raw); - this.validateDocUrls(raw); - // Auto-canonicalize footnotes after the transform (idempotent): no write - // path can leave footnotes out of order / orphaned / in a raw `[^id]` - // block. In a dryRun preview this may surface footnote edits the script - // author did not write (the canonicalizer tidied them) — that is expected. - // #419: normalize + merge glyph-forked definitions before canonicalizing. - const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw)); - newDoc = result; - return result; - }; - - if (dryRun) { - // Preview only: run against the current REST snapshot, never open the - // socket. oldDoc/newDoc are captured by runTransform. - const raw = await this.getPageRaw(pageId); - const current = raw.content || { type: "doc", content: [] }; - runTransform(current); - // Run an independent Yjs-encodability check (same sanitize + schema as the - // apply path), so the preview fails with the same descriptive error when - // the doc is not encodable instead of returning a misleadingly-green diff. - assertYjsEncodable(newDoc); - return { - pushed: false, - diff: diffDocs(oldDoc, newDoc), - log: ctx.log, - }; - } - - // Apply atomically against the live doc. - const collabToken = await this.getCollabTokenWithReauth(); - // Open the collab doc by the canonical UUID, never the slugId (#260). - const pageUuid = await this.resolvePageId(pageId); - const mutation = await mutatePageContent( - pageUuid, - collabToken, - this.apiUrl, - runTransform, - ); - - // Optionally delete consumed comments (best-effort; a delete failure must - // not undo the successful write). - const deletedComments: string[] = []; - if (deleteComments) { - for (const id of ctx.consumed) { - try { - await this.deleteComment(id); - deletedComments.push(id); - } catch (e) { - if (process.env.DEBUG) { - console.error(`transform: failed to delete comment ${id}:`, e); - } - } - } - } - - // Fetch the newest historyId (Docmost snapshots on the write above). - let historyId: string | null = null; - try { - const hist = await this.listPageHistory(pageId); - historyId = hist.items?.[0]?.id ?? null; - } catch (e) { - if (process.env.DEBUG) { - console.error("transform: failed to fetch history id:", e); - } - } - - return { - pushed: true, - historyId, - diff: diffDocs(oldDoc, newDoc), - deletedComments, - log: ctx.log, - verify: mutation.verify, - }; - } -} +export class DocmostClient extends DocmostClientBase {} diff --git a/packages/mcp/src/client/comments.ts b/packages/mcp/src/client/comments.ts new file mode 100644 index 00000000..c1fd557d --- /dev/null +++ b/packages/mcp/src/client/comments.ts @@ -0,0 +1,704 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { assertFullUuid } from "./errors.js"; +import { + filterWorkspace, + filterSpace, + filterPage, + filterComment, + filterSearchResult, +} from "../lib/filters.js"; +import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { + replaceNodeById, + replaceNodeByIdWithMany, + reassignCollidingBlockIds, + deleteNodeById, + assertUnambiguousMatch, + insertNodeRelative, + insertNodesRelative, + blockPlainText, + buildOutline, + getNodeByRef, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + findInvalidNode, +} from "@docmost/prosemirror-markdown"; +import { + applyAnchorInDoc, + countAnchorMatches, + getAnchoredText, + resolveAnchorSelection, + normalizeForMatch, +} from "../lib/comment-anchor.js"; +import { closestBlockHint } from "../lib/text-normalize.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; + +// Public method surface of CommentsMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements ICommentsMixin` fails to compile on drift. +export interface ICommentsMixin { + listComments(pageId: string, includeResolved?: boolean): any; + getComment(commentId: string): any; + createComment(pageId: string, content: string, type?: "page" | "inline", selection?: string, parentCommentId?: string, suggestedText?: string): any; + updateComment(commentId: string, content: string): any; + deleteComment(commentId: string): any; + resolveComment(commentId: string, resolved: boolean): any; + checkNewComments(spaceId: string, since: string, parentPageId?: string): any; +} + +export function CommentsMixin>(Base: TBase): GConstructor & TBase { + abstract class CommentsMixin extends Base implements ICommentsMixin { + // --- Comment methods (ported from upstream PR #3 by Max Nikitin) --- + + /** + * Normalize a comment's `content` into a ProseMirror doc object before + * markdown conversion. createComment/updateComment send content as a + * JSON.stringify(...) STRING, and the server stores it as-is, so on read it + * comes back as a string. convertProseMirrorToMarkdown returns "" for a + * string, so parse it first (guarded — fall back to the raw value on any + * parse failure so a non-JSON legacy value is still handled gracefully). + */ + protected parseCommentContent(content: any): any { + if (typeof content !== "string") return content; + try { + return JSON.parse(content); + } catch { + return content; + } + } + + /** + * List comments on a page (cursor-paginated), content as markdown. + * + * DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the + * agent sees only active discussions: a top-level comment with `resolvedAt` + * set AND every reply under it (a reply of a closed thread is part of the + * closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how + * many resolved top-level threads were hidden so the agent can re-query with + * `includeResolved: true` to see everything. Active threads always stay. + * + * Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) — callers that + * need the full feed (lossless export, transformPage, checkNewComments) pass + * `includeResolved: true` and read `.items`. + */ + async listComments(pageId: string, includeResolved = false) { + await this.ensureAuthenticated(); + let allComments: any[] = []; + let cursor: string | null = null; + + // Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments + // ever stops advancing the cursor (the exact #442 drift scenario) this loop + // would otherwise spin forever accumulating duplicates. + const MAX_PAGES = 50; + let truncated = false; + + for (let page = 0; page < MAX_PAGES; page++) { + const payload: Record = { pageId, limit: 100 }; + if (cursor) payload.cursor = cursor; + + const response = await this.client.post("/comments", payload); + const data = response.data.data || response.data; + const items = data.items || []; + allComments = allComments.concat(items); + + // Advance strictly via the server-issued cursor. A missing nextCursor or a + // cursor identical to the one we just sent means the end (or a server that + // ignores our pagination param) — stop instead of re-fetching page one. + const next: string | null = data.meta?.nextCursor || null; + if (!next || next === cursor) break; + cursor = next; + + // Reaching the ceiling with a still-advancing cursor means truncation. + if (page === MAX_PAGES - 1) truncated = true; + } + + if (truncated) { + console.warn( + `listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`, + ); + } + + const mapped = allComments.map((comment: any) => { + const markdown = comment.content + ? convertProseMirrorToMarkdown( + this.parseCommentContent(comment.content), + ) + : ""; + return filterComment(comment, markdown); + }); + + if (includeResolved) { + return { items: mapped, resolvedThreadsHidden: 0 }; + } + + // Ids of RESOLVED top-level threads (a top-level comment has no + // parentCommentId). A whole thread is hidden when its root is resolved. + const resolvedRootIds = new Set( + mapped + .filter((c) => !c.parentCommentId && c.resolvedAt != null) + .map((c) => c.id), + ); + + const items = mapped.filter((c) => { + // Hide the resolved root itself and every reply anchored to it. A reply's + // own resolvedAt is irrelevant — its membership follows the parent thread. + // ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId + // always points at the thread ROOT (no reply-of-reply nesting), so a single + // level of parent lookup covers a whole thread. If nested replies are ever + // introduced, a deep reply of a resolved thread would need a root-walk here. + if (!c.parentCommentId) return !resolvedRootIds.has(c.id); + return !resolvedRootIds.has(c.parentCommentId); + }); + + return { items, resolvedThreadsHidden: resolvedRootIds.size }; + } + + + async getComment(commentId: string) { + // Fail fast (#436): reject a truncated id before any network call. + assertFullUuid("get_comment", "commentId", commentId); + await this.ensureAuthenticated(); + const response = await this.client.post("/comments/info", { commentId }); + const comment = response.data.data || response.data; + const markdown = comment.content + ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) + : ""; + return { + data: filterComment(comment, markdown), + success: true, + }; + } + + /** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */ + protected topLevelBlockTexts(doc: any): string[] { + const content = doc && Array.isArray(doc.content) ? doc.content : []; + return content + .map((b: any) => blockPlainText(b)) + .filter((t: string) => t.length > 0); + } + + /** + * True when per-block anchoring failed but the (normalized) selection DOES + * appear in the blocks' joined plain text — i.e. it straddles a block + * boundary. Blocks are joined with a newline (collapsed to one space by + * normalizeForMatch) so a selection whose parts are separated by a paragraph + * break still matches. Callers only reach here after single-block anchoring + * (incl. the markdown-strip fallback) has already failed. + */ + protected selectionSpansMultipleBlocks( + blockTexts: string[], + selection: string, + ): boolean { + const normSel = normalizeForMatch(selection).norm.trim(); + if (normSel.length === 0) return false; + const joined = normalizeForMatch(blockTexts.join("\n")).norm; + return joined.indexOf(normSel) !== -1; + } + + /** + * Build the actionable error for a createComment anchor MISS, porting + * editPageText's self-correction affordances: an explicit "spans multiple + * blocks" message when the selection straddles a block boundary, otherwise a + * "closest block text" hint quoting the block that holds the selection's + * longest token. `live` switches the wording between the pre-check (reading the + * persisted page) and the post-create live-anchor failure (which rolls back). + */ + protected anchorNotFoundError( + doc: any, + selection: string, + live: boolean, + ): Error { + const blockTexts = this.topLevelBlockTexts(doc); + const rolled = live ? " The comment was rolled back." : ""; + if (this.selectionSpansMultipleBlocks(blockTexts, selection)) { + return new Error( + "createComment: the selection spans multiple blocks; anchor on a " + + "contiguous fragment within a SINGLE paragraph/block (<=250 chars)." + + rolled, + ); + } + const where = live ? "in the live document" : "in the page"; + return new Error( + `createComment: could not find the selection text ${where} to anchor ` + + "the comment. Provide the EXACT contiguous text from a single " + + "paragraph/block (<=250 chars)." + + closestBlockHint(blockTexts, selection) + + rolled, + ); + } + + /** + * Create an inline comment anchored to its `selection` text, or a reply. + * + * Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a + * `selection`: the `type` argument is kept for interface compatibility but the + * effective type is coerced to "inline". The selection has to anchor in the + * document; if it cannot, the comment is rolled back and an error is thrown so + * the caller is forced to supply a proper inline selection rather than leaving + * an orphan, unanchored comment behind. Replies (parentCommentId set) inherit + * their parent's anchor: they take NO selection and are not anchored. + */ + async createComment( + pageId: string, + content: string, + type: "page" | "inline" = "page", + selection?: string, + parentCommentId?: string, + suggestedText?: string, + ) { + // Fail fast (#436): a provided parent id must be a full UUID before any + // network call. Validate only when truthy — a falsy parentCommentId means + // "top-level comment" (mirrors the isReply computation below), not a reply. + if (parentCommentId) { + assertFullUuid("createComment", "parentCommentId", parentCommentId); + } + await this.ensureAuthenticated(); + + const isReply = !!parentCommentId; + const hasSuggestion = + suggestedText !== undefined && suggestedText !== null; + // Defense in depth mirroring the server DTO/service: a suggested edit rewrites + // the exact anchored text, so it is only meaningful on a top-level inline + // comment that carries a selection. + if (hasSuggestion) { + if (isReply) { + throw new Error( + "createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.", + ); + } + if (!selection || !selection.trim()) { + throw new Error( + "createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.", + ); + } + } + // Only top-level comments are inline-anchored, so they are stored as + // "inline". Replies carry no inline selection, so they keep the historical + // general ("page") type — both backward-compatible and semantically correct. + // The `type` argument is kept for interface compatibility; createComment + // normalizes the effective type internally, so callers may pass "inline". + const effectiveType: "page" | "inline" = isReply ? "page" : "inline"; + if (!isReply && (!selection || !selection.trim())) { + throw new Error( + "createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment", + ); + } + + // For a SUGGESTION, the value we store as the comment's `selection` must be + // the RAW document substring the mark lands on (typographic quotes/dashes, + // nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is + // placed via normalization, so when the doc was auto-converted to + // typographic the raw substring differs from the agent input; apply-time + // compares the stored selection to the marked doc text STRICTLY, so storing + // the raw substring is what makes "Apply" succeed instead of a spurious 409. + // Captured in the pre-check below (which already reads the page) and used as + // payload.selection. Ordinary comments keep sending the raw agent selection. + let anchoredSelection: string | null = null; + // Set when the anchor matched only after stripping markdown from the + // selection (the strip fallback); surfaced as a soft warning like + // editPageText does, so a stale-markdown selection is flagged. + let anchorNormalized = false; + + // For a top-level comment, fail BEFORE creating anything when the selection + // is not present in the persisted document — this avoids leaving an orphan + // comment + notification behind. A read failure (network) is non-fatal: the + // live anchor step below still enforces the anchoring invariant. + if (!isReply && selection) { + try { + const page = await this.getPageJson(pageId); + if (hasSuggestion) { + // A suggestion's anchor MUST be unambiguous: applying it rewrites the + // exact anchored text, and ordinary anchoring silently takes the first + // occurrence, so 0 matches -> not found and >=2 -> ambiguous, both + // rejected BEFORE creating the comment. + const matches = countAnchorMatches(page.content, selection); + if (matches === 0) { + throw this.anchorNotFoundError(page.content, selection, false); + } + if (matches >= 2) { + throw new Error( + `createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` + + "A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " + + "(still <=250 chars) so it appears exactly once.", + ); + } + // Exactly one match: capture the RAW anchored substring to store as the + // comment selection (so apply-time equality holds). If this returns + // null despite countAnchorMatches===1 (shouldn't happen), fall back to + // the raw agent selection below rather than crash. + anchoredSelection = getAnchoredText(page.content, selection); + anchorNormalized = resolveAnchorSelection( + page.content, + selection, + ).normalized; + } else { + const resolved = resolveAnchorSelection(page.content, selection); + if (!resolved.found) { + throw this.anchorNotFoundError(page.content, selection, false); + } + anchorNormalized = resolved.normalized; + } + } catch (e) { + // Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors; + // swallow read/network errors so the live anchor step can still try (and + // enforce) anchoring. + if ( + e instanceof Error && + (e.message.startsWith("createComment: could not find the selection") || + e.message.startsWith( + "createComment: the selection spans multiple blocks", + ) || + e.message.startsWith( + "createComment: the suggestion's selection is ambiguous", + )) + ) { + throw e; + } + if (process.env.DEBUG) { + console.error( + "Pre-check getPageJson failed; deferring to live anchor step:", + e, + ); + } + } + } + + // Convert through the full Docmost schema. Deliberately the NON-canonicalizing + // variant: a comment body may carry a footnote definition with no matching + // reference, and canonicalization would drop it (data loss). See + // markdownToProseMirror vs markdownToProseMirrorCanonical. + const jsonContent = await markdownToProseMirror(content); + const payload: Record = { + pageId, + content: JSON.stringify(jsonContent), + type: effectiveType, + }; + // For a suggestion, store the RAW anchored substring (anchoredSelection) so + // the stored selection === the text under the mark === apply-time + // expectedText. Ordinary comments (and the null fallback) keep the raw + // agent selection — their selection is only display/anchor and never used + // by apply, so their behavior is unchanged. + if (!isReply && selection) + payload.selection = anchoredSelection ?? selection; + if (parentCommentId) payload.parentCommentId = parentCommentId; + // Only a top-level inline comment (with a selection) may carry a suggestion. + if (!isReply && selection && hasSuggestion) { + payload.suggestedText = suggestedText; + } + + const response = await this.client.post("/comments/create", payload); + const comment = response.data.data || response.data; + const markdown = comment.content + ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) + : content; + const result: any = { + data: filterComment(comment, markdown), + success: true, + }; + + // Replies inherit the parent's anchor: no selection, no anchoring. + if (isReply) { + return result; + } + + // Anchor the comment in the document. The /comments/create API records the + // comment + its `selection` text, but it does NOT insert the comment MARK + // into the page content, so without this the inline comment has no + // highlight/anchor and is not clickable. If anchoring fails the comment is + // rolled back (deleted) and an error is thrown — never an orphan comment. + const newCommentId: string = comment.id; + // Guard: a create response without an id would mean writing a comment mark + // with commentId: undefined and a later delete of a falsy id. We have no id + // to roll back here (nothing was created with an id), so just fail loudly. + if (!newCommentId) { + throw new Error( + "createComment: the server returned no comment id, so the comment could not be anchored", + ); + } + let anchored = false; + // Set inside the transform when a suggestion's live anchor is ambiguous + // (>=2 occurrences), so the rollback path can surface the right error. + let ambiguousInLiveDoc = false; + // Captured inside the transform on a not-found abort, so the rollback path + // can surface the closest-block / spans-multiple-blocks hint built from the + // LIVE document (the pre-check page is not in scope there). + let liveNotFoundError: Error | null = null; + try { + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). The + // /comments/create REST call above keeps the agent-supplied id. + const pageUuid = await this.resolvePageId(pageId); + // Route through the mutatePage seam (not the free function) so this + // wrapper's uniqueness gate + rollback can be unit-tested without a live + // Hocuspocus collab socket. + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + const doc = + liveDoc && liveDoc.type === "doc" + ? liveDoc + : { type: "doc", content: [] }; + if (hasSuggestion) { + // Authoritative uniqueness check against the LIVE document: a + // suggestion must anchor to EXACTLY ONE occurrence, otherwise + // "Apply" would rewrite the wrong/ambiguous text. If the live doc + // no longer has exactly one occurrence (it changed since the + // pre-check), abort so the just-created comment is rolled back + // rather than mis-anchored to the first occurrence. + const liveCount = countAnchorMatches(doc, selection as string); + if (liveCount !== 1) { + ambiguousInLiveDoc = liveCount >= 2; + if (liveCount === 0) { + liveNotFoundError = this.anchorNotFoundError( + doc, + selection as string, + true, + ); + } + return null; + } + } + if (applyAnchorInDoc(doc, selection as string, newCommentId)) { + anchored = true; + return doc; + } + // Selection text not found in the LIVE document: abort the write. The + // rollback + throw below turns this into a hard error. + liveNotFoundError = this.anchorNotFoundError( + doc, + selection as string, + true, + ); + return null; + }, + ); + result.verify = mutation.verify; + } catch (e) { + // The comment record already exists; roll it back so we never leave an + // orphan, then rethrow the original anchoring error. + await this.safeDeleteComment(newCommentId); + throw e; + } + + if (!anchored) { + // Mutation aborted because the selection was not found (or, for a + // suggestion, was ambiguous) in the live document. Roll back the comment + // and surface a hard error. + await this.safeDeleteComment(newCommentId); + if (ambiguousInLiveDoc) { + throw new Error( + "createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.", + ); + } + throw ( + liveNotFoundError ?? + new Error( + "createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back", + ) + ); + } + + // Soft warning (like editPageText): the selection only matched after + // stripping markdown, so the caller likely quoted a styled fragment. + if (anchorNormalized) { + result.warning = + "The selection matched only after stripping markdown syntax; the comment " + + "was anchored on the document's plain text. Copy the selection verbatim " + + "from getPage / searchInPage output to avoid this."; + } + + result.anchored = true; + return result; + } + + /** + * Best-effort rollback of a just-created comment. Swallows any delete failure + * (logging under DEBUG) so a failed cleanup never masks the original error. + */ + protected async safeDeleteComment(commentId: string): Promise { + // Defense in depth: never call the delete API with a falsy id — there is + // nothing to roll back, and deleteComment(undefined) would hit a bad route. + if (!commentId) return; + try { + await this.deleteComment(commentId); + } catch (delErr) { + if (process.env.DEBUG) { + console.error( + "Failed to roll back comment after anchoring error:", + delErr, + ); + } + } + } + + + async updateComment(commentId: string, content: string) { + // Fail fast (#436): reject a truncated id before any network call. + assertFullUuid("updateComment", "commentId", commentId); + await this.ensureAuthenticated(); + // NON-canonicalizing on purpose (comment body — see createComment). + const jsonContent = await markdownToProseMirror(content); + await this.client.post("/comments/update", { + commentId, + content: JSON.stringify(jsonContent), + }); + return { + success: true, + commentId, + message: "Comment updated successfully.", + }; + } + + + async deleteComment(commentId: string) { + // Fail fast (#436): reject a truncated id before any network call. + assertFullUuid("deleteComment", "commentId", commentId); + await this.ensureAuthenticated(); + return this.client + .post("/comments/delete", { commentId }) + .then((res) => res.data); + } + + /** + * Resolve or reopen a top-level comment thread (reversible — `resolved` + * toggles the state). Only top-level comments can be resolved; the server + * rejects resolving a reply. Hits POST /comments/resolve. + */ + async resolveComment(commentId: string, resolved: boolean) { + // Fail fast (#436): reject a truncated id before any network call. + assertFullUuid("resolveComment", "commentId", commentId); + await this.ensureAuthenticated(); + const response = await this.client.post("/comments/resolve", { + commentId, + resolved, + }); + const comment = response.data?.data ?? response.data; + return { + success: true, + commentId, + resolved, + comment, + }; + } + + /** + * Check for new comments across pages in a space (optionally scoped to a + * subtree): pages updated after `since` are scanned and their comments + * filtered by createdAt > since. + */ + async checkNewComments( + spaceId: string, + since: string, + parentPageId?: string, + ) { + await this.ensureAuthenticated(); + + const sinceDate = new Date(since); + + // Reject an unparseable `since`: comparing against an Invalid Date silently + // yields zero new comments (every `>` against NaN is false), which would + // mask a malformed input as "nothing new" instead of erroring. + if (Number.isNaN(sinceDate.getTime())) { + throw new Error( + `checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`, + ); + } + + // 1. Enumerate the FULL set of pages in scope via the page tree (a complete + // page index), NOT the bounded "/pages/recent" feed which caps at ~5000 + // recent items and silently misses comments on older pages. + // + // Subtree scope: when parentPageId is given, the scope is that page ITSELF + // plus every descendant. Otherwise the scope is the whole space (all roots + // and their descendants). + // + // NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not + // bump it (verified on a live server), so such a filter silently misses + // comments on pages that were not otherwise edited. The complete tree walk + // already restricts the scope correctly, so no recent-feed allow-list is + // needed any more. + // + // The subtree scope (parentPageId given) already INCLUDES the root node + // itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so + // no separate getPageRaw fetch for the parent is needed. + const { pages: pagesInScope, truncated } = await this.enumerateSpacePages( + spaceId, + parentPageId, + ); + + // 2. Fetch comments for each page, keep ones created after since + const results: any[] = []; + for (const page of pagesInScope) { + try { + // Full feed (incl. resolved): a "new comments since" scan reports all + // recent activity; the active-only filter is scoped to listComments. + const comments = (await this.listComments(page.id, true)).items; + const newComments = comments.filter( + (c: any) => new Date(c.createdAt) > sinceDate, + ); + if (newComments.length > 0) { + results.push({ + pageId: page.id, + pageTitle: page.title, + comments: newComments, + }); + } + } catch (e: any) { + // Skip pages with errors (e.g. deleted between calls) + } + } + + const totalNewComments = results.reduce( + (sum, r) => sum + r.comments.length, + 0, + ); + + // `truncated` is reported by enumerateSpacePages: it is true ONLY when the + // stdio fallback BFS hit its node cap. The primary /pages/tree path is + // uncapped, so a space with legitimately many pages is not falsely flagged. + return { + since, + scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`, + checkedPages: pagesInScope.length, + pagesWithNewComments: results.length, + totalNewComments, + truncated, + comments: results, + }; + } + + // --- Image upload / embedding --- + + /** Map a Content-Type string to a supported MIME type, or null if unsupported. */ + } + return CommentsMixin; +} diff --git a/packages/mcp/src/client/context.ts b/packages/mcp/src/client/context.ts new file mode 100644 index 00000000..498c3b79 --- /dev/null +++ b/packages/mcp/src/client/context.ts @@ -0,0 +1,690 @@ +// Shared client context + core seams (issue #450). The abstract base of the +// DocmostClient mixin chain: it owns ALL shared instance state (the axios +// client, apiUrl, auth tokens, the resolvePageId cache, the collab-token cache, +// the sandbox/metrics sinks) and the core HTTP/auth/pagination/write seams every +// domain module builds on. Domain modules are mixins layered on top; the final +// DocmostClient (client.ts) assembles them. Extracted VERBATIM from the original +// monolith — only field/seam visibility was widened from `private` to +// `protected` so sibling mixins can reach the shared state through `this`, and +// the cross-module methods that live in other mixins are declared `abstract` +// here so `this.` type-checks. No behaviour changed. +import axios, { AxiosInstance } from "axios"; +import FormData from "form-data"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { acquireCollabSession } from "../lib/collab-session.js"; +import { withPageLock, isUuid } from "../lib/page-lock.js"; +import { getCollabToken, performLogin } from "../lib/auth-utils.js"; +import { formatDocmostAxiosError } from "./errors.js"; + +// A generic mixin base constructor (issue #450). Each domain mixin is a factory +// `>(Base: T) => class extends Base` +// so the mixins compose into one prototype chain sharing this context. +export type GConstructor = abstract new (...args: any[]) => T; + +/** + * Configuration for a DocmostClient / MCP server instance. A discriminated + * union: either service-account credentials (email/password — the client calls + * performLogin, powering the external /mcp HTTP endpoint and the stdio CLI) OR + * a token getter (getToken — the client uses the returned BARE access JWT as + * the Bearer and never calls performLogin; used for the internal per-user path). + * + * Both branches may ALSO carry an optional `getCollabToken` provider. When set, + * content mutations (which go over the collaboration websocket) use the token it + * returns INSTEAD of calling `POST /auth/collab-token`. The internal per-user + * agent path uses this to hand the client a provenance collab token (signed + * `actor:'agent'`+`aiChatId`), so agent content edits are attributed without a + * spoofable client-side field. When absent the client keeps the original + * `/auth/collab-token` path (service-account/stdio unchanged). + * + * Housed here (not in index.ts) so client.ts has no type dependency on index.ts; + * index.ts re-exports it for the package's public surface. + */ +// Sink the stash tool writes blobs into. The host app binds this to its in-RAM +// SandboxStore and composes the public `uri` (the package never sees the store +// or any env). `put` returns the anonymous read URL plus integrity metadata. +export type SandboxPut = ( + buf: Buffer, + mime: string, +) => { uri: string; sha256: string; size: number }; + +export type DocmostMcpConfig = { apiUrl: string } & ( + | { email: string; password: string } + | { getToken: () => Promise } // returns a BARE JWT; the client adds "Bearer " +) & { + // Optional collab-token provider (returns a ready collab JWT). Common to + // both branches; see the type doc above. + getCollabToken?: () => Promise; + // Optional blob sandbox sink. Present only where the stash tool is wired; + // when absent, stashPage throws a clear "not configured" error. The + // optional `has`/`evict` probes let stashPage keep its mirror counts honest + // under the store's FIFO eviction (see stashPage); older sinks omit them. + sandbox?: { + put: SandboxPut; + has?: (uri: string) => boolean; + evict?: (uri: string) => void; + }; + // Dependency-neutral metrics sink. When present, the client emits generic + // (name, value, labels) samples; the HOST maps those names onto its own + // metrics registry (the package never depends on prom-client or the server). + // Absent in standalone/stdio mode → the client is a complete no-op here. + onMetric?: ( + name: string, + value: number, + labels?: Record, + ) => void; + }; + + +/** + * Collab-token cache TTL in milliseconds (issue #435). Read fresh from the + * environment on every mint — like collab-session.ts readConfig — so tests and a + * live rollback can change it without reloading the module. + * + * Why a cache at all: the live CollabSession registry (#400/#431) keys sessions + * on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH + * collab-token sources mint a FRESH token per mutation — the in-app provider + * re-signs a JWT whose iat/exp (seconds) changes every second, and the external + * MCP POSTs /auth/collab-token each call — so the token in the key changed on + * every op and the session was almost never reused (connect-storms, 25s + * timeouts, zombie sessions). Caching the token per-client keeps the key stable + * across a burst of mutations so ONE session is reused. + * + * Default 5 min: well under the 24h collab-token lifetime AND <= the collab + * session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the + * permission-staleness window is not widened beyond what #431 already accepted. + * The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the + * cache — an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables + * the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls + * back to the 5-min default with the cache ON — parseInt yields NaN, which is + * treated as "not configured", not as "disabled". So to turn the cache off you + * must set the value to exactly 0, not to garbage. + */ +function readCollabTokenTtlMs(): number { + const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10); + return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000; +} + +export abstract class DocmostClientContext { + protected client: AxiosInstance; + protected token: string | null = null; + protected apiUrl: string; + // email/password are only set on the service-account (credentials) variant; + // null on the getToken variant (where there are no credentials to log in with). + protected email: string | null = null; + protected password: string | null = null; + // Per-user token provider. When set, login() calls it to obtain a BARE access + // JWT instead of performLogin, and the 401/403 re-auth path re-calls it. + protected getTokenFn: (() => Promise) | null = null; + // Optional collab-token provider. When set, getCollabTokenWithReauth() returns + // its token instead of calling POST /auth/collab-token; on a 401/403 it is + // re-invoked once. Used by the internal agent to carry signed provenance. + protected getCollabTokenFn: (() => Promise) | null = null; + // Optional blob-sandbox sink for the stash tool. Null when not configured. + protected sandboxPut: SandboxPut | null = null; + // Optional probes paired with the sink. `has` lets stashPage detect a blob + // FIFO-evicted by a LATER put in the same stash; `evict` lets it free this + // op's image blobs if the final doc put throws. Null when the sink omits them. + protected sandboxHas: ((uri: string) => boolean) | null = null; + protected sandboxEvict: ((uri: string) => void) | null = null; + // Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric). + // Null on the legacy positional form and whenever the host omits it → no-op. + protected onMetricFn: + | ((name: string, value: number, labels?: Record) => void) + | null = null; + // In-flight login dedup: when the token expires, the 401 interceptor, + // ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries + // can all call login() at once. Memoizing a single promise collapses that + // thundering herd into ONE /auth/login request that everyone awaits. + protected loginPromise: Promise | null = null; + // Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the + // page's canonical UUID, so repeated collab edits on the same page do not + // re-fetch /pages/info. A UUID input short-circuits before this cache (see + // resolvePageId), so only slugId->uuid entries are stored/read here. + protected pageIdCache = new Map(); + + // Collab-token cache (issue #435): the last minted collab token plus the + // wall-clock time it was minted, so a burst of content mutations reuses ONE + // token and therefore ONE live CollabSession (whose registry key includes the + // token — #400 invariant 4). Per-instance: a DocmostClient is built per + // user/per chat request, so a cached token can never leak across identities. + // Reset whenever the client's identity changes (login() / this.token cleared); + // bypassed on a forced refresh (the 401/403 reauth path). null = no token yet. + protected collabTokenCache: { token: string; mintedAt: number } | null = null; + + // Two construction forms: + // - new DocmostClient(config) // discriminated union (current) + // - new DocmostClient(baseURL, email, password) // legacy positional creds + // The positional form is retained so existing callers/tests keep working; it + // is exactly equivalent to the credentials branch of the object form. + constructor(config: DocmostMcpConfig); + constructor(baseURL: string, email: string, password: string); + constructor( + configOrBaseURL: DocmostMcpConfig | string, + email?: string, + password?: string, + ) { + // Normalize the legacy positional form into the object union. + const config: DocmostMcpConfig = + typeof configOrBaseURL === "string" + ? { apiUrl: configOrBaseURL, email: email!, password: password! } + : configOrBaseURL; + + this.apiUrl = config.apiUrl; + if ("getToken" in config) { + // Token variant: carry the user's JWT via getToken; no credentials, so + // login() must never call performLogin (there is nothing to log in with). + this.getTokenFn = config.getToken; + } else { + // Service-account variant: behaves exactly as before (performLogin). + this.email = config.email; + this.password = config.password; + } + // Optional, available to both variants. When present, content mutations get + // their collab token from here instead of POST /auth/collab-token. + if (config.getCollabToken) { + this.getCollabTokenFn = config.getCollabToken; + } + if (config.sandbox) { + this.sandboxPut = config.sandbox.put; + this.sandboxHas = config.sandbox.has ?? null; + this.sandboxEvict = config.sandbox.evict ?? null; + } + // Legacy positional form carries no onMetric → null (complete no-op). + this.onMetricFn = config.onMetric ?? null; + this.client = axios.create({ + baseURL: this.apiUrl, + // Default request timeout so a hung connection cannot wedge a per-page + // lock or block the server indefinitely. Multipart uploads override this + // with a longer per-request timeout. + timeout: 30000, + headers: { + "Content-Type": "application/json", + }, + }); + + // Re-authenticate transparently on a 401/403 once: the JWT authToken can + // expire while the server is long-running, after which every cached-token + // request would otherwise fail until a manual restart. On such a response, + // clear the stale token, perform a fresh login, and replay the original + // request exactly once (guarded by config._retry to avoid infinite loops; + // the login request itself is never retried). + this.client.interceptors.response.use( + (response) => response, + async (error) => { + const config = error.config; + const status = error.response?.status; + const isAuthError = status === 401 || status === 403; + const isLoginRequest = + typeof config?.url === "string" && config.url.includes("/auth/login"); + + if (config && isAuthError && !config._retry && !isLoginRequest) { + config._retry = true; + // Drop the stale token + Authorization header before re-login. Also + // clear the collab-token cache (#435): a new identity/login must not + // keep serving a collab token minted under the old one. + this.token = null; + this.collabTokenCache = null; + delete this.client.defaults.headers.common["Authorization"]; + try { + await this.login(); + } catch (loginError) { + // Re-login failed: surface the original error to the caller. + return Promise.reject(error); + } + // Re-issue the original request with the freshly minted Bearer token. + // Read it from the default header that login() just set, not from + // this.token, to avoid a theoretical "Bearer null" if this.token was + // cleared between login() resolving and this point. + config.headers = config.headers || {}; + config.headers["Authorization"] = + this.client.defaults.headers.common["Authorization"]; + return this.client.request(config); + } + + return Promise.reject(error); + }, + ); + + // Diagnostics interceptor (issue #437). Registered AFTER the re-login + // interceptor so a successful re-login retry (which resolves to a real + // response) is never seen here as an error; only a genuine failure reaches + // this rejection handler. It reformats error.message IN PLACE (see + // formatDocmostAxiosError — kept as a mutation, not a custom Error class, so + // the surrounding axios.isAxiosError / error.response?.status / config._retry + // checks keep working) and re-rejects the SAME error. The _docmostFormatted + // flag makes a re-processed retry-failure a no-op. + this.client.interceptors.response.use( + (response) => response, + (error) => { + formatDocmostAxiosError(error); + return Promise.reject(error); + }, + ); + } + + + // --- Cross-module seams (issue #450) ----------------------------------- + // A method in one domain mixin sometimes calls a PROTECTED method owned by + // another mixin (e.g. nodes-write -> validateDocUrls in doc-validate). Those + // callees are `protected`, so they cannot be surfaced through the public + // per-mixin interfaces. Declaring them here on the shared base lets `this.` + // type-check across modules. Each is a stub that is ALWAYS overridden by the + // owning mixin (layered above this base in the chain), so the body never runs; + // it throws only to make an impossible mis-wiring loud instead of silent. + // (The PUBLIC cross-module callees — getPage, getPageJson, listComments, + // deleteComment, listPageHistory — arrive via the mixins' public interfaces, + // so they are not restated here.) + protected enumerateSpacePages( + _spaceId: string, + _rootPageId?: string, + ): Promise<{ pages: any[]; truncated: boolean }> { + throw new Error("enumerateSpacePages not wired (missing ReadMixin)"); + } + protected validateDocUrls(_node: any, _depth?: number): void { + throw new Error("validateDocUrls not wired (missing DocValidateMixin)"); + } + protected validateDocStructure(_node: any, _depth?: number): void { + throw new Error("validateDocStructure not wired (missing DocValidateMixin)"); + } + protected assertValidNodeShape(_op: string, _node: any): void { + throw new Error("assertValidNodeShape not wired (missing DocValidateMixin)"); + } + protected fetchInternalFile( + _src: string, + ): Promise<{ buffer: Buffer; mime: string }> { + throw new Error("fetchInternalFile not wired (missing StashMixin)"); + } + protected uploadAttachmentBuffer( + _pageId: string, + _buffer: Buffer, + _fileName: string, + _mime: string, + ): Promise<{ id: string; fileName: string; fileSize: number }> { + throw new Error("uploadAttachmentBuffer not wired (missing MediaMixin)"); + } + protected fetchAttachmentText(_src: string): Promise { + throw new Error("fetchAttachmentText not wired (missing MediaMixin)"); + } + // PUBLIC cross-module callees. Declared here too (as always-overridden stubs) + // so a mixin calling e.g. `this.getPageJson` type-checks against the base — + // the mixin's own public interface only covers its own methods. The real + // implementations live in ReadMixin / CommentsMixin / PagesMixin and shadow + // these on the prototype chain. + getPage(_pageId: string): Promise { + throw new Error("getPage not wired (missing ReadMixin)"); + } + getPageJson(_pageId: string): Promise { + throw new Error("getPageJson not wired (missing ReadMixin)"); + } + listComments(_pageId: string, _includeResolved?: boolean): Promise { + throw new Error("listComments not wired (missing CommentsMixin)"); + } + deleteComment(_commentId: string): Promise { + throw new Error("deleteComment not wired (missing CommentsMixin)"); + } + listPageHistory(_pageId: string, _cursor?: string): Promise { + throw new Error("listPageHistory not wired (missing PagesMixin)"); + } + + /** Application base URL (API URL without the /api suffix). */ + get appUrl(): string { + return this.apiUrl.replace(/\/api\/?$/, ""); + } + + + async login() { + // Reuse an in-flight login if one is already running so concurrent callers + // share a single token fetch instead of each issuing their own. + if (!this.loginPromise) { + // Token variant: re-fetch a BARE JWT via getToken() (there are no + // credentials to log in with — on a 401/403 the interceptor below calls + // login() again, which re-invokes getToken()). Credentials variant: + // performLogin against /auth/login exactly as before. + const fetchToken = this.getTokenFn + ? this.getTokenFn() + : performLogin(this.apiUrl, this.email!, this.password!); + this.loginPromise = fetchToken + .then((token) => { + // Guard against an empty/invalid token (e.g. a getToken provider that + // resolves to "" or null): without this an empty token would set a + // literal "Authorization: Bearer null"/"Bearer " header and every + // request would 401 with a confusing error. Fail loudly instead. + if (typeof token !== "string" || token.length === 0) { + throw new Error("getToken returned an empty token"); + } + this.token = token; + // Identity (re)established: drop any collab token minted under a + // previous identity so the #435 cache can never outlive it. + this.collabTokenCache = null; + this.client.defaults.headers.common["Authorization"] = + `Bearer ${token}`; + }) + .finally(() => { + this.loginPromise = null; + }); + } + return this.loginPromise; + } + + + async ensureAuthenticated() { + if (!this.token) { + await this.login(); + } + } + + /** + * Fetch a collaboration token, transparently re-authenticating once on a + * 401/403. getCollabToken() uses bare axios internally, so it is NOT covered + * by this.client's response interceptor; this helper replicates that + * behaviour for collab-token requests: ensure a token, try once, and on an + * expired-token auth error perform a fresh login and retry exactly once. + * + * Collab-token cache (issue #435): both sources — the getCollabToken provider + * (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) — mint + * a FRESH token per call, whose string therefore changes every op. Since the + * live CollabSession registry keys on the token string (#400/#431 invariant 4), + * that churned the key and defeated session reuse. So we cache the last minted + * token per-client for readCollabTokenTtlMs() and hand it back for a burst of + * mutations, keeping the session key stable. `forceRefresh` bypasses the cache + * (the 401/403 reauth retry uses it, so the retry cannot be handed the same + * stale token that just failed — otherwise reauth would be a no-op). TTL 0 + * disables the cache: exact fetch-per-call legacy behaviour. + */ + protected async getCollabTokenWithReauth( + forceRefresh = false, + ): Promise { + const ttl = readCollabTokenTtlMs(); + // Serve the cached collab token while it is still fresh (identity isolation + // is preserved: the cache is a per-instance field on a client built per + // user/per chat request, and it is cleared on every identity change). + if ( + !forceRefresh && + ttl > 0 && + this.collabTokenCache && + Date.now() - this.collabTokenCache.mintedAt < ttl + ) { + return this.collabTokenCache.token; + } + + // Collab-token PROVIDER path: when a getCollabToken provider was supplied + // (the internal agent's provenance collab token), use it instead of the + // REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the + // signed token expired between content mutations in a long agent turn). + if (this.getCollabTokenFn) { + try { + const token = await this.getCollabTokenFn(); + if (typeof token !== "string" || token.length === 0) { + throw new Error("getCollabToken returned an empty token"); + } + return this.rememberCollabToken(token, ttl); + } catch (e) { + // On an auth error retry EXACTLY once, forcing a refresh so the retry + // re-invokes the provider (bypassing the cache) for a genuinely fresh + // token. `!forceRefresh` bounds it to a single retry (no loop). + if (this.isCollabAuthError(e) && !forceRefresh) { + return this.getCollabTokenWithReauth(true); + } + throw e; + } + } + + await this.ensureAuthenticated(); + try { + const token = await getCollabToken(this.apiUrl, this.token!); + return this.rememberCollabToken(token, ttl); + } catch (e) { + // getCollabToken wraps the AxiosError in a plain Error but attaches the + // HTTP status as `.status`, so isCollabAuthError detects an auth failure + // via either the raw AxiosError shape OR the attached status. + if (this.isCollabAuthError(e) && !forceRefresh) { + // Fresh login (which clears this.token AND the collab-token cache), then + // retry exactly once with the cache bypassed via forceRefresh. + await this.login(); + return this.getCollabTokenWithReauth(true); + } + throw e; + } + } + + /** + * Store a freshly minted collab token in the per-client cache (issue #435) and + * return it unchanged. No-op write when the cache is disabled (ttl<=0) or the + * token is empty, so a disabled cache is exact fetch-per-call legacy behaviour + * and a bad token is never cached. + */ + protected rememberCollabToken(token: string, ttl: number): string { + if (ttl > 0 && typeof token === "string" && token.length > 0) { + this.collabTokenCache = { token, mintedAt: Date.now() }; + } + return token; + } + + /** + * True when an error carries a 401/403 — either as a raw AxiosError + * (`error.response.status`) or as the plain-Error `.status` that + * lib/auth-utils.getCollabToken attaches after wrapping the AxiosError. + */ + protected isCollabAuthError(e: unknown): boolean { + const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined; + const attachedStatus = (e as any)?.status; + return ( + axiosStatus === 401 || + axiosStatus === 403 || + attachedStatus === 401 || + attachedStatus === 403 + ); + } + + /** + * Connect to the collaboration websocket, read the live doc, apply + * `transform`, write the result, and wait for the server to persist it — + * WITHOUT acquiring the per-page lock. + * + * This mirrors collaboration.mutatePageContent EXCEPT that it does not call + * withPageLock. It exists solely so replaceImage can hold ONE withPageLock + * across its scan -> upload -> write sequence: the per-page mutex is NOT + * reentrant, so calling the normal (self-locking) mutatePageContent inside an + * outer withPageLock for the same pageId would deadlock. The caller MUST hold + * the page lock for the whole operation; this helper assumes that invariant. + * + * `transform` receives the live ProseMirror doc and returns the NEW full doc + * to write, or `null` to abort with no write. Errors thrown by `transform` + * propagate to the caller. + * + * Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so + * every content mutator (including replaceImage) can return a verifiable + * change report. The report is computed AFTER the atomic read->write and + * never throws. + */ + protected async mutateLiveContentUnlocked( + pageId: string, + collabToken: string, + transform: (liveDoc: any) => any | null, + ): Promise { + // Reuse a live CollabSession for the page (issue #400) instead of opening a + // fresh provider per op. acquireCollabSession does NOT take the per-page + // lock — the caller (replaceImage) already holds ONE withPageLock across its + // scan -> upload -> write sequence, and the mutex is not reentrant, so + // taking it here would deadlock. The synchronous read->write section and the + // unsyncedChanges/connectionLost ack logic live in CollabSession.mutate, + // preserved verbatim from the old inline machine (incl. the #152 structural + // diff that keeps a live editor's cursor anchored). + const session = await acquireCollabSession(pageId, collabToken, this.apiUrl, { + // Only the actual 25s collab connect timeout emits this — the connect-vs- + // unload signal; the other failure paths must NOT emit it. + onConnectTimeout: () => + this.onMetricFn?.("collab_connect_timeouts_total", 1), + }); + try { + return await session.mutate(transform); + } catch (e) { + // Drop the session on any failure so the next call reconnects fresh. + session.destroy("mutate failed"); + throw e; + } + } + + /** + * Generic pagination handler for Docmost API endpoints + */ + async paginateAll( + endpoint: string, + basePayload: Record = {}, + limit: number = 100, + ): Promise { + await this.ensureAuthenticated(); + + const clampedLimit = Math.max(1, Math.min(100, limit)); + + // Hard ceiling on the number of pages to fetch: guards against a server + // that returns a perpetually-true hasNextPage (which would otherwise loop + // forever and accumulate duplicates). + const MAX_PAGES = 50; + + let cursor: string | undefined; + let allItems: T[] = []; + let truncated = false; + + for (let page = 0; page < MAX_PAGES; page++) { + const payload: Record = { + ...basePayload, + limit: clampedLimit, + }; + if (cursor) payload.cursor = cursor; + + const response = await this.client.post(endpoint, payload); + + const data = response.data; + const items = data.data?.items || data.items || []; + const meta = data.data?.meta || data.meta; + + allItems = allItems.concat(items); + + // Advance strictly via the server-issued cursor. A missing nextCursor (or + // hasNextPage false) means we reached the end. A cursor identical to the + // one we just sent means the server did not understand our pagination + // param — stop instead of re-fetching page one forever and duplicating. + const next = meta?.hasNextPage ? meta?.nextCursor : null; + if (!next || next === cursor) { + // If the server still reports more pages but stopped issuing a usable + // cursor at the ceiling, flag the result as truncated below. + if (page === MAX_PAGES - 1 && meta?.hasNextPage) truncated = true; + break; + } + cursor = next; + + // Reaching the ceiling with more pages still available means the result + // set is truncated. + if (page === MAX_PAGES - 1) truncated = true; + } + + // If the loop stopped because it hit the MAX_PAGES ceiling while the server + // still reported more results, the result set is truncated — warn so the + // caller is not silently handed an incomplete list. + if (truncated) { + console.warn( + `paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`, + ); + } + + return allItems; + } + + + /** Raw page info including the ProseMirror JSON content and slugId. */ + async getPageRaw(pageId: string) { + await this.ensureAuthenticated(); + const response = await this.client.post("/pages/info", { pageId }); + return response.data?.data ?? response.data; + } + + /** + * Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`), + * so every collaboration document the MCP opens is named `page.` — the + * SAME name the web editor always uses (`page.${page.id}`). + * + * The agent commonly passes a 10-char public slugId (from URLs/listings) as + * the pageId. The web editor opens the collab doc by UUID, but the MCP used to + * pass that slugId straight into the collab doc name (`page.`). For one + * DB row that produced TWO independent Yjs documents whose debounced stores + * clobbered each other — the agent's edit was silently lost (#260). + * + * A UUID input short-circuits with no network round-trip. A slugId is resolved + * once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so + * repeated edits on the same page add no extra request. + */ + protected async resolvePageId(pageId: string): Promise { + if (isUuid(pageId)) return pageId; + const cached = this.pageIdCache.get(pageId); + if (cached) return cached; + const data = await this.getPageRaw(pageId); + const uuid = data?.id; + if (typeof uuid !== "string" || !uuid) { + throw new Error( + `Could not resolve a canonical page id for "${pageId}"`, + ); + } + this.pageIdCache.set(pageId, uuid); + return uuid; + } + + + /** + * Page-locked write seam over collaboration.mutatePageContent. Production just + * delegates; it exists as an overridable method so the insertFootnote wrapper + * (transform abort-on-not-found + response shaping) can be unit-tested without + * standing up a live Hocuspocus collab socket. + * + * SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then- + * lock"): every write must lock and key its CollabSession by the UUID, never a + * raw slugId (#260). resolvePageId is cached/idempotent, so a caller that + * already resolved pays no extra round-trip; centralizing it here means a + * caller that reaches this seam with a raw slugId still locks correctly instead + * of silently splitting the mutex key. withPageLock also asserts the key is a + * UUID as a hard backstop. + */ + protected async mutatePage( + pageId: string, + collabToken: string, + apiUrl: string, + transform: (doc: any) => any, + ): Promise<{ doc?: any; verify?: any }> { + const pageUuid = await this.resolvePageId(pageId); + return mutatePageContent(pageUuid, collabToken, apiUrl, transform); + } + + /** + * Full-document write seam over collaboration.replacePageContent. Production + * just delegates; it exists as an overridable method so the full-doc write + * tools (updatePageJson, copyPageContent) can have their footnote- + * canonicalization binding unit-tested without a live Hocuspocus collab socket. + * + * SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then- + * lock") for the same reason as mutatePage above — the lock/CollabSession key + * is guaranteed canonical here, not left to the caller's discipline. + */ + protected async replacePage( + pageId: string, + doc: any, + collabToken: string, + apiUrl: string, + ): Promise<{ doc?: any; verify?: any }> { + const pageUuid = await this.resolvePageId(pageId); + return replacePageContent(pageUuid, doc, collabToken, apiUrl); + } + + /** + * Export a page to a single self-contained Docmost-flavoured markdown file: + * meta block + body (with inline comment anchors + diagrams) + comment + * threads. Lossless round-trip target; see importPageMarkdown for the inverse. + */ +} diff --git a/packages/mcp/src/client/doc-validate.ts b/packages/mcp/src/client/doc-validate.ts new file mode 100644 index 00000000..9d40f57b --- /dev/null +++ b/packages/mcp/src/client/doc-validate.ts @@ -0,0 +1,248 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { + replaceNodeById, + replaceNodeByIdWithMany, + reassignCollidingBlockIds, + deleteNodeById, + assertUnambiguousMatch, + insertNodeRelative, + insertNodesRelative, + blockPlainText, + buildOutline, + getNodeByRef, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + findInvalidNode, +} from "@docmost/prosemirror-markdown"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; + +// Public method surface of DocValidateMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements IDocValidateMixin` fails to compile on drift. +export interface IDocValidateMixin { +} + +export function DocValidateMixin>(Base: TBase): GConstructor & TBase { + abstract class DocValidateMixin extends Base implements IDocValidateMixin { + /** + * Validate a URL string against a scheme allowlist for a given context. + * + * The markdown link path enforces safe schemes via TipTap, but the raw + * JSON path (updatePageJson) bypasses that — so this is the sanitization + * choke point for ProseMirror JSON written directly by the caller. + * + * - "link": reject javascript:, vbscript:, data: (any scheme that can + * execute or smuggle script when the href is clicked). + * - "src": allow only http(s):, mailto:, /api/files paths, or a + * scheme-less relative/absolute path; reject + * javascript:/vbscript:/data:/file:. + */ + protected isSafeUrl(url: unknown, context: "link" | "src"): boolean { + if (typeof url !== "string") return false; + const trimmed = url.trim(); + if (trimmed === "") return true; // empty href/src is harmless + + // Extract a leading "scheme:" if present. A scheme must start with a + // letter and contain only letters/digits/+/-/. before the colon. Strip + // whitespace and ASCII control chars first so a tab/newline embedded in + // the scheme cannot smuggle a dangerous scheme past the check. + const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, ""); + const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + + const dangerous = new Set(["javascript", "vbscript", "data", "file"]); + + if (context === "link") { + if (scheme === null) return true; // relative/anchor link is fine + // For links, data: is also blocked (can carry script payloads). + return !new Set(["javascript", "vbscript", "data"]).has(scheme); + } + + // context === "src" + if (scheme === null) return true; // relative/absolute path (incl. /api/files) + if (dangerous.has(scheme)) return false; + return scheme === "http" || scheme === "https" || scheme === "mailto"; + } + + /** + * Recursively walk a ProseMirror doc and reject any unsafe URL on a link + * mark href or on a media node's src/url. Media nodes covered: image, + * attachment, video, plus embed (rendered as an iframe), youtube, drawio + * and excalidraw — all of which carry a user-controlled URL that Docmost + * renders. Throws a clear error on the first violation. A max-depth guard + * turns an over-deep document into a clean error instead of a RangeError + * stack overflow. + */ + protected validateDocUrls(node: any, depth: number = 0): void { + const MAX_DEPTH = 200; + if (depth > MAX_DEPTH) { + throw new Error( + `document nesting exceeds the maximum depth of ${MAX_DEPTH}`, + ); + } + if (!node || typeof node !== "object") return; + + // Link marks on text nodes: validate the href. + if (Array.isArray(node.marks)) { + for (const mark of node.marks) { + if (mark && mark.type === "link" && mark.attrs) { + if (!this.isSafeUrl(mark.attrs.href, "link")) { + throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`); + } + } + } + } + + // Media nodes: validate src/url against the stricter src allowlist. + // embed renders as an iframe (highest risk); youtube/drawio/excalidraw + // likewise carry a user-controlled URL Docmost renders, so they get the + // same scheme check as image/attachment/video. + if ( + node.type === "image" || + node.type === "attachment" || + node.type === "video" || + node.type === "embed" || + node.type === "youtube" || + node.type === "drawio" || + node.type === "excalidraw" || + node.type === "audio" || + node.type === "pdf" + ) { + const attrs = node.attrs || {}; + for (const key of ["src", "url"]) { + if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) { + throw new Error( + `unsafe ${node.type} ${key} rejected: "${attrs[key]}"`, + ); + } + } + } + + if (Array.isArray(node.content)) { + for (const child of node.content) { + this.validateDocUrls(child, depth + 1); + } + } + } + + /** + * Recursively validate the STRUCTURE of a ProseMirror node (reuses the + * recursion shape of validateDocUrls). Every node must be an object with a + * string `type`; when present, `content` must be an array, `marks` must be + * an array of objects each with a string `type`, and a text node's `text` + * must be a string. Throws a clear "invalid ProseMirror document" error on + * the first violation. A max-depth guard turns an over-deep document into a + * clean error instead of a RangeError stack overflow. + */ + protected validateDocStructure(node: any, depth: number = 0): void { + const MAX_DEPTH = 200; + if (depth > MAX_DEPTH) { + throw new Error( + `invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`, + ); + } + if (!node || typeof node !== "object" || typeof node.type !== "string") { + throw new Error( + "invalid ProseMirror document: every node must be an object with a string `type`", + ); + } + if ( + "text" in node && + node.type === "text" && + typeof node.text !== "string" + ) { + throw new Error( + "invalid ProseMirror document: a text node must have a string `text`", + ); + } + if (node.marks !== undefined) { + if (!Array.isArray(node.marks)) { + throw new Error( + "invalid ProseMirror document: `marks` must be an array", + ); + } + for (const mark of node.marks) { + if ( + !mark || + typeof mark !== "object" || + typeof mark.type !== "string" + ) { + throw new Error( + "invalid ProseMirror document: every mark must be an object with a string `type`", + ); + } + } + } + if (node.content !== undefined) { + if (!Array.isArray(node.content)) { + throw new Error( + "invalid ProseMirror document: `content` must be an array when present", + ); + } + for (const child of node.content) { + this.validateDocStructure(child, depth + 1); + } + } + } + + /** + * Pre-write SHAPE gate (#409). Walk the WHOLE node tree with the shared + * `findInvalidNode` and throw a rich, path-anchored error the instant a nested + * node has an absent/unknown `type` (or an unknown mark) — the exact shape that + * otherwise surfaces DEEP in the Yjs encode as the cryptic + * `Unknown node type: undefined`, but only AFTER a collab session was opened + * and a page lock taken. Calling this BEFORE `getCollabTokenWithReauth` / + * `mutatePageContent` fails fast: no collab connection, no lock, deterministic + * message. `op` names the tool for the message prefix (e.g. "patchNode"). + * + * `findInvalidNode` derives its "known type" set from the very same + * `docmostExtensions` the encode path uses, so a node this gate accepts is one + * the encoder will accept too. + */ + protected assertValidNodeShape(op: string, node: any): void { + const bad = findInvalidNode(node); + if (bad) { + throw new Error(`${op}: invalid node — ${bad.summary}`); + } + } + + /** + * Replace page content with a raw ProseMirror JSON document (lossless) and/or + * update its title. Both `doc` and `title` are optional, but at least one must + * be supplied: + * - `doc` provided -> validate + full-overwrite the body (and update the + * title too when `title` is also given). + * - `doc` omitted, `title` given -> title-only update; the body is NOT + * touched/resent (no collab write happens). + * - neither given -> throws (nothing to update). + */ + } + return DocValidateMixin; +} diff --git a/packages/mcp/src/client/drawio.ts b/packages/mcp/src/client/drawio.ts new file mode 100644 index 00000000..c3c4530f --- /dev/null +++ b/packages/mcp/src/client/drawio.ts @@ -0,0 +1,707 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { parseCells as parseDrawioCells } from "../lib/drawio-xml.js"; +import { + replaceNodeById, + replaceNodeByIdWithMany, + reassignCollidingBlockIds, + deleteNodeById, + assertUnambiguousMatch, + insertNodeRelative, + insertNodesRelative, + blockPlainText, + buildOutline, + getNodeByRef, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + findInvalidNode, +} from "@docmost/prosemirror-markdown"; +import { + prepareModel, + decodeDrawioSvg, + buildDrawioSvg, + mxHash, + normalizeXml, + countUserCells, +} from "../lib/drawio-xml.js"; +import { renderDiagramShapes } from "../lib/drawio-preview.js"; +import { applyElkLayout } from "../lib/drawio-layout.js"; +import { + buildFromGraph, + type Graph, + type LayoutMode as GraphLayoutMode, +} from "../lib/drawio-graph.js"; +import { applyCellOps, type CellOp } from "../lib/drawio-cell-ops.js"; +import { mermaidToGraph } from "../lib/drawio-mermaid.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; + +// Public method surface of DrawioMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements IDrawioMixin` fails to compile on drift. +export interface IDrawioMixin { + drawioGet(pageId: string, node: string, format?: "xml" | "svg"): Promise<{ pageId: string; nodeId: string; format: "xml" | "svg"; content: string; meta: { attachmentId: string | null; title: string | null; width: number | null; height: number | null; cellCount: number; hash: string; }; }>; + drawioCreate(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, xml: string, title?: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>; + drawioUpdate(pageId: string, node: string, xml: string, baseHash: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>; + drawioEditCells(pageId: string, node: string, operations: CellOp[], baseHash: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>; + drawioFromGraph(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, graph: Graph, direction?: "LR" | "RL" | "TB" | "BT", preset?: string, layout?: GraphLayoutMode, node?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>; + drawioFromMermaid(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, mermaid: string, preset?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>; +} + +export function DrawioMixin>(Base: TBase): GConstructor & TBase { + abstract class DrawioMixin extends Base implements IDrawioMixin { + /** + * Resolve a drawio node on a page by `attrs.id` or `#` and return the + * node plus its ref. Throws a clear error if the ref does not resolve to a + * drawio node. + */ + protected async resolveDrawioNode( + pageId: string, + node: string, + ): Promise<{ node: any; ref: string }> { + const data = await this.getPageRaw(pageId); + const hit = getNodeByRef( + data.content ?? { type: "doc", content: [] }, + node, + ); + if (!hit) { + throw new Error( + `drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#" from getOutline)`, + ); + } + if (hit.type !== "drawio") { + throw new Error( + `drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`, + ); + } + return { node: hit.node, ref: node }; + } + + /** + * Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`. + * Runs the decode chain (base64/entity content= → drawio file → nested XML or + * pako-inflated compressed ). The returned `hash` is the + * optimistic-lock key for drawioUpdate. + */ + async drawioGet( + pageId: string, + node: string, + format: "xml" | "svg" = "xml", + ): Promise<{ + pageId: string; + nodeId: string; + format: "xml" | "svg"; + content: string; + meta: { + attachmentId: string | null; + title: string | null; + width: number | null; + height: number | null; + cellCount: number; + hash: string; + }; + }> { + await this.ensureAuthenticated(); + const { node: drawio } = await this.resolveDrawioNode(pageId, node); + const attrs = drawio.attrs || {}; + const src = attrs.src; + if (!src) { + throw new Error( + `drawio: node "${node}" on page ${pageId} has no src to read`, + ); + } + const svg = await this.fetchAttachmentText(src); + const modelXml = decodeDrawioSvg(svg); + const meta = { + attachmentId: attrs.attachmentId ?? null, + title: attrs.title ?? null, + width: attrs.width != null ? Number(attrs.width) : null, + height: attrs.height != null ? Number(attrs.height) : null, + cellCount: countUserCells(modelXml), + hash: mxHash(modelXml), + }; + return { + pageId, + nodeId: attrs.id ?? node, + format, + content: format === "svg" ? svg : normalizeXml(modelXml), + meta, + }; + } + + /** + * Create a drawio diagram from mxGraph XML: lint → schematic SVG preview + * (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the + * attachment → insert a `drawio` node before/after an anchor or appended. + * `xml` is a bare `` or a list of `` (the server wraps + * it and adds the id=0/id=1 sentinels). + */ + async drawioCreate( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + xml: string, + title?: string, + layout?: "elk", + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + if ( + !where || + (where.position !== "before" && + where.position !== "after" && + where.position !== "append") + ) { + throw new Error( + 'drawioCreate: `where.position` must be one of "before", "after", "append"', + ); + } + if (where.position === "before" || where.position === "after") { + const hasId = + typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0; + const hasText = + typeof where.anchorText === "string" && where.anchorText.length > 0; + if (hasId === hasText) { + throw new Error( + `drawioCreate: position "${where.position}" requires exactly one of anchorNodeId or anchorText`, + ); + } + } + + // Optional server-side ELK auto-layout: the model declares structure with + // rough coords, ELK computes the pixels (best-effort — returns the input + // unchanged on any layout failure). + const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml; + // Pre-write pipeline (throws a structured DrawioLintError on any violation). + const prepared = prepareModel(laidOutXml); + const inner = renderDiagramShapes(prepared.cells, prepared.bbox); + const diagramTitle = title || "Page-1"; + const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); + + const att = await this.uploadAttachmentBuffer( + pageId, + Buffer.from(svg, "utf-8"), + "diagram.drawio.svg", + "image/svg+xml", + ); + + // NOTE: no `id` attribute is set here. The vendored `drawio` node schema + // (diagramAttributes) declares no `id`, so any block id would be silently + // dropped by PMNode.fromJSON on save and the returned handle would fail to + // resolve. The addressable handle is the node's "#" (like image/table + // nodes), computed after the insert below. + const drawioNode: any = { + type: "drawio", + attrs: { + src: `/api/files/${att.id}/${att.fileName}`, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + align: "center", + }, + }; + if (title) drawioNode.attrs.title = title; + // Reuse the existing URL trust boundary (rejects unsafe src schemes). + this.validateDocUrls(drawioNode); + + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + + let inserted = false; + let insertedIndex = -1; + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + inserted = false; + insertedIndex = -1; + const { doc: nd, inserted: ins } = insertNodeRelative( + liveDoc, + drawioNode, + where, + ); + inserted = ins; + if (!inserted) return null; // anchor not found -> skip the write + // Locate the freshly-inserted node to derive its "#" handle. The + // just-uploaded attachmentId is unique, so it identifies our node. + if (Array.isArray(nd.content)) { + insertedIndex = nd.content.findIndex( + (b: any) => + b && + b.type === "drawio" && + b.attrs && + b.attrs.attachmentId === att.id, + ); + } + return nd; + }, + ); + + if (!inserted) { + const anchorDesc = where.anchorNodeId + ? `anchorNodeId "${where.anchorNodeId}"` + : `anchorText "${where.anchorText}"`; + throw new Error( + `drawioCreate: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`, + ); + } + + if (insertedIndex < 0) { + // The node was inserted nested (e.g. inside a callout/table cell via an + // anchor), where "#" — which addresses only top-level blocks — + // cannot reference it. drawio nodes carry no persisted id, so there is no + // stable handle for a nested diagram. + throw new Error( + `drawioCreate: the diagram was inserted on page ${pageId} but not as a ` + + `top-level block, so it has no addressable "#" handle. Anchor ` + + `on a top-level block (or append) so the diagram can be re-read.`, + ); + } + + // The returned handle is POSITIONAL ("#"): valid for the immediate + // create -> get/update flow, but re-resolve via getOutline if the document + // structure changes (blocks added/removed before it shift the index). + const nodeId = `#${insertedIndex}`; + + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: prepared.warnings, + verify: mutation.verify, + }; + } + + /** + * Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is + * compared against the hash of the diagram's CURRENT XML (from drawioGet); + * any mismatch means a human or another agent edited the diagram after the + * read, so the write is refused with a conflict error. On success the new + * `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is + * avoided — some Docmost versions corrupt an attachment on overwrite, exactly + * as replaceImage documents) and the node is repointed with new dimensions. + */ + async drawioUpdate( + pageId: string, + node: string, + xml: string, + baseHash: string, + layout?: "elk", + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + if (typeof baseHash !== "string" || baseHash.length === 0) { + throw new Error( + "drawioUpdate: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", + ); + } + + // Resolve the node and read the CURRENT diagram to enforce the optimistic + // lock before doing any write or upload. + const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); + const oldAttrs = drawio.attrs || {}; + const oldSrc = oldAttrs.src; + // The returned handle is the caller-supplied reference. drawio nodes carry + // no persisted id, so `ref` (an "#" or a rare legacy attrs.id) is the + // honest identifier to hand back. + const nodeId = oldAttrs.id ?? ref; + if (!oldSrc) { + throw new Error( + `drawioUpdate: node "${node}" on page ${pageId} has no src to compare against`, + ); + } + const currentSvg = await this.fetchAttachmentText(oldSrc); + const currentHash = mxHash(decodeDrawioSvg(currentSvg)); + if (currentHash !== baseHash) { + throw new Error( + `drawioUpdate: conflict — the diagram changed since it was read ` + + `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, + ); + } + + // Optional server-side ELK auto-layout (best-effort; see drawioCreate). + const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml; + // Pipeline for the new content (throws a structured DrawioLintError). + const prepared = prepareModel(laidOutXml); + const inner = renderDiagramShapes(prepared.cells, prepared.bbox); + const diagramTitle = oldAttrs.title || "Page-1"; + const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); + + const att = await this.uploadAttachmentBuffer( + pageId, + Buffer.from(svg, "utf-8"), + "diagram.drawio.svg", + "image/svg+xml", + ); + const newSrc = `/api/files/${att.id}/${att.fileName}`; + + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + + let repointed = 0; + const repoint = (n: any) => { + n.attrs = { + ...n.attrs, + src: newSrc, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + }; + repointed++; + }; + + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + repointed = 0; + const doc = + liveDoc && liveDoc.type === "doc" + ? liveDoc + : { type: "doc", content: [] }; + if (!Array.isArray(doc.content)) doc.content = []; + // Repoint ONLY the resolved node — never every node that happens to + // share this attachmentId (a copied diagram is two nodes with one + // attachmentId; keying on it would clobber both). Re-resolve the same + // handle against the live doc and walk to its exact position. + const hit = getNodeByRef(doc, ref); + if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip + let target: any = doc; + for (const idx of hit.path) { + if (!target || !Array.isArray(target.content)) { + target = null; + break; + } + target = target.content[idx]; + } + if (!target || target.type !== "drawio") return null; + repoint(target); + if (repointed === 0) return null; // node vanished concurrently -> skip + return doc; + }, + ); + + if (repointed === 0) { + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: [ + ...prepared.warnings, + "target drawio node was removed concurrently; uploaded attachment is unreferenced", + ], + verify: mutation.verify, + }; + } + + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: prepared.warnings, + verify: mutation.verify, + }; + } + + // --- draw.io high-level semantic tools (issue #425) --- + + /** + * ID-based targeted edits of an existing drawio diagram (add / update / delete + * cells) instead of resending the whole XML. Reads the CURRENT diagram, checks + * the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies + * the operations to the parsed model (a `delete` CASCADES to container children + * and to every edge whose source/target is deleted), then runs the SAME #423 + * pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment -> + * repoint the node). Ids are stable so diffs stay meaningful across edits. + */ + + // --- draw.io high-level semantic tools (issue #425) --- + + /** + * ID-based targeted edits of an existing drawio diagram (add / update / delete + * cells) instead of resending the whole XML. Reads the CURRENT diagram, checks + * the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies + * the operations to the parsed model (a `delete` CASCADES to container children + * and to every edge whose source/target is deleted), then runs the SAME #423 + * pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment -> + * repoint the node). Ids are stable so diffs stay meaningful across edits. + */ + async drawioEditCells( + pageId: string, + node: string, + operations: CellOp[], + baseHash: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + if (typeof baseHash !== "string" || baseHash.length === 0) { + throw new Error( + "drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", + ); + } + if (!Array.isArray(operations) || operations.length === 0) { + throw new Error( + "drawioEditCells: operations must be a non-empty array of { op, ... }", + ); + } + + const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); + const oldAttrs = drawio.attrs || {}; + const oldSrc = oldAttrs.src; + const nodeId = oldAttrs.id ?? ref; + if (!oldSrc) { + throw new Error( + `drawioEditCells: node "${node}" on page ${pageId} has no src to edit`, + ); + } + const currentSvg = await this.fetchAttachmentText(oldSrc); + const currentModel = decodeDrawioSvg(currentSvg); + const currentHash = mxHash(currentModel); + if (currentHash !== baseHash) { + throw new Error( + `drawioEditCells: conflict — the diagram changed since it was read ` + + `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, + ); + } + + // Apply the operations to the parsed model, then run the standard pipeline. + const editedModel = applyCellOps(currentModel, operations); + const prepared = prepareModel(editedModel); + const inner = renderDiagramShapes(prepared.cells, prepared.bbox); + const diagramTitle = oldAttrs.title || "Page-1"; + const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); + + const att = await this.uploadAttachmentBuffer( + pageId, + Buffer.from(svg, "utf-8"), + "diagram.drawio.svg", + "image/svg+xml", + ); + const newSrc = `/api/files/${att.id}/${att.fileName}`; + + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + + let repointed = 0; + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + repointed = 0; + const doc = + liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] }; + if (!Array.isArray(doc.content)) doc.content = []; + const hit = getNodeByRef(doc, ref); + if (!hit || hit.type !== "drawio") return null; + let target: any = doc; + for (const idx of hit.path) { + if (!target || !Array.isArray(target.content)) { + target = null; + break; + } + target = target.content[idx]; + } + if (!target || target.type !== "drawio") return null; + target.attrs = { + ...target.attrs, + src: newSrc, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + }; + repointed++; + return doc; + }, + ); + + if (repointed === 0) { + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: [ + ...prepared.warnings, + "target drawio node was removed concurrently; uploaded attachment is unreferenced", + ], + verify: mutation.verify, + }; + } + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: prepared.warnings, + verify: mutation.verify, + }; + } + + /** + * The main high-level tool: build a diagram from a SEMANTIC graph (nodes with + * a `kind`/`icon`, groups, edges) — the model never supplies coordinates or + * style strings. The server resolves icons via the shape catalog (#424), + * assigns palette colors from the preset, runs ELK layered layout (honouring + * `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups), + * and assembles linter-clean XML, then inserts it through the SAME create + * pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a + * target `node` is given (it preserves that diagram's existing coordinates and + * places only new cells); on a fresh insert it behaves like "full". + */ + async drawioFromGraph( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + graph: Graph, + direction?: "LR" | "RL" | "TB" | "BT", + preset?: string, + layout?: GraphLayoutMode, + node?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + iconsResolved: number; + iconsMissing: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + // Direction/preset supplied as separate params override the graph fields so + // both the flat tool schema and an inline graph can set them. + const merged: Graph = { + ...graph, + direction: direction ?? graph.direction, + preset: preset ?? graph.preset, + }; + const mode: GraphLayoutMode = layout ?? "full"; + + // Incremental into an EXISTING node: read its coords so ELK preserves them, + // and keep the full existing model so incremental MERGES (never drops) any + // cell the new graph doesn't re-list. + let existingCoords: Map | undefined; + let existingModelXml: string | undefined; + let editExisting = false; + let baseHash: string | undefined; + if (node && (mode === "incremental" || mode === "none")) { + const { node: drawio } = await this.resolveDrawioNode(pageId, node); + const src = (drawio.attrs || {}).src; + if (src) { + const svg = await this.fetchAttachmentText(src); + const model = decodeDrawioSvg(svg); + baseHash = mxHash(model); + existingModelXml = model; + existingCoords = new Map(); + for (const c of parseDrawioCells(model)) { + if (c.vertex && c.geometry.x != null && c.geometry.y != null) { + existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y }); + } + } + editExisting = true; + } + } + + const built = await buildFromGraph( + merged, + mode, + existingCoords, + existingModelXml, + ); + + if (editExisting && node && baseHash) { + // Re-target the existing diagram: replace it with the assembled model. + const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash); + return { + ...res, + iconsResolved: built.iconsResolved, + iconsMissing: built.iconsMissing, + }; + } + + const res = await this.drawioCreate(pageId, where, built.modelXml); + return { + ...res, + iconsResolved: built.iconsResolved, + iconsMissing: built.iconsMissing, + }; + } + + /** + * Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE + * parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the + * drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most + * common wiki case); other diagram types throw a clear error so the model can + * fall back to drawioFromGraph. + */ + async drawioFromMermaid( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + mermaid: string, + preset?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + iconsResolved: number; + iconsMissing: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + const graph = mermaidToGraph(mermaid); + if (preset) graph.preset = preset; + return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset); + } + + // --- Page history / diff / transform --- + + /** + * List the saved versions (history snapshots) of a page, newest first. + * Docmost auto-snapshots on every save. Returns one cursor-paginated page of + * results: `{ items, nextCursor }`. The history record's id field is `id`. + */ + } + return DrawioMixin; +} diff --git a/packages/mcp/src/client/errors.ts b/packages/mcp/src/client/errors.ts new file mode 100644 index 00000000..19c3d4aa --- /dev/null +++ b/packages/mcp/src/client/errors.ts @@ -0,0 +1,165 @@ +// Central REST error diagnostics (issues #437 + #450). SINGLE place that maps an +// axios error to the model-facing message. Extracted verbatim from client.ts; +// the constructor's response interceptor (see client/context.ts) routes every +// REST call through formatDocmostAxiosError so the whole surface is uniform. +import axios from "axios"; + +// --- Issue #437: central error diagnostics ------------------------------- +// The agent only ever sees the thrown exception's `error.message`, so a failed +// tool must return an ACTIONABLE message (method, path, status, and the +// server's own validation text) instead of the opaque "Request failed with +// status code 400". These helpers + the response interceptor in the +// constructor are the single authoritative place that text is composed. + +// Overall cap on the composed diagnostic message so the model context stays +// compact and a (whitelisted) server string can never blow up the text. +const ERROR_MESSAGE_CAP = 300; +// Only attempt to JSON.parse an arraybuffer body under this size: a larger +// binary body is never a JSON error envelope, so parsing it just wastes memory +// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch +// carries the JSON error envelope as raw bytes here). +const ERROR_BUFFER_PARSE_CAP = 4096; + +// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant- +// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the +// canonical shape/length is enforced, not the version/variant nibble. +const FULL_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Throw an actionable error BEFORE any network call when `value` is not a full + * canonical UUID. Absorbs #436: a truncated/short comment id used to reach the + * server and bounce back as an opaque 400/404 the agent could not self-correct; + * failing fast here names the exact fix. + */ +export function assertFullUuid( + tool: string, + param: string, + value: string, +): void { + if (typeof value !== "string" || !FULL_UUID_RE.test(value)) { + throw new Error( + `${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` + + `019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` + + `verbatim from listComments / createComment output.`, + ); + } +} + +// Keep ONLY the pathname of a request (no host, no query string, no fragment) +// so the message never leaks a host or query params. Resolves a relative +// config.url against config.baseURL, then discards everything but the path. +function requestPath(config: any): string { + const rawUrl = typeof config?.url === "string" ? config.url : ""; + const base = + typeof config?.baseURL === "string" ? config.baseURL : undefined; + try { + // A dummy base makes an absolute config.url parse too; its host is dropped. + return new URL(rawUrl, base ?? "http://localhost").pathname; + } catch { + // Malformed url: still strip any query/fragment manually. + return rawUrl.split(/[?#]/)[0] || rawUrl; + } +} + +/** + * Compose the server-facing message from `error.response.data`, using ONLY the + * whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the + * raw response body, headers (Authorization!) and config are NEVER read here — + * a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in + * favour of the statusText. + */ +function extractServerMessage(data: any, statusText: string): string { + // class-validator envelope: { message: string | string[], error?: string }. + if ( + data && + typeof data === "object" && + !Buffer.isBuffer(data) && + !(data instanceof ArrayBuffer) + ) { + const msg = (data as any).message; + if (Array.isArray(msg)) { + const joined = msg.filter((m) => typeof m === "string").join("; "); + if (joined) return joined; + } else if (typeof msg === "string" && msg) { + return msg; + } + const err = (data as any).error; + if (typeof err === "string" && err) return err; + return statusText; + } + + // Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a + // failed arraybuffer fetch still surfaces the server's validation text. + if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) { + try { + return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText); + } catch { + return statusText; + } + } + return statusText; + } + + // A raw string / HTML body is never surfaced (may echo server internals). + return statusText; +} + +/** + * Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic: + * ` failed ( ): ` + * or, when the request never got a response: + * ` failed: (no response from server)`. + * + * Mutates the SAME error object (never a custom subclass) so the live + * axios.isAxiosError / error.response?.status / config._retry checks around the + * client keep working, and sets `_docmostFormatted` as a double-processing + * guard. A no-op on a non-axios or already-formatted error. + */ +export function formatDocmostAxiosError(error: any): void { + if (!error || error._docmostFormatted) return; + if (!axios.isAxiosError(error)) return; + + const config: any = error.config ?? {}; + const method = + typeof config.method === "string" ? config.method.toUpperCase() : ""; + const methodPath = `${method} ${requestPath(config)}`.trim(); + const response = error.response; + + let message: string; + if (response) { + const statusText = + typeof response.statusText === "string" ? response.statusText : ""; + const serverMessage = extractServerMessage(response.data, statusText); + message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`; + // Full body only to stderr under DEBUG (parity with downloadImage). + if (process.env.DEBUG) { + console.error( + "Docmost request failed; response body:", + JSON.stringify(response.data), + ); + } + } else { + // No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout). + // Use ONLY error.code, never the raw error.message: axios network messages + // embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo + // ENOTFOUND host") and #437's invariant is that the host never reaches the + // model-visible message. code is set for essentially every real no-response + // error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full + // native message still goes to stderr under DEBUG. + const reason = error.code ?? "network error"; + message = `${methodPath} failed: ${reason} (no response from server)`; + if (process.env.DEBUG) { + console.error("Docmost request failed; no response:", error.message); + } + } + + if (message.length > ERROR_MESSAGE_CAP) { + message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…"; + } + + error.message = message; + (error as any)._docmostFormatted = true; +} diff --git a/packages/mcp/src/client/media.ts b/packages/mcp/src/client/media.ts new file mode 100644 index 00000000..e7b77c6a --- /dev/null +++ b/packages/mcp/src/client/media.ts @@ -0,0 +1,730 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import FormData from "form-data"; +import axios, { AxiosInstance } from "axios"; +import { basename, extname } from "path"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { withPageLock, isUuid } from "../lib/page-lock.js"; +import { diffDocs, summarizeChange } from "../lib/diff.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; + +// Supported image types, kept as two lookup tables so both a local file +// extension and a remote Content-Type can be mapped to the same canonical set. +const EXT_TO_MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", +}; +const MIME_TO_EXT: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", +}; + +// Public method surface of MediaMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements IMediaMixin` fails to compile on drift. +export interface IMediaMixin { + uploadImage(pageId: string, url: string): any; + insertImage(pageId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string; replaceText?: string; afterText?: string; }): any; + replaceImage(pageId: string, oldAttachmentId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string }): any; +} + +export function MediaMixin>(Base: TBase): GConstructor & TBase { + abstract class MediaMixin extends Base implements IMediaMixin { + // --- Image upload / embedding --- + + /** Map a Content-Type string to a supported MIME type, or null if unsupported. */ + protected supportedImageMime(ct: string): string | null { + return MIME_TO_EXT[ct] ? ct : null; + } + + /** + * Download a remote image from a caller-supplied URL and resolve its bytes, + * MIME and a filename. + * + * SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is + * fetched BY THE SERVER, so it must be guarded before and after the request. + * The guards mirror the local-file trust boundary in uploadImage: + * - scheme allowlist (http/https only) — rejects file:, data:, ftp:, etc., + * so the caller cannot use this path to read local files or other schemes; + * - a size cap enforced both via axios maxContentLength/maxBodyLength AND a + * post-download buffer.length re-check (defends against a missing/lying + * Content-Length), so a huge response cannot exhaust memory; + * - a 30s timeout. The timeout matters because replaceImage holds the + * per-page lock across this upload, so a hung download would wedge the + * lock for that page. + * We deliberately do NOT block private IP ranges: the MCP caller is already + * trusted to read arbitrary host files via the filePath path, so the marginal + * trust granted by fetching internal URLs is comparable, and blocking would + * break legitimate internal-image use. + */ + protected async fetchRemoteImage( + url: string, + maxBytes: number, + ): Promise<{ buffer: Buffer; mime: string; fileName: string }> { + // Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes + // (file:, data:, ftp:, ...) before any network request is made. + let parsed: URL; + try { + parsed = new URL(url); + } catch (e: any) { + throw new Error(`Invalid image URL "${url}": ${e.message}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error( + `unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`, + ); + } + + let response; + try { + response = await axios.get(url, { + responseType: "arraybuffer", + timeout: 30000, + maxContentLength: maxBytes, + maxBodyLength: maxBytes, + headers: { Accept: "image/*" }, + }); + } catch (error) { + // Keep the thrown message free of the raw response body (it may echo + // server internals); surface only status/statusText. The full body is + // logged under DEBUG for diagnostics. + if (axios.isAxiosError(error)) { + if (process.env.DEBUG) { + console.error( + "Image download failed; response body:", + JSON.stringify(error.response?.data), + ); + } + throw new Error( + `Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim(), + ); + } + throw error; + } + + // axios returns an ArrayBuffer for responseType: "arraybuffer". + const buffer = Buffer.from(response.data); + // Re-check the size: maxContentLength relies on Content-Length, which may be + // absent or lie, so guard against the actual byte count too. + if (buffer.length === 0) { + throw new Error(`Empty image response from "${url}"`); + } + if (buffer.length > maxBytes) { + throw new Error( + `Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`, + ); + } + + // Resolve MIME: prefer the response Content-Type (strip any "; charset=..." + // parameter, lowercase, trim) mapped through the supported set; if the + // header is generic/missing/unsupported, fall back to the URL path + // extension via the existing extension->MIME logic. + const rawCt = response.headers?.["content-type"]; + let mime: string | null = null; + if (typeof rawCt === "string" && rawCt.length > 0) { + const ct = rawCt.split(";")[0].trim().toLowerCase(); + mime = this.supportedImageMime(ct); + } + if (!mime) { + // Fall back to the URL path extension. Use the pathname so the query + // string never contaminates the extension lookup. + const ext = extname(parsed.pathname).toLowerCase(); + mime = EXT_TO_MIME[ext] ?? null; + } + if (!mime) { + throw new Error( + `cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`, + ); + } + + // Build a filename from the URL path basename (ignore the query string), + // defaulting to "image" when empty, and ensure it ends with the canonical + // extension for the resolved MIME (append it when missing/mismatched). + const canonicalExt = MIME_TO_EXT[mime]; + let fileName = basename(parsed.pathname) || "image"; + if (extname(fileName).toLowerCase() !== canonicalExt) { + fileName += canonicalExt; + } + + return { buffer, mime, fileName }; + } + + /** Build a Docmost ProseMirror image node from an uploaded attachment. */ + protected buildImageNode( + att: { id: string; fileName: string; fileSize?: number }, + align?: "left" | "center" | "right", + alt?: string, + ): any { + // Clean file URL, matching Docmost's native behaviour. No cache-busting + // query: the server serves the bare URL correctly, and replacement creates + // a new attachment id (a new URL) which busts caches naturally. + const src = `/api/files/${att.id}/${att.fileName}`; + const node: any = { + type: "image", + attrs: { + src, + attachmentId: att.id, + // Default to null when the server omits fileSize so the attr is never + // undefined (undefined would be dropped on serialization / break the + // ProseMirror image schema which expects size present). + size: att.fileSize ?? null, + align: align || "center", + width: null, + }, + }; + if (alt) node.attrs.alt = alt; + return node; + } + + /** + * Download a remote image from an http(s) URL and upload it as an attachment + * of a page, returning the attachment metadata plus a ready-to-insert + * ProseMirror image node. Local file paths are intentionally not supported: + * the MCP caller is a remote AI with no access to this server's filesystem. + */ + async uploadImage(pageId: string, url: string) { + await this.ensureAuthenticated(); + + const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB + + // Fetch + validate the remote image (scheme allowlist, size cap, timeout). + // See fetchRemoteImage for the SSRF / resource trust boundary. + const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES); + const fileBuffer = fetched.buffer; + const mime = fetched.mime; + const fileName = fetched.fileName; + + // Build a FRESH FormData for every send attempt. A FormData body is a + // single-use stream that is CONSUMED on the first send, so it cannot be + // replayed by this.client's response interceptor (replaying a consumed + // stream fails with 'socket hang up'). Multipart re-auth is therefore done + // here with bare axios and an explicit one-shot 401/403 retry that rebuilds + // the body. Field order matters: text fields must precede the file part so + // the server reads them; the server always generates a fresh attachment id. + const buildForm = () => { + const form = new FormData(); + form.append("pageId", pageId); + form.append("file", fileBuffer, { + filename: fileName, + contentType: mime, + }); + return form; + }; + + // Local name distinct from the `url` parameter (the source image URL): this + // is the /files/upload endpoint we POST the multipart body to. + const uploadUrl = `${this.apiUrl}/files/upload`; + let response; + try { + // Call buildForm() ONCE per attempt and reuse the instance for both + // getHeaders() and the body so the Content-Type boundary matches the body. + const form = buildForm(); + // Read the Authorization header from this.client's defaults (set by + // login(), only ever deleted — never set to null) instead of building + // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token + // mid-flight, which would otherwise produce a literal "Bearer null". + // ensureAuthenticated() above guarantees login() ran, so the default + // header exists here. A 60s timeout keeps a hung upload from wedging the + // per-page lock (replaceImage holds withPageLock across this call). + response = await axios.post(uploadUrl, form, { + headers: { + ...form.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } catch (error) { + // On an expired-token auth error, re-login and retry exactly once with a + // freshly-rebuilt FormData (the previous one was already consumed). + if ( + axios.isAxiosError(error) && + (error.response?.status === 401 || error.response?.status === 403) + ) { + await this.login(); + const form2 = buildForm(); + response = await axios.post(uploadUrl, form2, { + headers: { + ...form2.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } else if (axios.isAxiosError(error)) { + // Keep the thrown message free of the raw response body (it may echo + // request data or server internals); surface only status/statusText. + // The full body is logged under DEBUG for diagnostics. + if (process.env.DEBUG) { + console.error( + "Image upload failed; response body:", + JSON.stringify(error.response?.data), + ); + } + throw new Error( + `Image upload failed: ${error.response?.status} ${error.response?.statusText}`, + ); + } else { + throw error; + } + } + // The attachment may arrive bare or wrapped in a { data } envelope. + const att = response.data?.data ?? response.data; + if (!att?.id || !att?.fileName) { + throw new Error( + "Unexpected /files/upload response: " + JSON.stringify(response.data), + ); + } + + // Some Docmost versions omit fileSize from the upload response. Fall back + // to the fetched byte length (the bytes we just uploaded) so callers never + // get an undefined size. + const resolvedSize = att.fileSize ?? fileBuffer.length; + + return { + attachmentId: att.id, + fileName: att.fileName, + fileSize: resolvedSize, + src: `/api/files/${att.id}/${att.fileName}`, + imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }), + }; + } + + /** + * Upload an image from a web (http/https) URL and insert it into a page in + * one step. + * By default the image is appended at the end. With replaceText, the first + * top-level block whose text contains the string is replaced; with afterText, + * the image is inserted right after the first matching block. All other + * block ids are preserved (only one top-level block is added or swapped). + */ + async insertImage( + pageId: string, + url: string, + opts: { + align?: "left" | "center" | "right"; + alt?: string; + replaceText?: string; + afterText?: string; + } = {}, + ) { + const up = await this.uploadImage(pageId, url); + // Reuse the node from uploadImage (clean /api/files// src), then + // apply align/alt onto a shallow attrs copy. + const node: any = { ...up.imageNode, attrs: { ...up.imageNode.attrs } }; + if (opts.align) node.attrs.align = opts.align; + if (opts.alt) node.attrs.alt = opts.alt; + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). The + // uploadImage /files/upload call above keeps the agent-supplied id. + const pageUuid = await this.resolvePageId(pageId); + + // Recursively collect the plain text of a top-level block. + const blockText = (n: any): string => { + let out = ""; + if (n.type === "text") out += n.text || ""; + for (const child of n.content || []) out += blockText(child); + return out; + }; + + // Insert into the LIVE synced document, not the debounced REST snapshot, so + // concurrent edits/comments/images are preserved and parallel insertImage + // calls (serialized by the per-page lock) each see the previous insertion. + let placement: "replaced" | "after" | "appended" | undefined; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + const doc = + liveDoc && liveDoc.type === "doc" + ? liveDoc + : { type: "doc", content: [] }; + if (!Array.isArray(doc.content)) doc.content = []; + + if (opts.replaceText) { + // Ambiguity guard (mirrors editPageText): count matching top-level + // blocks first, so a non-unique fragment cannot silently replace the + // wrong block (e.g. text that also appears inside a callout/table). + const matches = doc.content.filter((b: any) => + blockText(b).includes(opts.replaceText!), + ); + if (matches.length === 0) { + throw new Error(`replaceText not found: "${opts.replaceText}"`); + } + if (matches.length > 1) { + throw new Error( + `replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`, + ); + } + const idx = doc.content.findIndex((b: any) => + blockText(b).includes(opts.replaceText!), + ); + // Data-loss guard: replaceText swaps the WHOLE top-level block, so if + // the fragment only appears nested inside a container (table, callout, + // list, blockquote) the entire structure would be destroyed. Refuse + // when the matched block is a container rather than a leaf + // paragraph/heading and point the caller at a safer tool. + const CONTAINER_TYPES = new Set([ + "table", + "callout", + "bulletList", + "orderedList", + "taskList", + "blockquote", + ]); + const matchedBlock = doc.content[idx]; + if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) { + throw new Error( + `replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` + + `Use afterText to insert near it, or updatePageJson for surgical edits.`, + ); + } + doc.content.splice(idx, 1, node); + placement = "replaced"; + } else if (opts.afterText) { + // Ambiguity guard (mirrors editPageText): refuse a non-unique fragment. + const matches = doc.content.filter((b: any) => + blockText(b).includes(opts.afterText!), + ); + if (matches.length === 0) { + throw new Error(`afterText not found: "${opts.afterText}"`); + } + if (matches.length > 1) { + throw new Error( + `afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`, + ); + } + const idx = doc.content.findIndex((b: any) => + blockText(b).includes(opts.afterText!), + ); + doc.content.splice(idx + 1, 0, node); + placement = "after"; + } else { + doc.content.push(node); + placement = "appended"; + } + + return doc; + }, + ); + + return { + success: true, + pageId, + attachmentId: up.attachmentId, + src: up.src, + placement, + verify: mutation.verify, + }; + } + + /** + * Replace an existing image in a page with a new image fetched from a web + * (http/https) URL. Uploads the new file as a brand-new attachment, which + * yields a fresh clean URL that both renders correctly and busts browser + * caches (the URL changed). Finds every image node + * whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested + * in callouts/tables) and repoints its src/attachmentId/size, preserving + * comments, alignment and alt. Operates on the live collab document so comments + * and concurrent edits are preserved. Throws if no matching image is found. + * + * The OLD attachment is left in place as an unreferenced orphan: Docmost + * exposes NO HTTP API to delete a single content attachment (verified against + * the attachment controller/service and by probing the live API — deletion + * happens only by cascade when the page, space or user is removed). This is the + * same outcome as Docmost's own editor when an image is removed/replaced. + * In-place byte overwrite is deliberately NOT used because some Docmost + * versions corrupt the attachment (HTTP 500) when its bytes are overwritten. + */ + async replaceImage( + pageId: string, + oldAttachmentId: string, + url: string, + opts: { align?: "left" | "center" | "right"; alt?: string } = {}, + ) { + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). The + // page lock must ALSO key on the UUID so this operation serializes against + // other writes to the same page (mutatePageContent now locks by the resolved + // UUID too); locking by the raw slugId here would desync the mutex key and + // reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage + // keeps the agent-supplied id (it hits REST, not the collab doc). + const pageUuid = await this.resolvePageId(pageId); + + // Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write). + // Previously the scan and the write were two separate mutatePageContent + // calls, each acquiring + releasing the lock, with the upload happening in + // the UNLOCKED gap between them. A concurrent op could interleave there: it + // could remove the target image so the write pass matches nothing, leaving + // the freshly-uploaded attachment as an un-deletable orphan (Docmost has no + // API to delete a single content attachment). Acquiring the lock once and + // using the non-locking collab helper inside (the per-page mutex is NOT + // reentrant, so the self-locking mutatePageContent would deadlock here) + // closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP + // and does not touch the page lock, so it is safe to call while held. + return withPageLock(pageUuid, async () => { + // STEP 1: read-only live check. Scan the live document for any image node + // matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id + // throws without ever creating an orphan attachment. + let matchFound = false; + const scan = (nodes: any[]) => { + for (const node of nodes) { + if (!node) continue; + if ( + node.type === "image" && + node.attrs && + node.attrs.attachmentId === oldAttachmentId + ) { + matchFound = true; + } + if (Array.isArray(node.content)) scan(node.content); + } + }; + + await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => { + matchFound = false; // reset per-transform (collab may retry the read). + const doc = + liveDoc && liveDoc.type === "doc" + ? liveDoc + : { type: "doc", content: [] }; + if (Array.isArray(doc.content)) scan(doc.content); + return null; // read-only: never write on the check pass. + }); + + if (!matchFound) { + throw new Error( + `replaceImage: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`, + ); + } + + // STEP 2: a match exists — upload the new file as a FRESH attachment (new + // id, new clean URL) and repoint every matching node in a second pass. + // Still inside the SAME lock, so no other op can have changed the page + // since the scan. + const up = await this.uploadImage(pageId, url); + + let replaced = 0; + + // Swap the source of one image node, preserving align/alt/title/geometry. + const repoint = (node: any) => { + node.attrs = { + ...node.attrs, + src: up.src, + attachmentId: up.attachmentId, + // Default to null when fileSize is unknown so the attr is never + // undefined. + size: up.fileSize ?? null, + }; + if (opts.align) node.attrs.align = opts.align; + if (opts.alt !== undefined) node.attrs.alt = opts.alt; + replaced++; + }; + + // Recursively repoint every image node (incl. ones nested in callouts/tables). + const walk = (nodes: any[]) => { + for (const node of nodes) { + if (!node) continue; + if ( + node.type === "image" && + node.attrs && + node.attrs.attachmentId === oldAttachmentId + ) { + repoint(node); + } + if (Array.isArray(node.content)) walk(node.content); + } + }; + + const mutation = await this.mutateLiveContentUnlocked( + pageUuid, + collabToken, + (liveDoc) => { + // Reset per-transform so collab retries recompute cleanly (no double-count). + replaced = 0; + const doc = + liveDoc && liveDoc.type === "doc" + ? liveDoc + : { type: "doc", content: [] }; + if (!Array.isArray(doc.content)) doc.content = []; + walk(doc.content); + if (replaced === 0) return null; // no match -> skip the write entirely + return doc; + }, + ); + // KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no + // text/mark change) may still report verify.changed === false, because the + // text+marks+integrity-count model in summarizeChange does not inspect + // image `src`/attachmentId attributes. That is acceptable here — the + // replace is confirmed by `replaced` below, and verify is supplementary. + + if (replaced === 0) { + // The pass-1 SCAN found the target (matchFound was true) and we already + // uploaded the new attachment, but pass-2 matched nothing — a concurrent + // editor must have removed the node between the two passes. Do NOT throw + // here (that would leak the just-uploaded attachment AND report failure); + // instead report success with the upload flagged as an unreferenced + // orphan so the caller knows. (The early throw above still covers the + // case where pass-1 finds nothing, before any upload happens.) + return { + success: true, + replaced: 0, + pageId, + oldAttachmentId, + newAttachmentId: up.attachmentId, + src: up.src, + orphanedAttachmentId: up.attachmentId, + warning: + "target image was removed concurrently; uploaded attachment is unreferenced", + verify: mutation.verify, + }; + } + + return { + success: true, + pageId, + replaced, + oldAttachmentId, + newAttachmentId: up.attachmentId, + src: up.src, + verify: mutation.verify, + }; + }); + } + + // --- draw.io diagrams (issue #423) --- + + /** + * Upload a ready-made byte buffer as a page attachment via the same + * multipart /files/upload endpoint uploadImage uses. Split out as its own + * (overridable) seam so drawioCreate/update can upload the generated + * `.drawio.svg` without going through the URL-fetch path, and so tests can + * stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403 + * re-auth handling (a FormData body is single-use, so it must be rebuilt per + * attempt). + */ + + // --- draw.io diagrams (issue #423) --- + + /** + * Upload a ready-made byte buffer as a page attachment via the same + * multipart /files/upload endpoint uploadImage uses. Split out as its own + * (overridable) seam so drawioCreate/update can upload the generated + * `.drawio.svg` without going through the URL-fetch path, and so tests can + * stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403 + * re-auth handling (a FormData body is single-use, so it must be rebuilt per + * attempt). + */ + protected async uploadAttachmentBuffer( + pageId: string, + buffer: Buffer, + fileName: string, + mime: string, + ): Promise<{ id: string; fileName: string; fileSize: number }> { + await this.ensureAuthenticated(); + const buildForm = () => { + const form = new FormData(); + form.append("pageId", pageId); + form.append("file", buffer, { filename: fileName, contentType: mime }); + return form; + }; + const uploadUrl = `${this.apiUrl}/files/upload`; + let response; + try { + const form = buildForm(); + response = await axios.post(uploadUrl, form, { + headers: { + ...form.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } catch (error) { + if ( + axios.isAxiosError(error) && + (error.response?.status === 401 || error.response?.status === 403) + ) { + await this.login(); + const form2 = buildForm(); + response = await axios.post(uploadUrl, form2, { + headers: { + ...form2.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } else if (axios.isAxiosError(error)) { + if (process.env.DEBUG) { + console.error( + "Attachment upload failed; response body:", + JSON.stringify(error.response?.data), + ); + } + throw new Error( + `Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`, + ); + } else { + throw error; + } + } + const att = response.data?.data ?? response.data; + if (!att?.id || !att?.fileName) { + throw new Error( + "Unexpected /files/upload response: " + JSON.stringify(response.data), + ); + } + return { + id: att.id, + fileName: att.fileName, + fileSize: att.fileSize ?? buffer.length, + }; + } + + /** + * Fetch a stored `.drawio.svg` attachment as text. Overridable seam over + * fetchInternalFile (the authed loopback fetch, which also rejects any + * traversal/SSRF src) so drawioGet/update can read the current diagram and + * tests can stub the bytes. + */ + protected async fetchAttachmentText(src: string): Promise { + const { buffer } = await this.fetchInternalFile(src); + return buffer.toString("utf-8"); + } + + /** + * Resolve a drawio node on a page by `attrs.id` or `#` and return the + * node plus its ref. Throws a clear error if the ref does not resolve to a + * drawio node. + */ + } + return MediaMixin; +} diff --git a/packages/mcp/src/client/nodes-write.ts b/packages/mcp/src/client/nodes-write.ts new file mode 100644 index 00000000..952e4f55 --- /dev/null +++ b/packages/mcp/src/client/nodes-write.ts @@ -0,0 +1,700 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { + replaceNodeById, + replaceNodeByIdWithMany, + reassignCollidingBlockIds, + deleteNodeById, + assertUnambiguousMatch, + insertNodeRelative, + insertNodesRelative, + blockPlainText, + buildOutline, + getNodeByRef, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + findInvalidNode, +} from "@docmost/prosemirror-markdown"; +import { + importMarkdownFragment, + canBeDocChild, + findUnrepresentableTableAttrs, +} from "../lib/markdown-fragment.js"; +import { + applyTextEdits, + TextEdit, + TextEditResult, + TextEditFailure, +} from "../lib/json-edit.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; +import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js"; + +// Public method surface of NodesWriteMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements INodesWriteMixin` fails to compile on drift. +export interface INodesWriteMixin { + updatePageJson(pageId: string, doc?: any, title?: string): any; + editPageText(pageId: string, edits: TextEdit[]): any; + patchNode(pageId: string, nodeId: string, input: { markdown?: string; node?: any }): any; + insertNode(pageId: string, input: { markdown?: string; node?: any }, opts: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }): any; + deleteNode(pageId: string, nodeId: string): any; +} + +export function NodesWriteMixin>(Base: TBase): GConstructor & TBase { + abstract class NodesWriteMixin extends Base implements INodesWriteMixin { + /** + * Replace page content with a raw ProseMirror JSON document (lossless) and/or + * update its title. Both `doc` and `title` are optional, but at least one must + * be supplied: + * - `doc` provided -> validate + full-overwrite the body (and update the + * title too when `title` is also given). + * - `doc` omitted, `title` given -> title-only update; the body is NOT + * touched/resent (no collab write happens). + * - neither given -> throws (nothing to update). + */ + async updatePageJson(pageId: string, doc?: any, title?: string) { + await this.ensureAuthenticated(); + + // Title-only / no-op handling: when no document is supplied, do NOT write + // the body. Update the title if one was given; otherwise there is nothing + // to do, so fail loudly rather than silently no-op. + if (doc == null) { + if (!title) { + throw new Error( + "updatePageJson: nothing to update (provide content and/or title)", + ); + } + await this.client.post("/pages/update", { pageId, title }); + return { + success: true, + modified: true, + message: "Page title updated (content left unchanged).", + pageId, + }; + } + + // Validate the document shape before a full overwrite: a malformed doc + // would otherwise silently corrupt the page (full-overwrite is the + // documented behaviour; no optimistic-concurrency is applied here). + if ( + typeof doc !== "object" || + doc.type !== "doc" || + !Array.isArray(doc.content) + ) { + throw new Error( + 'content must be a ProseMirror document ({"type":"doc","content":[...]}) ' + + "where content is an array of nodes each having a string `type`", + ); + } + + // Recurse the WHOLE document so a malformed nested node (e.g. a node with a + // non-string type, a non-array content/marks, or a text node missing its + // string text) is rejected up front rather than silently corrupting the + // page on overwrite. + this.validateDocStructure(doc); + + // #409: beyond the string-`type` check above, reject a nested node whose + // `type` is a string but NOT a known Docmost schema node (a typo/unknown + // block) — the same `Unknown node type` the encoder throws — with a rich, + // path-anchored message, still BEFORE any collab connection. + this.assertValidNodeShape("updatePageJson", doc); + + // Sanitize URLs before writing. This closes the JSON-path bypass: unlike + // the markdown link path (which TipTap sanitizes), raw JSON could otherwise + // inject javascript:/data: link hrefs or media srcs straight into the doc. + this.validateDocUrls(doc); + + // Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot + // leave footnotes out of order, orphaned, or in multiple lists — the bottom + // list + numbering are always derived from reference order. No-op when the + // footnotes are already canonical. + // #419: normalize + merge glyph-forked definitions before canonicalizing. + doc = normalizeAndMergeFootnotes(doc); + doc = canonicalizeFootnotes(doc); + + // Write the BODY first, then the title (#159 split-brain): a failed body + // write (e.g. persist timeout) must not leave a new title over the old body. + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + const mutation = await this.replacePage( + pageUuid, + doc, + collabToken, + this.apiUrl, + ); + + // Body persisted successfully — now it is safe to set the title. + if (title) { + await this.client.post("/pages/update", { pageId, title }); + } + + return { + success: true, + modified: true, + message: "Page content replaced from ProseMirror JSON.", + pageId, + verify: mutation.verify, + }; + } + + /** + * AUTHOR-INLINE footnote insertion. The agent supplies only WHERE + * (`anchorText`, a snippet of body text to attach the marker after) and WHAT + * (`text`, the footnote content as markdown). Numbering and the bottom + * `footnotesList` are derived deterministically server-side + * (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees, + * assigns, or edits a footnote number or the list, so it CANNOT desync. + * + * Content DEDUP: when an existing definition has the same content, its id is + * reused (one number, one definition, several references). The write is atomic + * via `mutatePageContent` (single-writer, page-locked); if the anchor text is + * not found the transform aborts with a clear error and no write happens. + */ + + /** + * Surgical text edits: find/replace inside text nodes of the live + * document. Preserves all block ids, marks, callouts and tables. + */ + async editPageText(pageId: string, edits: TextEdit[]) { + await this.ensureAuthenticated(); + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + // Apply the edits against the LIVE synced document, not the debounced REST + // snapshot, so concurrent human edits/comments are preserved. applyTextEdits + // records per-edit match problems in `failed` instead of throwing, and + // applies whatever it can; we abort the write only when nothing applied. + let results: TextEditResult[] | undefined; + let failed: TextEditFailure[] | undefined; + // Whether we actually wrote new content. Set inside the transform: a + // degenerate edit (e.g. find === replace, or a batch that nets to no change) + // can "apply" yet leave the document byte-for-byte identical, in which case + // we must NOT write (no spurious history version) and must not claim a write + // happened. + let wrote = false; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + wrote = false; + const r = applyTextEdits(liveDoc, edits); + results = r.results; + failed = r.failed; + // Nothing applied -> abort the write (mutatePageContent treats a null + // return from the transform as "write nothing"). + if (r.results.length === 0) return null; + // Edits "applied" but produced an identical document: skip the write so + // no new history version is created. Stable structural comparison via + // JSON.stringify (both docs come from the same deep-copied source, so + // key order is stable). + if (JSON.stringify(r.doc) === JSON.stringify(liveDoc)) return null; + wrote = true; + return r.doc; + }, + ); + + if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) { + // No edit applied: surface an aggregated, actionable error so the caller + // does not mistake a no-op for a partial success. + throw new Error( + "editPageText: no edits were applied (nothing written). " + + failed!.map((f) => `"${f.find}": ${f.reason}`).join("; "), + ); + } + + // Edits matched but produced no content change (identical document): report + // a successful no-op — NOT a failure — and do not falsely claim a write. + if (!wrote) { + return { + success: true, + pageId, + applied: results, + failed, + message: "No changes written (edits produced identical content).", + verify: mutation.verify, + }; + } + + const result: any = { + success: true, + pageId, + applied: results, + failed, + message: + (failed?.length ?? 0) + ? `Applied ${results?.length ?? 0} edit(s); ${failed!.length} failed (see failed[]). Node ids and formatting preserved.` + : "Text edits applied (node ids and formatting preserved).", + verify: mutation.verify, + }; + + // If any applied edit matched only after stripping markdown (the + // normalized fallback), warn that editPageText preserved existing marks + // and did NOT change formatting — so a caller who intended a formatting + // change is pointed at patchNode. + if (results?.some((r) => r.normalized === true)) { + result.warning = + "Some edits matched only after stripping markdown from your find string; " + + "editPageText preserved existing marks (it did not change bold/strike/etc.). " + + "If you intended a formatting change, use patchNode."; + } + + return result; + } + + /** + * Replace the block whose attrs.id === nodeId. Operates on the LIVE collab + * document so comments and concurrent edits are preserved. + * + * Exactly one of `input.markdown` / `input.node` (#413): + * - `markdown` (RECOMMENDED): the block is rewritten from a canonical markdown + * fragment. The fragment may import to N blocks (a "1 -> N" splice: rewrite a + * whole section in one call). The FIRST resulting block INHERITS the target's + * `attrs.id` (so an existing comment anchoring the block by id survives); the + * rest get FRESH ids. `^[...]` footnotes in the fragment are first-class: + * their definitions merge into the page's TAIL footnote list (content-key + * dedup + canonicalize), same machinery insertFootnote uses. REJECTED when + * the TARGET block carries a table-cell attribute markdown cannot represent + * (colspan/rowspan/colwidth/background) — use the table tools or `node`. + * - `node`: a raw ProseMirror node for precise attr/mark work. The replacement + * keeps the target id (if `node.attrs.id` is missing it is set to nodeId). + * + * #159 ambiguous-id semantics are unchanged: 0 matches -> "no node"; >1 matches + * -> "ambiguous, refused" (nothing written), on BOTH paths — the markdown path + * runs a dry `replaceNodeById` count first, so a duplicated id never splices. + */ + async patchNode( + pageId: string, + nodeId: string, + input: { markdown?: string; node?: any }, + ) { + await this.ensureAuthenticated(); + + // XOR: exactly one of markdown / node. Both optional in the schema; the + // runtime enforces the recommendation ("markdown for prose, node for fine + // work") without letting an ambiguous both-or-neither call through. + const hasMd = + input != null && + typeof input.markdown === "string" && + input.markdown.trim() !== ""; + const hasNode = input != null && input.node != null; + if (hasMd === hasNode) { + throw new Error( + "patchNode: provide exactly one of `markdown` (recommended, for prose) " + + "or `node` (a raw ProseMirror node, for precise attr/mark work)", + ); + } + + if (hasMd) { + return this.patchNodeMarkdown(pageId, nodeId, input.markdown as string); + } + return this.patchNodeJson(pageId, nodeId, input.node); + } + + /** + * patchNode with a raw ProseMirror `node` (the pre-#413 behavior). Replaces + * EVERY node whose attrs.id === nodeId; the swapped-in node keeps the target + * id. #159 ambiguity refused. Split out so the markdown path can reuse the + * shared collab/guard plumbing without a giant branch. + */ + protected async patchNodeJson(pageId: string, nodeId: string, node: any) { + if (!node || typeof node !== "object" || typeof node.type !== "string") { + throw new Error( + "patchNode: `node` must be an object with a string `type`", + ); + } + // Preserve the block id WITHOUT mutating the caller's object: build a local + // copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the + // node it replaces). + const target = { + ...node, + attrs: { + ...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}), + }, + }; + if (target.attrs.id == null) { + target.attrs.id = nodeId; + } + + // #409: fail fast on a malformed node SHAPE (a nested child with an + // absent/unknown `type`, e.g. a text leaf written as `{"text":"foo"}` with + // no `"type":"text"`) BEFORE opening a collab session or taking the page + // lock — the root-only `typeof node.type === "string"` check above never + // sees nested children, and the encoder's `Unknown node type: undefined` + // would otherwise only surface after the connection. + this.assertValidNodeShape("patchNode", target); + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + // Track the replacement count in an outer var, reset per-transform, so a + // collab retry recomputes it cleanly (mirrors replaceImage's pattern). + let replaced = 0; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + replaced = 0; + const { doc: nd, replaced: r } = replaceNodeById( + liveDoc, + nodeId, + target, + ); + replaced = r; + // 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost + // duplicates block ids on copy/paste (and copyPageContent writes them + // verbatim), so replacing "the node with id X" would silently clobber + // EVERY duplicate (#159). Refuse: skip the write and throw below so the + // model re-targets with a more specific anchor instead of corrupting the + // page. Only an unambiguous single match is written. + if (replaced !== 1) return null; + return nd; + }, + ); + + // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped + // the write for any count !== 1). Single shared guard (#159, #185 review). + assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId); + + return { success: true, replaced, nodeId, verify: mutation.verify }; + } + + /** + * patchNode with a MARKDOWN fragment (#413). Imports the fragment through the + * canonical importer, then 1 -> N splices the resulting blocks in place of the + * target block on the LIVE collab doc: + * - the FIRST block inherits the target's id; the rest get FRESH ids (minted + * by the importer/id-remap, so neighbour blocks are untouched); + * - `^[...]` footnote definitions merge into the page's tail list; + * - REJECTED when the target block carries a markdown-unrepresentable table + * attr (colspan/rowspan/colwidth/background) — guarding against silent loss; + * - #159 ambiguity is enforced by a dry `replaceNodeById` count BEFORE the + * splice, so a duplicated id never writes. + */ + protected async patchNodeMarkdown( + pageId: string, + nodeId: string, + markdown: string, + ) { + // Import the fragment up front (network-free, canonical) so a bad fragment + // fails before any collab connection or page lock. + const { blocks, definitions } = await importMarkdownFragment(markdown); + + // The first imported block inherits the target id; the rest keep the fresh + // ids the importer assigned. Build the thread now so it is stable across a + // collab retry (the transform below is pure over its inputs). + const threaded = blocks.map((b, i) => { + if (i !== 0) return b; + return { + ...b, + attrs: { + ...(b && typeof b.attrs === "object" ? b.attrs : {}), + id: nodeId, + }, + }; + }); + + // Shape-validate every imported block up front (parity with the JSON path): + // the importer only emits schema nodes, but the check is cheap insurance and + // yields the same rich #409 diagnostics if the schema ever drifts. + for (const b of threaded) { + this.assertValidNodeShape("patchNode", b); + } + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + let replaced = 0; + let guardAttrs: string | null = null; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + replaced = 0; + guardAttrs = null; + + // #159: count matches with the same recursive walk the JSON path uses; + // only an UNAMBIGUOUS single match may write. A dry count keeps the + // ambiguity semantics identical across both paths. + const { replaced: count } = replaceNodeById(liveDoc, nodeId, { + type: "paragraph", + }); + replaced = count; + if (count !== 1) return null; + + // Guard against SILENT LOSS: if the target block carries a table-cell + // attribute markdown cannot represent (colspan/rowspan/colwidth/ + // background), refuse the markdown rewrite so those attrs are not + // dropped. Simple tables (no such attrs) rewrite fine. + const hit = getNodeByRef(liveDoc, nodeId); + guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null; + if (guardAttrs != null) return null; + + // Re-mint any minted block id that collides with an existing page id + // (skip index 0: its id is intentionally the target nodeId, unique by + // the #159 dry-count above), so the 1 -> N splice stays page-wide unique. + reassignCollidingBlockIds(liveDoc, threaded, 0); + + // 1 -> N splice, then merge any fragment footnote definitions into the + // page's tail list and re-derive canonical footnote numbering. + const { doc: spliced } = replaceNodeByIdWithMany( + liveDoc, + nodeId, + threaded, + ); + return mergeFootnoteDefinitions(spliced, definitions); + }, + ); + + // Surface the guard rejection with an actionable message (nothing written). + if (guardAttrs != null) { + throw new Error( + `patchNode: the target block has table-cell attributes markdown cannot ` + + `represent (${guardAttrs}) — a markdown rewrite would drop them. Use ` + + `the table tools (tableUpdateCell/tableInsertRow) or pass a raw ` + + `ProseMirror \`node\` instead of \`markdown\`.`, + ); + } + + // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform skipped the write + // for any count !== 1). Shared #159 guard, identical to the JSON path. + assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId); + + return { + success: true, + replaced, + nodeId, + blocks: threaded.length, + verify: mutation.verify, + }; + } + + /** + * Insert content relative to an anchor (or append it at the top level). + * Operates on the LIVE collab document so comments and concurrent edits are + * preserved. + * + * Exactly one of `input.markdown` / `input.node` (#413): + * - `markdown` (RECOMMENDED): a canonical markdown fragment. It may import to + * SEVERAL blocks — they are inserted IN ORDER at the anchor. `^[...]` + * footnote definitions merge into the page's tail list (same machinery as + * insertFootnote). Every inserted block gets a fresh id. + * - `node`: a raw ProseMirror node for precise attr/mark work, or to insert + * table structure (a bare tableRow/tableCell/tableHeader — NOT expressible in + * markdown, so those stay JSON-only). + * + * opts.position: + * - "append": push the content at the end of the top-level content. + * - "before"/"after": insert as a sibling of the anchor, just before/after it. + * Exactly one of anchorNodeId / anchorText must be given; anchorNodeId + * locates a node anywhere by attrs.id, anchorText matches the first top-level + * block whose plain text includes it. + * + * Throws if the anchor cannot be found. + */ + async insertNode( + pageId: string, + input: { markdown?: string; node?: any }, + opts: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + ) { + await this.ensureAuthenticated(); + + // XOR: exactly one of markdown / node (both optional in the schema). + const hasMd = + input != null && + typeof input.markdown === "string" && + input.markdown.trim() !== ""; + const hasNode = input != null && input.node != null; + if (hasMd === hasNode) { + throw new Error( + "insertNode: provide exactly one of `markdown` (recommended, for prose) " + + "or `node` (a raw ProseMirror node, for precise attr/mark work or table structure)", + ); + } + + if ( + !opts || + (opts.position !== "before" && + opts.position !== "after" && + opts.position !== "append") + ) { + throw new Error( + 'insertNode: `position` must be one of "before", "after", "append"', + ); + } + if (opts.position === "before" || opts.position === "after") { + // before/after require EXACTLY ONE anchor (an id or a text fragment). + const hasId = + typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0; + const hasText = + typeof opts.anchorText === "string" && opts.anchorText.length > 0; + if (hasId === hasText) { + throw new Error( + `insertNode: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`, + ); + } + } + + // Resolve the ordered list of blocks to insert plus any footnote definitions + // to merge. The markdown path imports canonically (so an inserted block is + // byte-identical to the same content in a full-page import); the node path is + // a single block with no footnote merge (raw JSON `^[...]` is not touched). + let blocks: any[]; + let definitions: any[] = []; + if (hasMd) { + const frag = await importMarkdownFragment(input.markdown as string); + blocks = frag.blocks; + definitions = frag.definitions; + } else { + const node = input.node; + if (!node || typeof node !== "object" || typeof node.type !== "string") { + throw new Error( + "insertNode: `node` must be an object with a string `type`", + ); + } + blocks = [node]; + } + + // #409: fail fast on a malformed node SHAPE (a nested child with an + // absent/unknown `type`) BEFORE opening a collab session or taking the page + // lock — the root-only check above never sees nested children. + for (const b of blocks) { + this.assertValidNodeShape("insertNode", b); + } + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + // Track insertion in an outer var, reset per-transform, so a collab retry + // recomputes it cleanly (mirrors replaceImage's pattern). + let inserted = false; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + inserted = false; + // Re-mint any minted block id that collides with an existing page id + // (all inserted blocks are fresh, no skip) so the splice stays unique. + if (hasMd) reassignCollidingBlockIds(liveDoc, blocks); + // Single-block node path keeps `insertNodeRelative` (it owns the + // structural table-node splicing); the markdown path uses the array + // splice so N blocks land in order at one anchor. + const res = hasMd + ? insertNodesRelative(liveDoc, blocks, opts) + : insertNodeRelative(liveDoc, blocks[0], opts); + inserted = res.inserted; + if (!inserted) return null; // anchor not found -> skip the write entirely + // Merge any fragment footnote definitions into the page tail list and + // re-derive canonical numbering (no-op when there are none). + return mergeFootnoteDefinitions(res.doc, definitions); + }, + ); + + if (!inserted) { + const anchorDesc = opts.anchorNodeId + ? `anchorNodeId "${opts.anchorNodeId}"` + : `anchorText "${opts.anchorText}"`; + // anchorText is matched against the block's literal RENDERED plain text; + // markdown/emoji are tolerated only as a strip-and-retry fallback, so a + // miss usually means the text differs from what's on the page. + const hint = opts.anchorText + ? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from getPageJson is more reliable." + : ""; + throw new Error( + `insertNode: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`, + ); + } + + return { + success: true, + inserted: true, + position: opts.position, + blocks: blocks.length, + verify: mutation.verify, + }; + } + + /** + * Remove EVERY node whose attrs.id === nodeId (recursively, including nodes + * nested in callouts/tables) from its parent content array. Operates on the + * LIVE collab document so comments and concurrent edits are preserved. + * Throws if no node matches. + */ + async deleteNode(pageId: string, nodeId: string) { + await this.ensureAuthenticated(); + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + // Track the deletion count in an outer var, reset per-transform, so a + // collab retry recomputes it cleanly (mirrors replaceImage's pattern). + let deleted = 0; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + deleted = 0; + const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId); + deleted = d; + // 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block + // ids are duplicated on copy/paste, #159): deleting "the node with id X" + // would silently remove EVERY duplicate. Refuse: skip the write and throw + // below so the model re-targets. Only an unambiguous single match is + // deleted. + if (deleted !== 1) return null; + return nd; + }, + ); + + // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped + // the write for any count !== 1). Single shared guard (#159, #185 review). + assertUnambiguousMatch("deleteNode", "delete", deleted, nodeId, pageId); + + return { success: true, deleted, nodeId, verify: mutation.verify }; + } + + /** Build the public share URL for a page. */ + } + return NodesWriteMixin; +} diff --git a/packages/mcp/src/client/pages.ts b/packages/mcp/src/client/pages.ts new file mode 100644 index 00000000..8d69603b --- /dev/null +++ b/packages/mcp/src/client/pages.ts @@ -0,0 +1,655 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import FormData from "form-data"; +import axios, { AxiosInstance } from "axios"; +import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { footnoteWarningsField } from "../lib/footnote-analyze.js"; +import { + serializeDocmostMarkdown, + parseDocmostMarkdown, +} from "../lib/markdown-document.js"; +import { diffDocs, summarizeChange } from "../lib/diff.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; +import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js"; +import vm from "node:vm"; + +// Public method surface of PagesMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements IPagesMixin` fails to compile on drift. +export interface IPagesMixin { + createPage(title: string, content: string, spaceId: string, parentPageId?: string): any; + updatePage(pageId: string, content: string, title?: string): any; + renamePage(pageId: string, title: string): any; + movePage(pageId: string, parentPageId: string | null, position?: string): any; + deletePage(pageId: string): any; + sharePage(pageId: string, searchIndexing?: boolean): any; + listShares(): any; + unsharePage(pageId: string): any; + exportPageMarkdown(pageId: string): Promise; + importPageMarkdown(pageId: string, fullMarkdown: string): Promise; + copyPageContent(sourcePageId: string, targetPageId: string): any; + listPageHistory(pageId: string, cursor?: string): any; + getPageHistory(historyId: string): any; + restorePageVersion(historyId: string): any; + diffPageVersions(pageId: string, from?: string, to?: string): any; +} + +export function PagesMixin>(Base: TBase): GConstructor & TBase { + abstract class PagesMixin extends Base implements IPagesMixin { + /** + * Create a new page with title and content. + * Uses the /pages/import workaround (the only endpoint accepting content), + * then moves the page and restores the exact title: the import endpoint + * derives the title from the FILENAME and replaces spaces with + * underscores, so we explicitly re-set it via /pages/update afterwards. + */ + async createPage( + title: string, + content: string, + spaceId: string, + parentPageId?: string, + ) { + await this.ensureAuthenticated(); + + if (parentPageId) { + try { + await this.getPage(parentPageId); + } catch (e) { + throw new Error(`Parent page with ID ${parentPageId} not found.`); + } + } + + // 1. Create content via Import (using multipart/form-data). + // Build a FRESH FormData per send attempt: a FormData body is a single-use + // stream consumed on the first send, so it cannot be replayed by + // this.client's response interceptor (replay fails with 'socket hang up'). + // Multipart re-auth is therefore done here with bare axios and an explicit + // one-shot 401/403 retry that rebuilds the body. + const fileContent = Buffer.from(content, "utf-8"); + const buildForm = () => { + const form = new FormData(); + form.append("spaceId", spaceId); + form.append("file", fileContent, { + filename: `${title || "import"}.md`, + contentType: "text/markdown", + }); + return form; + }; + + const importUrl = `${this.apiUrl}/pages/import`; + let response; + try { + // Call buildForm() ONCE per attempt and reuse the instance for both + // getHeaders() and the body so the Content-Type boundary matches the body. + const form = buildForm(); + // Read the Authorization header from this.client's defaults (set by + // login(), only ever deleted — never set to null) instead of building + // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token + // mid-flight, which would otherwise produce a literal "Bearer null". + // ensureAuthenticated() above guarantees login() ran, so the default + // header exists here. + response = await axios.post(importUrl, form, { + headers: { + ...form.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } catch (error) { + // On an expired-token auth error, re-login and retry exactly once with a + // freshly-rebuilt FormData (the previous one was already consumed). + if ( + axios.isAxiosError(error) && + (error.response?.status === 401 || error.response?.status === 403) + ) { + await this.login(); + const form2 = buildForm(); + response = await axios.post(importUrl, form2, { + headers: { + ...form2.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } else { + throw error; + } + } + const newPageId = (response.data?.data ?? response.data).id; + + // 2. Move to parent if needed + if (parentPageId) { + await this.movePage(newPageId, parentPageId); + } + + // 3. Restore the exact title (import mangles spaces into underscores) + if (title) { + await this.client.post("/pages/update", { pageId: newPageId, title }); + } + + const page = await this.getPage(newPageId); + // Surface non-fatal footnote problems (dangling refs, empty/duplicate + // definitions, markers in tables) so the agent can fix its markup (#166). + return { ...page, ...footnoteWarningsField(content) }; + } + + /** + * Update a page's content from markdown and optionally its title. + * NOTE: full re-import — block ids regenerate. For surgical changes + * use editPageText / updatePageJson instead. + */ + async updatePage(pageId: string, content: string, title?: string) { + await this.ensureAuthenticated(); + // Open the collab doc by the canonical UUID, never the slugId (#260). The + // REST /pages/update title write below keeps the agent-supplied id (the + // server resolves a slugId there). + const pageUuid = await this.resolvePageId(pageId); + + // Write the BODY first, then the title (#159 split-brain). If the collab + // body write fails (e.g. a persist timeout), the title must be left + // UNTOUCHED so the page never ends up with a new title over its old body. + // A title write failing AFTER a successful body is rarer (REST is fast) and + // leaves correct content under a stale title — the lesser inconsistency. + let collabToken = ""; + let mutation; + try { + collabToken = await this.getCollabTokenWithReauth(); + mutation = await updatePageContentRealtime( + pageUuid, + content, + collabToken, + this.apiUrl, + ); + } catch (error: any) { + // Verbose diagnostics (incl. anything that could expose a token prefix) + // are gated behind DEBUG; the thrown Error below carries no token data. + if (process.env.DEBUG) { + console.error( + "Failed to update page content via realtime collaboration:", + error, + ); + const tokenPreview = collabToken + ? collabToken.substring(0, 15) + "..." + : "null"; + console.error(`Collab token preview: ${tokenPreview}`); + } + throw new Error(`Failed to update page content: ${error.message}`); + } + + // Body persisted successfully — now it is safe to set the title. + if (title) { + await this.client.post("/pages/update", { pageId, title }); + } + + return { + success: true, + modified: true, + message: "Page updated successfully.", + pageId: pageId, + verify: mutation.verify, + // Non-fatal footnote diagnostics (#166); omitted when there are none. + ...footnoteWarningsField(content), + }; + } + + /** + * Validate a URL string against a scheme allowlist for a given context. + * + * The markdown link path enforces safe schemes via TipTap, but the raw + * JSON path (updatePageJson) bypasses that — so this is the sanitization + * choke point for ProseMirror JSON written directly by the caller. + * + * - "link": reject javascript:, vbscript:, data: (any scheme that can + * execute or smuggle script when the href is clicked). + * - "src": allow only http(s):, mailto:, /api/files paths, or a + * scheme-less relative/absolute path; reject + * javascript:/vbscript:/data:/file:. + */ + + /** + * Rename a page (change its title only) without touching or resending its + * content. The slug is derived from the page record, not the body, so it is + * left intact too. + */ + async renamePage(pageId: string, title: string) { + await this.ensureAuthenticated(); + await this.client.post("/pages/update", { pageId, title }); + return { success: true, pageId, title }; + } + + /** + * Copy the WHOLE content of one page onto another, entirely server-side: the + * source's ProseMirror document is read and written verbatim onto the target + * via the live collab path, so the document never passes through the model. + * + * Only the target's BODY is replaced — its title and slug live on the page + * record (not in the content), so they are untouched. The source page is not + * modified at all. + */ + + async movePage( + pageId: string, + parentPageId: string | null, + position?: string, + ) { + await this.ensureAuthenticated(); + // Docmost requires position >= 5 chars. + const validPosition = position || "a00000"; + + return this.client + .post("/pages/move", { + pageId, + parentPageId, + position: validPosition, + }) + .then((res) => res.data); + } + + + async deletePage(pageId: string) { + await this.ensureAuthenticated(); + return this.client + .post("/pages/delete", { pageId }) + .then((res) => res.data); + } + + // --- Comment methods (ported from upstream PR #3 by Max Nikitin) --- + + /** + * Normalize a comment's `content` into a ProseMirror doc object before + * markdown conversion. createComment/updateComment send content as a + * JSON.stringify(...) STRING, and the server stores it as-is, so on read it + * comes back as a string. convertProseMirrorToMarkdown returns "" for a + * string, so parse it first (guarded — fall back to the raw value on any + * parse failure so a non-JSON legacy value is still handled gracefully). + */ + + /** Share a page publicly (idempotent) and return the public URL. */ + async sharePage(pageId: string, searchIndexing: boolean = true) { + await this.ensureAuthenticated(); + const response = await this.client.post("/shares/create", { + pageId, + includeSubPages: false, + searchIndexing, + }); + const share = response.data?.data ?? response.data; + const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId; + return { + shareId: share.id, + key: share.key, + pageId: share.pageId, + publicUrl: this.shareUrl(share.key, slugId), + searchIndexing: share.searchIndexing, + }; + } + + /** List all public shares in the workspace with their URLs. */ + + /** Build the public share URL for a page. */ + protected shareUrl(shareKey: string, slugId: string): string { + return `${this.appUrl}/share/${shareKey}/p/${slugId}`; + } + + /** Share a page publicly (idempotent) and return the public URL. */ + + /** List all public shares in the workspace with their URLs. */ + async listShares() { + const shares = await this.paginateAll("/shares", {}); + return shares.map((s: any) => ({ + shareId: s.id, + key: s.key, + pageId: s.pageId, + pageTitle: s.page?.title, + publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null, + searchIndexing: s.searchIndexing, + createdAt: s.createdAt, + })); + } + + /** Remove the public share of a page. */ + async unsharePage(pageId: string) { + await this.ensureAuthenticated(); + const shares = await this.listShares(); + const share = shares.find((s: any) => s.pageId === pageId); + if (!share) { + throw new Error(`Page ${pageId} is not shared.`); + } + await this.client.post("/shares/delete", { shareId: share.shareId }); + return { success: true, removedShareId: share.shareId, pageId }; + } + + + /** + * Export a page to a single self-contained Docmost-flavoured markdown file: + * meta block + body (with inline comment anchors + diagrams) + comment + * threads. Lossless round-trip target; see importPageMarkdown for the inverse. + */ + async exportPageMarkdown(pageId: string): Promise { + await this.ensureAuthenticated(); + const page = await this.getPageRaw(pageId); + const body = page.content ? convertProseMirrorToMarkdown(page.content) : ""; + let comments: any[] = []; + try { + // Lossless export: include RESOLVED threads so the export -> import + // round-trip preserves every comment. This is exactly why the active-only + // filter is an opt-in (default false) on listComments. + comments = (await this.listComments(pageId, true)).items; + } catch (e) { + // A comments fetch failure must not lose the body; export with [] and let + // the caller see the (empty) comments block. Log under DEBUG only. + if (process.env.DEBUG) console.error("export: listComments failed", e); + } + const meta = { + version: 1, + pageId: page.id, + slugId: page.slugId, + title: page.title, + spaceId: page.spaceId, + parentPageId: page.parentPageId ?? null, + }; + return serializeDocmostMarkdown(meta, body, comments); + } + + /** + * Import a self-contained Docmost markdown file back into a page. Parses out + * the meta + comments metadata blocks, converts the body to ProseMirror + * (restoring comment marks + diagrams from their inline HTML), and replaces + * the page content. Comment THREAD records are NOT written to the server in + * this version — they are preserved in the file and the inline marks are + * re-applied so the highlights survive; managing comment records stays with + * the comment tools/UI. + */ + async importPageMarkdown(pageId: string, fullMarkdown: string): Promise { + await this.ensureAuthenticated(); + const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown); + // PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical). + const doc = await markdownToProseMirrorCanonical(body); + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + const mutation = await replacePageContent( + pageUuid, + doc, + collabToken, + this.apiUrl, + ); + // Collect distinct comment ids that actually became comment marks in the doc. + const collectCommentIds = (node: any, acc: Set): Set => { + if (!node || typeof node !== "object") return acc; + if (Array.isArray(node.marks)) { + for (const mk of node.marks) { + if (mk && mk.type === "comment" && mk.attrs?.commentId) { + acc.add(mk.attrs.commentId); + } + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) collectCommentIds(child, acc); + } + return acc; + }; + // Count reflects the comment marks present in the written document, so an id + // that only appears as inert text (e.g. inside a fenced code block) is not + // counted because it never becomes a comment mark. + const anchoredIds = collectCommentIds(doc, new Set()); + const result: any = { + success: true, + pageId, + anchoredCommentCount: anchoredIds.size, + commentsInFile: Array.isArray(comments) ? comments.length : 0, + verify: mutation.verify, + }; + // Warn (non-fatal) if the file was exported from a DIFFERENT page. + if (meta?.pageId && meta.pageId !== pageId) { + result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`; + } + // Non-fatal footnote diagnostics (#166), analyzed on the BODY (the part after + // the docmost:meta / docmost:comments blocks) — so a `[^x]`-like token inside + // those JSON blocks never produces a false warning, while real markers in the + // body do. `body` comes from parseDocmostMarkdown(fullMarkdown) above. + Object.assign(result, footnoteWarningsField(body)); + return result; + } + + /** + * Rename a page (change its title only) without touching or resending its + * content. The slug is derived from the page record, not the body, so it is + * left intact too. + */ + + /** + * Copy the WHOLE content of one page onto another, entirely server-side: the + * source's ProseMirror document is read and written verbatim onto the target + * via the live collab path, so the document never passes through the model. + * + * Only the target's BODY is replaced — its title and slug live on the page + * record (not in the content), so they are untouched. The source page is not + * modified at all. + */ + async copyPageContent(sourcePageId: string, targetPageId: string) { + await this.ensureAuthenticated(); + + // A self-copy would be a no-op overwrite; reject it explicitly so a caller + // mistake surfaces as a clear error rather than a silent round-trip. + if (sourcePageId === targetPageId) { + throw new Error( + "copyPageContent: sourcePageId and targetPageId are the same page (no-op copy)", + ); + } + + const source = await this.getPageRaw(sourcePageId); + const content = source?.content; + if ( + !content || + typeof content !== "object" || + content.type !== "doc" || + !Array.isArray(content.content) + ) { + throw new Error( + `copyPageContent: source page ${sourcePageId} has no usable ProseMirror content to copy`, + ); + } + + // Defense-in-depth: run the same URL-scheme sanitizer the JSON write path + // uses, so copying never lands a javascript:/data: href/src on the target + // (parity with updatePageJson; harmless for already-stored source content). + this.validateDocUrls(content); + + // Defense-in-depth (#228): this is a FULL-document write, so canonicalize + // footnotes before copying — a no-op on already-canonical source content, but + // it guarantees a copy can never propagate a non-canonical footnote topology + // to the target (parity with the other full-doc write paths). + // #419: normalize + merge glyph-forked definitions before canonicalizing. + const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content)); + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the TARGET collab doc by its canonical UUID, never the slugId (#260). + const targetUuid = await this.resolvePageId(targetPageId); + const mutation = await this.replacePage( + targetUuid, + canonical, + collabToken, + this.apiUrl, + ); + + return { + success: true, + sourcePageId, + targetPageId, + copiedNodes: canonical.content.length, + verify: mutation.verify, + }; + } + + /** + * Surgical text edits: find/replace inside text nodes of the live + * document. Preserves all block ids, marks, callouts and tables. + */ + + // --- Page history / diff / transform --- + + /** + * List the saved versions (history snapshots) of a page, newest first. + * Docmost auto-snapshots on every save. Returns one cursor-paginated page of + * results: `{ items, nextCursor }`. The history record's id field is `id`. + */ + async listPageHistory(pageId: string, cursor?: string) { + await this.ensureAuthenticated(); + const payload: Record = { pageId }; + if (cursor) payload.cursor = cursor; + const response = await this.client.post("/pages/history", payload); + const data = response.data?.data ?? response.data; + return { + items: data?.items ?? [], + nextCursor: data?.meta?.nextCursor ?? null, + }; + } + + /** + * Fetch a single page-history version including its lossless ProseMirror + * `content`. The version also carries pageId/title/createdAt. + */ + async getPageHistory(historyId: string) { + await this.ensureAuthenticated(); + const response = await this.client.post("/pages/history/info", { + historyId, + }); + return response.data?.data ?? response.data; + } + + /** + * "Restore" a version: Docmost has NO restore endpoint, so we take the + * version's `content` and write it as the page's current content via the live + * collab path (which itself creates a new history snapshot). Returns the + * affected pageId and the source historyId. + */ + async restorePageVersion(historyId: string) { + await this.ensureAuthenticated(); + const version = await this.getPageHistory(historyId); + if ( + !version || + !version.pageId || + !version.content || + typeof version.content !== "object" + ) { + throw new Error( + `restorePageVersion: history ${historyId} has no usable content`, + ); + } + // Defense-in-depth: sanitize URLs in the restored content (parity with the + // JSON write path) before writing it back. + this.validateDocUrls(version.content); + const collabToken = await this.getCollabTokenWithReauth(); + // version.pageId is the page entity id (already a UUID); resolvePageId + // short-circuits a UUID with no round-trip, so this is defensive only (#260). + const pageUuid = await this.resolvePageId(version.pageId); + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + () => version.content, + ); + return { + pageId: version.pageId, + restoredFrom: historyId, + verify: mutation.verify, + }; + } + + /** + * Diff two versions of a page and return a Docmost-equivalent change set. + * `from`/`to` each resolve to a ProseMirror doc: + * - null / undefined / "current" -> the page's CURRENT content; + * - any other string -> that historyId's content. + * Returns the diff plus the resolved version metadata for each side. + */ + async diffPageVersions(pageId: string, from?: string, to?: string) { + await this.ensureAuthenticated(); + + const isCurrent = (v?: string) => v == null || v === "" || v === "current"; + + const resolveSide = async ( + v?: string, + ): Promise<{ doc: any; meta: any }> => { + if (isCurrent(v)) { + const raw = await this.getPageRaw(pageId); + return { + doc: raw.content || { type: "doc", content: [] }, + meta: { + kind: "current", + pageId, + title: raw.title, + updatedAt: raw.updatedAt, + }, + }; + } + const version = await this.getPageHistory(v as string); + return { + doc: version.content || { type: "doc", content: [] }, + meta: { + kind: "history", + historyId: version.id, + pageId: version.pageId, + title: version.title, + createdAt: version.createdAt, + }, + }; + }; + + const fromSide = await resolveSide(from); + const toSide = await resolveSide(to); + const diff = diffDocs(fromSide.doc, toSide.doc); + return { from: fromSide.meta, to: toSide.meta, diff }; + } + + /** + * Edit a page by running an arbitrary user-supplied JS transform against the + * live document, with a diff preview + page-history safety net. + * + * The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm + * sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals, + * a 5s timeout, and NO access to require/process/fs/network. It must return a + * `{ type: "doc" }` node, which is validated structurally before any write. + * + * `ctx` exposes: + * - comments: the page's comments (fetched before the live read); + * - log: an array the transform can push diagnostics to (via console.log); + * - consume(id): mark a comment id as consumed (for deleteComments); + * - helpers: the transforms.ts primitives + commentsToFootnotes. + * + * Footnote convention used by the helpers: footnote markers are plain "[N]" + * text in the body, and the notes are an orderedList under a heading whose + * text is "Примечания переводчика". + * + * dryRun (default true): read the page's current content, run the transform, + * and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket. + * Otherwise the transform runs atomically inside mutatePageContent, optionally + * deletes consumed comments, and returns the new historyId + diff + log. + */ + } + return PagesMixin; +} diff --git a/packages/mcp/src/client/read.ts b/packages/mcp/src/client/read.ts new file mode 100644 index 00000000..9f8c4a11 --- /dev/null +++ b/packages/mcp/src/client/read.ts @@ -0,0 +1,651 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import axios, { AxiosInstance } from "axios"; +import { + filterWorkspace, + filterSpace, + filterPage, + filterComment, + filterSearchResult, +} from "../lib/filters.js"; +import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js"; +import { + collectInternalFileNodes, + normalizeFileUrl, + resolveInternalFilePath, +} from "../lib/internal-file-urls.js"; +import { buildPageTree } from "../lib/tree.js"; +import { + replaceNodeById, + replaceNodeByIdWithMany, + reassignCollidingBlockIds, + deleteNodeById, + assertUnambiguousMatch, + insertNodeRelative, + insertNodesRelative, + blockPlainText, + buildOutline, + getNodeByRef, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + findInvalidNode, +} from "@docmost/prosemirror-markdown"; +import { + importMarkdownFragment, + canBeDocChild, + findUnrepresentableTableAttrs, +} from "../lib/markdown-fragment.js"; +import { searchInDoc, SearchOptions } from "../lib/page-search.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; + +// Public method surface of ReadMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements IReadMixin` fails to compile on drift. +export interface IReadMixin { + getWorkspace(): any; + getSpaces(): any; + listPages(spaceId?: string, limit?: number, tree?: boolean): any; + getTree(spaceId: string, rootPageId?: string, maxDepth?: number): any; + getPageContext(pageId: string): any; + listSidebarPages(spaceId: string, pageId?: string): any; + getPage(pageId: string): any; + getPageJson(pageId: string): any; + getOutline(pageId: string): any; + getNode(pageId: string, nodeId: string, format?: "markdown" | "json"): any; + searchInPage(pageId: string, query: string, opts?: SearchOptions): any; + getTable(pageId: string, tableRef: string): any; + search(query: string, spaceId?: string, limit?: number, opts?: { parentPageId?: string; titleOnly?: boolean }): any; +} + +export function ReadMixin>(Base: TBase): GConstructor & TBase { + abstract class ReadMixin extends Base implements IReadMixin { + async getWorkspace() { + await this.ensureAuthenticated(); + const response = await this.client.post("/workspace/info", {}); + return { + data: filterWorkspace(response.data?.data ?? response.data), + success: response.data.success, + }; + } + + + async getSpaces() { + const spaces = await this.paginateAll("/spaces", {}); + return spaces.map((space) => filterSpace(space)); + } + + /** + * List pages in one of two modes. + * + * Default (`tree` false): most recent pages by updatedAt (descending), + * bounded. Fetching the whole space can exceed MCP response/time limits on + * large instances, so a single bounded page of results is returned (default + * 50, max 100) via the `/pages/recent` feed. + * + * Tree (`tree` true): DEPRECATED — prefer `getTree`, which shares this exact + * code path (a single `/pages/tree` request via `enumerateSpacePages` + + * `buildPageTree`) but returns the compact `{pageId, title, children?, + * hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is + * kept for backward compatibility; it REQUIRES `spaceId` (a page tree is + * scoped to one space) and IGNORES `limit` — the whole hierarchy is returned. + * It fetches the tree via `enumerateSpacePages`, which on the fork server + * resolves to a single `/pages/tree` request returning the whole + * permission-filtered flat page set (soft-deleted pages excluded + * server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for + * stock upstream servers that lack `/pages/tree`. + */ + async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) { + await this.ensureAuthenticated(); + + if (tree) { + if (!spaceId) { + throw new Error( + "listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.", + ); + } + const { pages } = await this.enumerateSpacePages(spaceId); + return buildPageTree(pages); + } + + const clampedLimit = Math.max(1, Math.min(100, limit)); + const payload: Record = { limit: clampedLimit, page: 1 }; + if (spaceId) payload.spaceId = spaceId; + const response = await this.client.post("/pages/recent", payload); + const data = response.data; + const items = data.data?.items || data.items || []; + return items.map((page: any) => filterPage(page)); + } + + /** + * Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE + * request — the #443 `getTree` tool. Shares its whole code path with + * `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree` + * (with the cursor-BFS only as a fallback for stock upstream servers that lack + * the endpoint), then `buildPageTree` nests the flat, permission-filtered, + * position-ordered list. No second tree fetch, no per-node BFS. + * + * - `rootPageId` — restrict to that page's subtree; the server seeds the CTE + * with the page itself, so the result is exactly ONE root (the page and its + * descendants). Omit it for the whole space. + * - `maxDepth` — trim the response to that many levels (roots = depth 1) to + * save tokens; the server still returns everything in one request, the cut + * is applied in `buildPageTree` AFTER the full tree is built. A node whose + * children were cut carries `hasChildren: true` (source of truth = the flat + * item's server `hasChildren`) so the caller can descend with a follow-up + * `getTree(spaceId, rootPageId=that node)` call. + * + * Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID + * `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId` + * (a page tree is scoped to one space). + */ + async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) { + await this.ensureAuthenticated(); + if (!spaceId) { + throw new Error( + "getTree: spaceId is required (a page tree is scoped to one space).", + ); + } + const { pages } = await this.enumerateSpacePages(spaceId, rootPageId); + return buildPageTree(pages, { shape: "getTree", maxDepth }); + } + + /** + * "Where am I / what's around" for a single page — the #443 `getPageContext` + * tool. Metadata only (no page content), using exactly TWO server requests: + * + * 1. `POST /pages/breadcrumbs` — a recursive CTE that walks UP from the page. + * The server returns the chain root->page order (it `.reverse()`s the + * child-first walk before responding), INCLUDING the page itself as the + * LAST element. So the last element is the page and everything before it + * is the ancestor chain root->parent. This carries the page's own title + * and spaceId, so no extra page-info fetch is needed for a UUID input. + * 2. `listSidebarPages(spaceId, pageId)` — the page's DIRECT children, + * cursor-paginated (a page with >20 children returns ALL of them, no + * dupes) and in sidebar `position` order, each carrying `hasChildren`. + * + * The input may be a slugId (agents copy them from URLs); it is run through + * `resolvePageId` first, exactly like the other page tools. A UUID input adds + * no request there (short-circuit), keeping the total at two; a slugId input + * adds one unavoidable resolve round-trip. + * + * INVARIANT: only the UUID `pageId` is exposed anywhere — server `id` is + * mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible + * pageId makes the server 404/403, which propagates as a clear tool error + * (never a hollow empty object). + */ + async getPageContext(pageId: string) { + await this.ensureAuthenticated(); + + // Resolve a possibly-slugId input to the canonical UUID (no round-trip for a + // UUID). Errors here (bad/inaccessible id) propagate as a clear tool error. + const pageUuid = await this.resolvePageId(pageId); + + // Request 1: the ancestor chain, root->page, page included as the LAST item. + const response = await this.client.post("/pages/breadcrumbs", { + pageId: pageUuid, + }); + const chain: any[] = (response.data?.data ?? response.data) ?? []; + if (!Array.isArray(chain) || chain.length === 0) { + // The endpoint always includes the page itself, so an empty chain means + // the page is gone/inaccessible — surface a clear error, not {}. + throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`); + } + + // Split: the last element is the page, the rest (root->parent) are the + // breadcrumbs. A root page has no ancestors -> breadcrumbs is []. + const self = chain[chain.length - 1]; + const ancestors = chain.slice(0, -1); + + const page = { + pageId: self.id, + title: self.title, + spaceId: self.spaceId, + }; + const breadcrumbs = ancestors.map((n: any) => ({ + pageId: n.id, + title: n.title, + })); + + // Request 2: direct children in sidebar order, each with hasChildren. + const childItems = await this.listSidebarPages(self.spaceId, pageUuid); + const children = childItems.map((c: any) => ({ + pageId: c.id, + title: c.title, + hasChildren: Boolean(c.hasChildren), + })); + + return { page, breadcrumbs, children }; + } + + /** + * List sidebar pages for a space. With no pageId the request returns the + * space ROOT pages; with a pageId it returns the direct CHILDREN of that + * page. pageId is therefore optional and is only included in the POST body + * when provided (an empty/undefined pageId would otherwise change the + * semantics on the server). + */ + async listSidebarPages(spaceId: string, pageId?: string) { + await this.ensureAuthenticated(); + + // Paginate via the server-issued cursor. The server switched from OFFSET + // (`page`) to CURSOR (`cursor`/`nextCursor`) pagination, and the global + // ValidationPipe(whitelist:true) SILENTLY STRIPS the obsolete `page` field + // — so the old offset loop got the SAME first page every time (with + // hasNextPage stuck true) and dropped every child beyond the first page. + const MAX_PAGES = 50; + let cursor: string | undefined; + let allItems: any[] = []; + let truncated = false; + + for (let i = 0; i < MAX_PAGES; i++) { + // limit: 100 is the server-side Max; cuts request count 5x vs the default 20. + const payload: Record = { spaceId, limit: 100 }; + // Only send pageId when scoping to a page's children; omit it for roots. + if (pageId) payload.pageId = pageId; + if (cursor) payload.cursor = cursor; + + const data = (await this.client.post("/pages/sidebar-pages", payload)).data + ?.data; + allItems = allItems.concat(data?.items ?? []); + + // Advance strictly via the server-issued cursor; a missing/repeated cursor + // means the protocol drifted again — stop instead of looping on page one. + const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null; + if (!next || next === cursor) break; + cursor = next; + + // Reaching the ceiling with more pages still available means the child + // list is truncated (mirrors paginateAll). + if (i === MAX_PAGES - 1) truncated = true; + } + + // Warn on real truncation (ceiling hit while the server still had pages) so + // the caller is not silently handed an incomplete child list. + if (truncated) { + console.warn( + `listSidebarPages: children of "${pageId ?? spaceId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`, + ); + } + + return allItems; + } + + /** + * Enumerate EVERY page in a space (or in a subtree, when rootPageId is given). + * + * Primary path (fork server): a SINGLE `POST /pages/tree` returns the whole + * space (or a subtree) as a flat, permission-filtered list in one request, in + * the exact node shape buildPageTree consumes. This replaces the old + * per-node BFS, which issued N sidebar requests and — after the server moved + * to cursor pagination — silently lost every child past the first sidebar + * page (the obsolete `page` param was stripped by ValidationPipe). + * + * The subtree variant (rootPageId given) INCLUDES the root node itself + * (getPageAndDescendants seeds with id = rootPageId), unlike the old BFS + * which started from the root's children. + * + * Fallback path (stdio mode may target STOCK upstream Docmost, which lacks + * `/pages/tree`): on a 404/405 it falls back to the cursor-based BFS below, + * walking direct children via the fixed cursor listSidebarPages. Safeguards: + * a `visited` Set of page ids prevents re-processing a node (cycles / + * duplicate references), and a hard node cap bounds pathological trees so the + * walk always terminates. + * + * Returns `{ pages, truncated }`. `truncated` is true ONLY when the fallback + * BFS stopped at its MAX_NODES cap — the primary /pages/tree path is uncapped + * and always returns the complete set, so it never reports truncation. + */ + protected async enumerateSpacePages( + spaceId: string, + rootPageId?: string, + ): Promise<{ pages: any[]; truncated: boolean }> { + await this.ensureAuthenticated(); + + // Single request replaces the whole BFS: /pages/tree returns the full + // permission-filtered flat page set of a space (or a subtree) at once. This + // path is uncapped, so it is never truncated. + const payload = rootPageId ? { pageId: rootPageId } : { spaceId }; + try { + const response = await this.client.post("/pages/tree", payload); + const pages = (response.data?.data ?? response.data)?.items ?? []; + return { pages, truncated: false }; + } catch (e: any) { + // Only fall back when the endpoint is absent (stock upstream Docmost); + // any other error is a genuine failure and must propagate. + if ( + !axios.isAxiosError(e) || + (e.response?.status !== 404 && e.response?.status !== 405) + ) { + throw e; + } + } + + // Fallback: cursor-based breadth-first walk via listSidebarPages. + const MAX_NODES = 10000; + const result: any[] = []; + const visited = new Set(); + + // Seed with the root node itself when scoping to a subtree, so its own + // comments aren't dropped: the primary /pages/tree seeds + // getPageAndDescendants with id = rootPageId (root included), but + // listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN. + // The `visited` set below prevents a double-add if the root also appears + // among the children. getPageRaw returns a page whose id/title/spaceId are + // exactly what buildPageTree and checkNewComments consume. + if (rootPageId) { + try { + const root = await this.getPageRaw(rootPageId); + if (root?.id) { + result.push(root); + visited.add(root.id); + } + } catch { + // Non-fatal: if the root can't be read, fall through to children-only. + } + } + + // Seed the queue with the starting level (subtree children or roots). + const queue: any[] = await this.listSidebarPages(spaceId, rootPageId); + + while (queue.length > 0 && result.length < MAX_NODES) { + const node = queue.shift(); + if (!node || typeof node !== "object" || !node.id) continue; + + // Skip already-seen ids to guard against cycles / duplicate references. + if (visited.has(node.id)) continue; + visited.add(node.id); + + result.push(node); + + if (node.hasChildren) { + try { + const children = await this.listSidebarPages(spaceId, node.id); + for (const child of children) queue.push(child); + } catch (e: any) { + // A failure fetching one node's children must not abort the whole + // walk: skip this branch and keep enumerating the rest. + } + } + } + + // Truncated only when the cap was hit with the queue still non-empty (real + // truncation, not a natural end at exactly MAX_NODES). + return { + pages: result, + truncated: result.length >= MAX_NODES && queue.length > 0, + }; + } + + /** Raw page info including the ProseMirror JSON content and slugId. */ + + async getPage(pageId: string) { + await this.ensureAuthenticated(); + const resultData = await this.getPageRaw(pageId); + + // Agent read: hide resolved-comment anchors so the agent sees only active + // discussions. Active anchors are kept. (The lossless exportPageMarkdown + // round-trip deliberately does NOT pass this flag — resolved anchors there + // must be preserved.) + let content = resultData.content + ? convertProseMirrorToMarkdown(resultData.content, { + dropResolvedCommentAnchors: true, + }) + : ""; + + // Always fetch subpages to provide context to the agent + let subpages: any[] = []; + try { + // `pageId` may be a slugId, but the sidebar-pages endpoint requires the + // UUID; `resultData.id` holds the resolved UUID returned by getPageRaw. + subpages = await this.listSidebarPages(resultData.spaceId, resultData.id); + } catch (e: any) { + console.warn("Failed to fetch subpages:", e); + } + + // Resolve subpages if the placeholder exists + if (content && content.includes("{{SUBPAGES}}")) { + if (subpages && subpages.length > 0) { + const list = subpages + .map((p: any) => `- [${p.title}](page:${p.id})`) + .join("\n"); + content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`); + } else { + content = content.replace("{{SUBPAGES}}", ""); + } + } + + return { + data: filterPage(resultData, content, subpages), + success: true, + }; + } + + /** Page info + raw ProseMirror JSON content (lossless representation). */ + async getPageJson(pageId: string) { + const data = await this.getPageRaw(pageId); + return { + id: data.id, + slugId: data.slugId, + title: data.title, + parentPageId: data.parentPageId, + spaceId: data.spaceId, + updatedAt: data.updatedAt, + content: data.content || { type: "doc", content: [] }, + }; + } + + /** + * Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring. + * `src` is normalized to `/api/files//`; `this.client.baseURL` + * already ends in `/api`, so we strip the leading `/api` and request the + * relative path with the client's Authorization header. Returns the raw bytes + * and the response Content-Type (mime), defaulting to octet-stream. + * + * The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory; + * the authoritative per-blob cap is enforced by the sandbox `put`. The path is + * resolved via resolveInternalFilePath, which REJECTS (throws) any traversal + * or percent-encoded src that would let an attacker-controlled `attrs.src` + * escape `/api/files/` and reach another internal endpoint (SSRF). That throw + * happens before this.client.get, so a malicious src is counted as a failed + * mirror — it never reaches the network. + */ + + /** + * Compact outline of a page's top-level blocks (no full document body). + * Cheap way to locate sections/tables and grab block ids before drilling in + * with getNode / patchNode / insertNode. + */ + async getOutline(pageId: string) { + await this.ensureAuthenticated(); + const data = await this.getPageRaw(pageId); + return { + pageId, + slugId: data.slugId, + title: data.title, + outline: buildOutline(data.content ?? { type: "doc", content: [] }), + }; + } + + /** + * Fetch a single block for editing by reference: a block id (headings/ + * paragraphs/callouts/images), or `#` to select a top-level block by its + * outline index (the only way to reach tables/rows/cells, which carry no id). + * + * `format` (#413): + * - `"markdown"` (DEFAULT): serialize the block via the canonical converter + * (`{type:"doc",content:[node]}` -> `convertProseMirrorToMarkdown`) — a read + * "for editing": pair it with `patchNode({markdown})` to rewrite the block. + * Comment anchors (``, INCLUDING resolved ones) are + * NOT stripped here (unlike getPage): losing them on write-back would + * orphan the thread. Returns `{ ..., format:"markdown", markdown }`. + * - `"json"`: return the raw ProseMirror subtree as-is (lossless; the previous + * default). Returns `{ ..., format:"json", node }`. + * + * AUTO fallback: a type that cannot be a document top-level child + * (tableRow/tableCell/tableHeader, addressed by `#`) is NOT expressible + * as a standalone markdown document, so a `"markdown"` request for such a node + * transparently falls back to JSON with an explicit `format:"json"` field. The + * check derives from the schema's `doc` contentMatch, so it tracks the schema. + */ + async getNode( + pageId: string, + nodeId: string, + format: "markdown" | "json" = "markdown", + ) { + await this.ensureAuthenticated(); + const data = await this.getPageRaw(pageId); + const hit = getNodeByRef( + data.content ?? { type: "doc", content: [] }, + nodeId, + ); + if (!hit) { + throw new Error( + `getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#" for a top-level block such as a table)`, + ); + } + + // JSON requested (or a non-top-level type that markdown cannot represent as a + // standalone document): return the subtree verbatim. + if (format === "json" || !canBeDocChild(hit.type)) { + return { + pageId, + ref: nodeId, + path: hit.path, + type: hit.type, + format: "json" as const, + node: hit.node, + }; + } + + // Markdown: wrap the node as a one-block doc and run the canonical converter. + // Comment anchors are DELIBERATELY preserved (converter default) so a + // getNode(markdown) -> edit -> patchNode(markdown) round trip does not orphan + // a comment thread; this differs from getPage, which strips them. + const markdown = convertProseMirrorToMarkdown({ + type: "doc", + content: [hit.node], + }); + return { + pageId, + ref: nodeId, + path: hit.path, + type: hit.type, + format: "markdown" as const, + markdown, + }; + } + + /** + * Find every occurrence of `query` on a page IN MEMORY, over the plain text of + * each text container (reusing the same `getPageRaw` fetch as the other read + * tools) — no server search endpoint, no whole-document round-trip through the + * model. Returns `{ total, truncated, matches }`; each match carries a ref for + * getNode/patchNode (the `#` form resolves with getNode but NOT + * patchNode — see SearchMatch.nodeId), plus the top-level block index and a + * short context window used to build a unique text `selection` for + * createComment (createComment has no nodeId param). The pure engine + * (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine + * and the empty-query / invalid-or-unsupported-regex errors. + */ + async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) { + await this.ensureAuthenticated(); + const data = await this.getPageRaw(pageId); + const result = searchInDoc( + data.content ?? { type: "doc", content: [] }, + query, + opts, + ); + return { pageId, query, ...result }; + } + + /** + * Read a table as a matrix. `tableRef` is `#` (from getOutline) or a + * block id of any node inside the table. Returns the cell texts plus a + * parallel cellIds matrix (each cell's first paragraph id, or null) so a + * caller can patchNode a cell for rich-formatted edits. Throws when no table + * resolves for the reference. + */ + async getTable(pageId: string, tableRef: string) { + await this.ensureAuthenticated(); + const data = await this.getPageRaw(pageId); + const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef); + if (!t) { + throw new Error( + `tableGet: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, + ); + } + return { + pageId, + table: tableRef, + rows: t.rows, + cols: t.cols, + path: t.path, + cells: t.cells, + cellIds: t.cellIds, + }; + } + + /** + * Insert a row of plain-text cells into a table on the LIVE collab document. + * `tableRef` is `#` or a block id inside the target table. `cells` is + * padded to the table's column count (more cells than columns throws); `index` + * is a 0-based insert position (omit/out-of-range to append). Throws when no + * table resolves for the reference. + */ + + async search( + query: string, + spaceId?: string, + limit?: number, + opts: { parentPageId?: string; titleOnly?: boolean } = {}, + ) { + await this.ensureAuthenticated(); + // Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid + // substring + FTS branch that returns path + snippet + score. A stock + // upstream server strips these unknown DTO fields (whitelist:true) and + // silently degrades to plain FTS — see the tool-registration comment. + const payload: Record = { + query, + spaceId, + substring: true, + }; + if (opts.parentPageId) payload.parentPageId = opts.parentPageId; + if (opts.titleOnly) payload.titleOnly = true; + // Clamp an optional caller-supplied limit into the lookup range (1..50) + // before forwarding; omit it when not provided so the server default applies. + if (limit !== undefined) { + payload.limit = Math.max(1, Math.min(50, limit)); + } + const response = await this.client.post("/search", payload); + + // Normalize both response shapes: bare array and paginated { items: [...] } + const data = response.data?.data; + const items = Array.isArray(data) ? data : data?.items || []; + const filteredItems = items.map((item: any) => filterSearchResult(item)); + + return { + items: filteredItems, + success: response.data?.success || false, + }; + } + + } + return ReadMixin; +} diff --git a/packages/mcp/src/client/stash.ts b/packages/mcp/src/client/stash.ts new file mode 100644 index 00000000..2f37eb6d --- /dev/null +++ b/packages/mcp/src/client/stash.ts @@ -0,0 +1,225 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { + collectInternalFileNodes, + normalizeFileUrl, + resolveInternalFilePath, +} from "../lib/internal-file-urls.js"; + +// Public method surface of StashMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements IStashMixin` fails to compile on drift. +export interface IStashMixin { + stashPage(pageId: string): Promise<{ uri: string; sha256: string; size: number; images: { mirrored: number; failed: number }; }>; +} + +export function StashMixin>(Base: TBase): GConstructor & TBase { + abstract class StashMixin extends Base implements IStashMixin { + /** + * Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring. + * `src` is normalized to `/api/files//`; `this.client.baseURL` + * already ends in `/api`, so we strip the leading `/api` and request the + * relative path with the client's Authorization header. Returns the raw bytes + * and the response Content-Type (mime), defaulting to octet-stream. + * + * The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory; + * the authoritative per-blob cap is enforced by the sandbox `put`. The path is + * resolved via resolveInternalFilePath, which REJECTS (throws) any traversal + * or percent-encoded src that would let an attacker-controlled `attrs.src` + * escape `/api/files/` and reach another internal endpoint (SSRF). That throw + * happens before this.client.get, so a malicious src is counted as a failed + * mirror — it never reaches the network. + */ + protected async fetchInternalFile( + src: string, + ): Promise<{ buffer: Buffer; mime: string }> { + const HARD_CEILING = 64 * 1024 * 1024; // 64 MiB memory guard + const relPath = resolveInternalFilePath(src); + const response = await this.client.get(relPath, { + responseType: "arraybuffer", + timeout: 30000, + maxContentLength: HARD_CEILING, + maxBodyLength: HARD_CEILING, + }); + const buffer = Buffer.from(response.data); + if (buffer.length === 0) { + throw new Error(`Empty file response from "${src}"`); + } + const rawCt = response.headers?.["content-type"]; + const mime = + typeof rawCt === "string" && rawCt.length > 0 + ? rawCt.split(";")[0].trim().toLowerCase() + : "application/octet-stream"; + return { buffer, mime }; + } + + /** + * Stash a page's full content into the in-RAM blob sandbox and return ONLY a + * short anonymous URL — the body never enters the model context (this is the + * whole point: ~30KB+ ProseMirror docs blow the model context if passed as a + * tool argument). Every INTERNAL file/image src (the type-agnostic criterion, + * so drawio/excalidraw/video/file nodes are covered too) is mirrored into the + * sandbox and its `src` rewritten to the sandbox URL, so an external consumer + * can fetch the images anonymously. External http(s) srcs are left untouched. + * + * Blobs live in RAM with a short TTL and are cleared on restart — consume the + * URLs within the TTL and one uptime. A failed image fetch never aborts the + * doc: the original src is kept and the failure counted. + * + * Returns { uri, sha256, size, images:{mirrored, failed} }. `uri` and `sha256` + * are for the document blob; `sha256` is also the blob's ETag (integrity). + */ + async stashPage(pageId: string): Promise<{ + uri: string; + sha256: string; + size: number; + images: { mirrored: number; failed: number }; + }> { + if (!this.sandboxPut) { + throw new Error( + "stashPage is unavailable: the blob sandbox is not configured on this server", + ); + } + await this.ensureAuthenticated(); + + // Stash the SAME shape getPageJson returns (id/title/.../content), with a + // deep clone so the rewrite never mutates anything shared. + const pageJson = await this.getPageJson(pageId); + const cloned: any = structuredClone(pageJson); + + // Group internal-file nodes by normalized src so each unique resource is + // fetched + stored ONCE (dedup), and every node sharing that src points at + // the one sandbox blob. Capture each node's ORIGINAL raw src per-node: + // dedup groups nodes whose normalized src is equal even when their raw srcs + // differ (e.g. `/api/files/...` vs the bare `/files/...`), so on a revert we + // must restore each node's own original value, not the group key. + const bySrc = new Map>(); + for (const node of collectInternalFileNodes(cloned.content)) { + const origSrc = String(node.attrs.src); + const src = normalizeFileUrl(origSrc); + const entry = { node, origSrc }; + const group = bySrc.get(src); + if (group) group.push(entry); + else bySrc.set(src, [entry]); + } + + let mirrored = 0; + let failed = 0; + // Record every successful mirror so it can be (a) reverted if its blob gets + // FIFO-evicted by a LATER put in this same stash, and (b) freed if the final + // doc put throws. + const mirrors: Array<{ + uri: string; + entries: Array<{ node: any; origSrc: string }>; + }> = []; + const MAX_CONCURRENCY = 5; + const groups = [...bySrc.entries()]; + for (let i = 0; i < groups.length; i += MAX_CONCURRENCY) { + const batch = groups.slice(i, i + MAX_CONCURRENCY); + await Promise.all( + batch.map(async ([src, entries]) => { + try { + const { buffer, mime } = await this.fetchInternalFile(src); + // put may throw if the blob exceeds the per-blob/total caps. + const stored = this.sandboxPut!(buffer, mime); + for (const entry of entries) entry.node.attrs.src = stored.uri; + mirrors.push({ uri: stored.uri, entries }); + mirrored++; + } catch (err) { + // One bad/oversized image (or a rejected traversal src) must not + // abort the document. Logged unconditionally (never the blob body), + // matching the package's ungated console.warn convention. + failed++; + console.warn( + `stashPage: failed to mirror "${src}": ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }), + ); + } + + // Revert one mirror's nodes to their original internal srcs and re-count it + // as failed (its blob was FIFO-evicted before the doc could reference it + // safely). + const revertMirror = (mirror: { + uri: string; + entries: Array<{ node: any; origSrc: string }>; + }) => { + for (const entry of mirror.entries) entry.node.attrs.src = entry.origSrc; + mirrored--; + failed++; + console.warn( + `stashPage: mirrored blob ${mirror.uri} was evicted before the doc ` + + `could safely reference it; reverted its src and counted it as failed`, + ); + }; + + // Pre-put reconciliation: an image put earlier in THIS stash can FIFO-evict + // an even-earlier image of the same stash. Drop those from the live set + // first so the first serialized doc is already mostly correct. + let liveMirrors = mirrors; + if (this.sandboxHas) { + liveMirrors = []; + for (const mirror of mirrors) { + if (this.sandboxHas(mirror.uri)) liveMirrors.push(mirror); + else revertMirror(mirror); + } + } + + // Put the document, then reconcile against eviction caused by the doc put + // ITSELF (the doc is newest, FIFO drops oldest = this stash's images). Each + // iteration reverts >=1 mirror, so the loop terminates (worst case: all + // images reverted and the doc references no sandbox image URLs). + let stored: { uri: string; sha256: string; size: number }; + for (;;) { + const docBuf = Buffer.from(JSON.stringify(cloned), "utf8"); + let docStored: { uri: string; sha256: string; size: number }; + try { + docStored = this.sandboxPut(docBuf, "application/json"); + } catch (err) { + // The doc put failed (e.g. doc exceeds the cap). Free this op's image + // blobs instead of leaking them in RAM for the whole TTL, then + // re-throw. + if (this.sandboxEvict) { + for (const mirror of liveMirrors) this.sandboxEvict(mirror.uri); + } + throw err; + } + + if (!this.sandboxHas) { + stored = docStored; + break; + } + const evictedNow = liveMirrors.filter((m) => !this.sandboxHas!(m.uri)); + if (evictedNow.length === 0) { + stored = docStored; + break; + } + // The doc we just stored references now-dead blobs. Revert those nodes, + // drop the stale doc blob, and loop to re-serialize + re-put the + // corrected doc. + for (const mirror of evictedNow) revertMirror(mirror); + liveMirrors = liveMirrors.filter((m) => this.sandboxHas!(m.uri)); + if (this.sandboxEvict) this.sandboxEvict(docStored.uri); + } + return { + uri: stored.uri, + sha256: stored.sha256, + size: stored.size, + images: { mirrored, failed }, + }; + } + + /** + * Compact outline of a page's top-level blocks (no full document body). + * Cheap way to locate sections/tables and grab block ids before drilling in + * with getNode / patchNode / insertNode. + */ + } + return StashMixin; +} diff --git a/packages/mcp/src/client/tables.ts b/packages/mcp/src/client/tables.ts new file mode 100644 index 00000000..8841b62c --- /dev/null +++ b/packages/mcp/src/client/tables.ts @@ -0,0 +1,291 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { + replaceNodeById, + replaceNodeByIdWithMany, + reassignCollidingBlockIds, + deleteNodeById, + assertUnambiguousMatch, + insertNodeRelative, + insertNodesRelative, + blockPlainText, + buildOutline, + getNodeByRef, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + findInvalidNode, +} from "@docmost/prosemirror-markdown"; +import { withPageLock, isUuid } from "../lib/page-lock.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; + +// Public method surface of TablesMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements ITablesMixin` fails to compile on drift. +export interface ITablesMixin { + insertFootnote(pageId: string, anchorText: string, text: string): any; + tableInsertRow(pageId: string, tableRef: string, cells: string[], index?: number): any; + tableDeleteRow(pageId: string, tableRef: string, index: number): any; + tableUpdateCell(pageId: string, tableRef: string, row: number, col: number, text: string): any; +} + +export function TablesMixin>(Base: TBase): GConstructor & TBase { + abstract class TablesMixin extends Base implements ITablesMixin { + /** + * AUTHOR-INLINE footnote insertion. The agent supplies only WHERE + * (`anchorText`, a snippet of body text to attach the marker after) and WHAT + * (`text`, the footnote content as markdown). Numbering and the bottom + * `footnotesList` are derived deterministically server-side + * (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees, + * assigns, or edits a footnote number or the list, so it CANNOT desync. + * + * Content DEDUP: when an existing definition has the same content, its id is + * reused (one number, one definition, several references). The write is atomic + * via `mutatePageContent` (single-writer, page-locked); if the anchor text is + * not found the transform aborts with a clear error and no write happens. + */ + async insertFootnote(pageId: string, anchorText: string, text: string) { + await this.ensureAuthenticated(); + if (!anchorText || !anchorText.trim()) { + throw new Error("insertFootnote: anchorText is required"); + } + if (text == null || `${text}`.trim() === "") { + throw new Error("insertFootnote: text is required"); + } + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + let result: { footnoteId: string; reused: boolean } | null = null; + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc: any) => { + const r = insertInlineFootnote(liveDoc, { anchorText, text }); + if (!r.inserted) { + // Abort the page-locked write by throwing: mutatePageContent does not + // persist when the transform throws, so a missing anchor leaves the + // page untouched (no partial write). + throw new Error( + `insertFootnote: anchor text not found: ${JSON.stringify( + anchorText.slice(0, 80), + )}`, + ); + } + result = { footnoteId: r.footnoteId, reused: r.reused }; + return r.doc; + }, + ); + // The not-found path throws inside the transform (aborting mutatePage), so by + // here `result` is always set. + const r = result!; + return { + success: true, + modified: true, + pageId, + footnoteId: r.footnoteId, + reused: r.reused, + message: r.reused + ? "Footnote inserted (reused an existing same-content definition)." + : "Footnote inserted.", + verify: mutation.verify, + }; + } + + /** + * Page-locked write seam over collaboration.mutatePageContent. Production just + * delegates; it exists as an overridable method so the insertFootnote wrapper + * (transform abort-on-not-found + response shaping) can be unit-tested without + * standing up a live Hocuspocus collab socket. + * + * SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then- + * lock"): every write must lock and key its CollabSession by the UUID, never a + * raw slugId (#260). resolvePageId is cached/idempotent, so a caller that + * already resolved pays no extra round-trip; centralizing it here means a + * caller that reaches this seam with a raw slugId still locks correctly instead + * of silently splitting the mutex key. withPageLock also asserts the key is a + * UUID as a hard backstop. + */ + + /** + * Insert a row of plain-text cells into a table on the LIVE collab document. + * `tableRef` is `#` or a block id inside the target table. `cells` is + * padded to the table's column count (more cells than columns throws); `index` + * is a 0-based insert position (omit/out-of-range to append). Throws when no + * table resolves for the reference. + */ + async tableInsertRow( + pageId: string, + tableRef: string, + cells: string[], + index?: number, + ) { + await this.ensureAuthenticated(); + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + // Track insertion in an outer var, reset per-transform, so a collab retry + // recomputes it cleanly (mirrors insertNode's pattern). + let inserted = false; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + inserted = false; + const { doc: nd, inserted: ins } = insertTableRow( + liveDoc, + tableRef, + cells, + index, + ); + inserted = ins; + if (!inserted) return null; // table not found -> skip the write entirely + return nd; + }, + ); + + if (!inserted) { + throw new Error( + `tableInsertRow: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, + ); + } + return { + success: true, + table: tableRef, + inserted: true, + verify: mutation.verify, + }; + } + + /** + * Delete the row at 0-based `index` from a table on the LIVE collab document. + * `tableRef` is `#` or a block id inside the target table. The helper's + * out-of-range and last-row errors propagate; a missing table throws here. + */ + async tableDeleteRow(pageId: string, tableRef: string, index: number) { + await this.ensureAuthenticated(); + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + let deleted = false; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + deleted = false; + const { doc: nd, deleted: del } = deleteTableRow( + liveDoc, + tableRef, + index, + ); + deleted = del; + if (!deleted) return null; // table not found -> skip the write entirely + return nd; + }, + ); + + if (!deleted) { + throw new Error( + `tableDeleteRow: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, + ); + } + return { + success: true, + table: tableRef, + deleted: true, + verify: mutation.verify, + }; + } + + /** + * Set the plain-text content of cell `[row, col]` (0-based) in a table on the + * LIVE collab document, replacing the cell's content with a single text + * paragraph (the cell's first-paragraph id is preserved). `tableRef` is + * `#` or a block id inside the target table. The helper's out-of-range + * error propagates; a missing table throws here. + */ + async tableUpdateCell( + pageId: string, + tableRef: string, + row: number, + col: number, + text: string, + ) { + await this.ensureAuthenticated(); + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + let updated = false; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + updated = false; + const { doc: nd, updated: upd } = updateTableCell( + liveDoc, + tableRef, + row, + col, + text, + ); + updated = upd; + if (!updated) return null; // table not found -> skip the write entirely + return nd; + }, + ); + + if (!updated) { + throw new Error( + `tableUpdateCell: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, + ); + } + return { + success: true, + table: tableRef, + row, + col, + verify: mutation.verify, + }; + } + + /** + * Create a new page with title and content. + * Uses the /pages/import workaround (the only endpoint accepting content), + * then moves the page and restores the exact title: the import endpoint + * derives the title from the FILENAME and replaces spaces with + * underscores, so we explicitly re-set it via /pages/update afterwards. + */ + } + return TablesMixin; +} diff --git a/packages/mcp/src/client/transforms.ts b/packages/mcp/src/client/transforms.ts new file mode 100644 index 00000000..85830bfa --- /dev/null +++ b/packages/mcp/src/client/transforms.ts @@ -0,0 +1,232 @@ +// Auto-split from client.ts (issue #450). Mixin over the shared client context. +// Bodies are VERBATIM from the original DocmostClient; only the enclosing class +// changed to a mixin factory. See client/context.ts for the shared base. +import type { GConstructor, DocmostClientContext } from "./context.js"; +import { + updatePageContentRealtime, + replacePageContent, + markdownToProseMirror, + markdownToProseMirrorCanonical, + mutatePageContent, + assertYjsEncodable, + MutationResult, +} from "../lib/collaboration.js"; +import { diffDocs, summarizeChange } from "../lib/diff.js"; +import { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + mergeFootnoteDefinitions, +} from "../lib/transforms.js"; +import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js"; +import vm from "node:vm"; + +// Public method surface of TransformsMixin (issue #450) — a NAMED type so the factory +// return type is expressible in the emitted .d.ts (the anonymous mixin class +// carries the base's protected shared state, which would otherwise trip TS4094). +// Derived from the class below; `implements ITransformsMixin` fails to compile on drift. +export interface ITransformsMixin { + transformPage(pageId: string, transformJs: string, opts?: { dryRun?: boolean; deleteComments?: boolean }): any; +} + +export function TransformsMixin>(Base: TBase): GConstructor & TBase { + abstract class TransformsMixin extends Base implements ITransformsMixin { + /** + * Edit a page by running an arbitrary user-supplied JS transform against the + * live document, with a diff preview + page-history safety net. + * + * The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm + * sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals, + * a 5s timeout, and NO access to require/process/fs/network. It must return a + * `{ type: "doc" }` node, which is validated structurally before any write. + * + * `ctx` exposes: + * - comments: the page's comments (fetched before the live read); + * - log: an array the transform can push diagnostics to (via console.log); + * - consume(id): mark a comment id as consumed (for deleteComments); + * - helpers: the transforms.ts primitives + commentsToFootnotes. + * + * Footnote convention used by the helpers: footnote markers are plain "[N]" + * text in the body, and the notes are an orderedList under a heading whose + * text is "Примечания переводчика". + * + * dryRun (default true): read the page's current content, run the transform, + * and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket. + * Otherwise the transform runs atomically inside mutatePageContent, optionally + * deletes consumed comments, and returns the new historyId + diff + log. + */ + async transformPage( + pageId: string, + transformJs: string, + opts: { dryRun?: boolean; deleteComments?: boolean } = {}, + ) { + const dryRun = opts.dryRun ?? true; + const deleteComments = opts.deleteComments ?? false; + + await this.ensureAuthenticated(); + // Full feed (incl. resolved): a page transform (e.g. comments -> footnotes) + // must operate on every comment, so it opts into the unfiltered feed. + const comments = (await this.listComments(pageId, true)).items; + + // ctx handed to the sandbox. consume() records ids; helpers are the pure + // transform primitives. log is captured from console.log inside the sandbox. + const ctx = { + comments, + log: [] as string[], + consumed: new Set(), + consume(id: string) { + this.consumed.add(id); + }, + helpers: { + blockText, + walk, + getList, + insertMarkerAfter, + setCalloutRange, + noteItem, + mdToInlineNodes, + commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, + }, + }; + + // Captured oldDoc / newDoc for the diff (set inside runTransform). + let oldDoc: any; + let newDoc: any; + + // SYNCHRONOUS transform runner — safe to call inside mutatePageContent's + // onSynced (no await between the live read and the write). + const runTransform = (liveDoc: any): any => { + oldDoc = structuredClone(liveDoc); + const sandbox: Record = { + doc: structuredClone(liveDoc), + ctx, + structuredClone, + console: { + log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")), + }, + }; + // Wrap the provided string in parentheses so both an expression-arrow + // (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a + // fresh context with no require/process/module so the transform cannot + // touch fs/network/process. 5s wall-clock timeout. + let fn: any; + try { + fn = vm.runInNewContext("(" + transformJs + ")", sandbox, { + timeout: 5000, + }); + } catch (e: any) { + throw new Error(`transform did not compile: ${e?.message ?? e}`); + } + if (typeof fn !== "function") { + throw new Error( + "transform must evaluate to a function (doc, ctx) => doc", + ); + } + const raw = vm.runInNewContext( + "f(d, c)", + { f: fn, d: sandbox.doc, c: ctx }, + { timeout: 5000 }, + ); + if ( + !raw || + typeof raw !== "object" || + raw.type !== "doc" || + !Array.isArray(raw.content) + ) { + throw new Error( + 'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })', + ); + } + // Validate the RAW transform output FIRST (structure — including the + // MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer + // recurses without a depth limiter, so validating after it would turn a + // too-deep doc into an opaque "Maximum call stack size exceeded" instead of + // the intended "nesting exceeds the maximum depth" error. + this.validateDocStructure(raw); + this.validateDocUrls(raw); + // Auto-canonicalize footnotes after the transform (idempotent): no write + // path can leave footnotes out of order / orphaned / in a raw `[^id]` + // block. In a dryRun preview this may surface footnote edits the script + // author did not write (the canonicalizer tidied them) — that is expected. + // #419: normalize + merge glyph-forked definitions before canonicalizing. + const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw)); + newDoc = result; + return result; + }; + + if (dryRun) { + // Preview only: run against the current REST snapshot, never open the + // socket. oldDoc/newDoc are captured by runTransform. + const raw = await this.getPageRaw(pageId); + const current = raw.content || { type: "doc", content: [] }; + runTransform(current); + // Run an independent Yjs-encodability check (same sanitize + schema as the + // apply path), so the preview fails with the same descriptive error when + // the doc is not encodable instead of returning a misleadingly-green diff. + assertYjsEncodable(newDoc); + return { + pushed: false, + diff: diffDocs(oldDoc, newDoc), + log: ctx.log, + }; + } + + // Apply atomically against the live doc. + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + runTransform, + ); + + // Optionally delete consumed comments (best-effort; a delete failure must + // not undo the successful write). + const deletedComments: string[] = []; + if (deleteComments) { + for (const id of ctx.consumed) { + try { + await this.deleteComment(id); + deletedComments.push(id); + } catch (e) { + if (process.env.DEBUG) { + console.error(`transform: failed to delete comment ${id}:`, e); + } + } + } + } + + // Fetch the newest historyId (Docmost snapshots on the write above). + let historyId: string | null = null; + try { + const hist = await this.listPageHistory(pageId); + historyId = hist.items?.[0]?.id ?? null; + } catch (e) { + if (process.env.DEBUG) { + console.error("transform: failed to fetch history id:", e); + } + } + + return { + pushed: true, + historyId, + diff: diffDocs(oldDoc, newDoc), + deletedComments, + log: ctx.log, + verify: mutation.verify, + }; + } + } + return TransformsMixin; +}