Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 512bcba5f3 | |||
| 14d7b21df0 | |||
| 9a8671c3af |
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
#
|
||||
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
||||
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
||||
# go over the collaboration websocket and are serialized PER PAGE by an
|
||||
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
||||
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
||||
#
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
||||
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
||||
# two replicas can each "hold" the lock for the same page at the same time and
|
||||
# serialization is silently lost (concurrent full-document writes race on the
|
||||
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
||||
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
||||
# same constraint applies to the RAM-only stash_page blob store above. There is
|
||||
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
||||
# CONSCIOUS documented constraint, not an oversight (#449).
|
||||
#
|
||||
# To reduce connect-storms the write path caches ONE live collab session per
|
||||
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
||||
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
||||
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
||||
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
||||
#
|
||||
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
||||
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
||||
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
||||
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
||||
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
||||
# the session is torn down and the next write re-auths with a fresh token, picking
|
||||
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
||||
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
||||
# stale-rights window. There is intentionally no push-based cache invalidation on
|
||||
# a rights change — this bounded window is the accepted trade-off (#449).
|
||||
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
||||
#
|
||||
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
||||
# content + images to an external consumer WITHOUT bloating the model context or
|
||||
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
||||
|
||||
@@ -62,38 +62,6 @@ jobs:
|
||||
needs: [test, e2e-server, e2e-mcp, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Image boot-smoke (issue #476): every other job tests code from the working
|
||||
# tree, but the :develop IMAGE that watchtower pulls was never actually
|
||||
# started anywhere (incident classes #353/#452/#361-boot: startup-migrator
|
||||
# crash-loop, runtime module missing from the image, wrong static-asset
|
||||
# headers). The services below back a smoke boot of the exact image right
|
||||
# before it is pushed; a smoke failure blocks the push.
|
||||
services:
|
||||
postgres:
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -114,37 +82,6 @@ jobs:
|
||||
id: version
|
||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Load the image into the local docker daemon so it can be booted (the
|
||||
# push step below exports straight to the registry and leaves nothing
|
||||
# runnable locally). CONVENTION: build-args here must stay TEXTUALLY
|
||||
# IDENTICAL to the push step's build-args — same cache scope + same args
|
||||
# means the layers are reused and the image we smoke IS the image we push.
|
||||
- name: Build image for smoke (load, no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
load: true
|
||||
push: false
|
||||
tags: gitmost:smoke
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
|
||||
# Boot-smoke the exact image against the job services (see the comment on
|
||||
# `services:` above): health (startup migrator), auth/setup, client dist
|
||||
# served, immutable + brotli asset headers. Fails the job (and therefore
|
||||
# the push) on any miss.
|
||||
- name: Smoke the built image
|
||||
run: bash scripts/ci/image-smoke.sh gitmost:smoke
|
||||
|
||||
# The smoke script leaves the container running on failure precisely so
|
||||
# the boot error (migration mismatch, stack trace) is diagnosable here.
|
||||
- name: Dump smoke container log on failure
|
||||
if: failure()
|
||||
run: docker logs gitmost-smoke 2>&1 | tail -200 || true
|
||||
|
||||
- name: Build and push develop image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
||||
+13
-41
@@ -25,65 +25,37 @@ jobs:
|
||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||
# the PR so the migration is renamed to a current timestamp before merge.
|
||||
# Runs for pull_request (diff against the base branch) AND for push (#476
|
||||
# retrospective: a DIRECT push to develop used to bypass this PR-only gate
|
||||
# entirely — now the push is diffed against its `before` SHA; workflow_call
|
||||
# from develop.yml inherits the caller's push event). workflow_dispatch has
|
||||
# nothing to diff against and still skips the job.
|
||||
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||
# runs for pull_request events (needs a base branch to diff against).
|
||||
migration-order:
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout (full history for the base diff)
|
||||
- name: Checkout (full history for the base-branch diff)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Added migrations must sort after the newest on the base
|
||||
- name: Added migrations must sort after the newest on the base branch
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.base_ref }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIG_DIR="apps/server/src/database/migrations"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||
# break the merge-base when the base has moved ahead of the PR merge —
|
||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||
BASE="origin/${TARGET_BRANCH}"
|
||||
else
|
||||
# push event: compare against the pre-push tip of the branch.
|
||||
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "::notice::branch creation push — nothing to compare"
|
||||
exit 0
|
||||
fi
|
||||
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||
# The before-SHA is not in the clone (a force-push rewrote history).
|
||||
# One recovery attempt — refresh every remote head (cheap: the
|
||||
# checkout is already fetch-depth:0); a fetch failure aborts via
|
||||
# `set -e`, which is fail-closed too.
|
||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
fi
|
||||
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||
# FAIL-CLOSED: without the before-SHA there is no base to prove the
|
||||
# ordering against, and a gate whose job is to BLOCK must not guess.
|
||||
echo "::error::force-push detected — verify migration order manually, then re-run via workflow_dispatch"
|
||||
exit 1
|
||||
fi
|
||||
BASE="$BEFORE_SHA"
|
||||
fi
|
||||
newest_on_target=$(git ls-tree -r --name-only "$BASE" "$MIG_DIR" | sort | tail -1)
|
||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||
# break the merge-base when the base has moved ahead of the PR merge —
|
||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||
# `set -e` above already aborts on a non-zero diff exit.
|
||||
added=$(git diff --diff-filter=A --name-only "${BASE}...HEAD" -- "$MIG_DIR")
|
||||
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||
bad=0
|
||||
for f in $added; do
|
||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||
echo "::error::Migration $f sorts at or before the newest on the base ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||
bad=1
|
||||
fi
|
||||
done
|
||||
|
||||
+10
@@ -81,4 +81,14 @@ VOLUME ["/app/data/storage"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
|
||||
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
|
||||
# stash_page blob store + cached collab sessions are RAM-only and process-local.
|
||||
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
|
||||
# sessions silently breaks per-page write serialization (two replicas can lock
|
||||
# the same page at once) and makes stash_page blobs unreachable across replicas.
|
||||
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
|
||||
# sessions / consistent hashing on page id). There is deliberately no
|
||||
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
|
||||
# collaboration write path" block) and packages/mcp/README.md for details.
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
+2
-9
@@ -121,20 +121,13 @@ export default function AiMcpServerForm({
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const headers = resolveHeaders();
|
||||
|
||||
// An empty tag field means "no restriction" and must be sent as null —
|
||||
// since #476 the server persists a literal `[]` as deny-all (zero tools),
|
||||
// so an empty array from this form would silently disable every tool of
|
||||
// the server. Deny-all remains expressible via the API, not via this form.
|
||||
const toolAllowlist =
|
||||
values.toolAllowlist.length === 0 ? null : values.toolAllowlist;
|
||||
|
||||
if (isEdit && server) {
|
||||
const payload: IAiMcpServerUpdate = {
|
||||
id: server.id,
|
||||
name: values.name,
|
||||
transport: values.transport,
|
||||
url: values.url,
|
||||
toolAllowlist,
|
||||
toolAllowlist: values.toolAllowlist,
|
||||
// Always sent: a blank value clears the stored guidance (server -> null).
|
||||
instructions: values.instructions,
|
||||
enabled: values.enabled,
|
||||
@@ -147,7 +140,7 @@ export default function AiMcpServerForm({
|
||||
name: values.name,
|
||||
transport: values.transport,
|
||||
url: values.url,
|
||||
toolAllowlist,
|
||||
toolAllowlist: values.toolAllowlist,
|
||||
// Blank => server stores null (no guidance).
|
||||
instructions: values.instructions,
|
||||
enabled: values.enabled,
|
||||
|
||||
@@ -27,9 +27,7 @@ export interface IAiMcpServerCreate {
|
||||
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
||||
// never returned.
|
||||
headers?: Record<string, string>;
|
||||
// Omit/null => no restriction; `[]` is persisted verbatim and means
|
||||
// deny-all (zero tools) since #476.
|
||||
toolAllowlist?: string[] | null;
|
||||
toolAllowlist?: string[];
|
||||
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
@@ -45,9 +43,7 @@ export interface IAiMcpServerUpdate {
|
||||
transport?: McpTransport;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||
// and means deny-all (zero tools) since #476.
|
||||
toolAllowlist?: string[] | null;
|
||||
toolAllowlist?: string[];
|
||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -37,13 +37,10 @@ export class CreateMcpServerDto {
|
||||
@IsObject()
|
||||
headers?: Record<string, string>;
|
||||
|
||||
// Omit/null => no restriction; `[]` is persisted verbatim and means deny-all
|
||||
// (zero tools) since #476. @IsOptional() skips validation for null as well,
|
||||
// so an explicit null is accepted.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
toolAllowlist?: string[] | null;
|
||||
toolAllowlist?: string[];
|
||||
|
||||
// Admin-authored guidance ("how/when to use this server's tools") injected
|
||||
// into the agent system prompt next to the tool descriptions (#180). Trusted,
|
||||
|
||||
@@ -38,13 +38,10 @@ export class UpdateMcpServerDto {
|
||||
@IsObject()
|
||||
headers?: Record<string, string>;
|
||||
|
||||
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||
// and means deny-all (zero tools) since #476. @IsOptional() skips validation
|
||||
// for null as well, so an explicit null is accepted.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
toolAllowlist?: string[] | null;
|
||||
toolAllowlist?: string[];
|
||||
|
||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared
|
||||
// (stored as null by the repo). Capped to bound prompt/token size.
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { type Tool } from 'ai';
|
||||
import { McpClientsService } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* Tool-allowlist filtering semantics on the merged external toolset (#476).
|
||||
*
|
||||
* COVERAGE CHOICE (documented per issue #476): the full corrupt-row chain
|
||||
* (DB value -> repo normalizeRow -> toolsFor filter) is covered on TWO levels
|
||||
* instead of one live-stub-MCP-server integration test:
|
||||
* (a) apps/server/test/integration/ai-mcp-server-repo.int-spec.ts pins the
|
||||
* repo read/write semantics against a real Postgres — `[]` round-trips
|
||||
* as jsonb `[]`, a present-but-corrupt value fails CLOSED to `[]` with
|
||||
* an error log;
|
||||
* (b) THIS spec pins what the toolset builder does with the repo's output —
|
||||
* null = unrestricted, `['alpha']` = only alpha, `[]` (including the
|
||||
* corrupt-row fallback) = ZERO tools.
|
||||
* Together they prove the end-to-end property "corrupt/empty allowlist can
|
||||
* never widen to all tools" without a live stub HTTP MCP server.
|
||||
*
|
||||
* The drive path mirrors mcp-namespacing.spec.ts: stub the repo's listEnabled,
|
||||
* spy the private `connect` to return a fake client, inspect the merged keys.
|
||||
*/
|
||||
|
||||
function fakeTool(): Tool {
|
||||
return { description: 'x', inputSchema: undefined } as unknown as Tool;
|
||||
}
|
||||
|
||||
interface FakeServer {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
url: string;
|
||||
headersEnc: string | null;
|
||||
toolAllowlist: string[] | null;
|
||||
}
|
||||
|
||||
function server(
|
||||
over: Partial<FakeServer> & { id: string; name: string },
|
||||
): FakeServer {
|
||||
return {
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
headersEnc: null,
|
||||
toolAllowlist: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a service whose repo returns `servers` and whose fake clients expose
|
||||
* `rawTools` from tools(). Returns the merged tool keys produced by toolsFor.
|
||||
*/
|
||||
async function mergedKeysFor(
|
||||
servers: FakeServer[],
|
||||
rawTools: Record<string, Tool>,
|
||||
): Promise<string[]> {
|
||||
const repoStub = {
|
||||
listEnabled: jest.fn().mockResolvedValue(servers),
|
||||
};
|
||||
const service = new McpClientsService(repoStub as never, {} as never);
|
||||
|
||||
jest
|
||||
.spyOn(
|
||||
service as unknown as { connect: (s: FakeServer) => unknown },
|
||||
'connect',
|
||||
)
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
tools: () => Promise.resolve(rawTools),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
);
|
||||
|
||||
const toolset = await service.toolsFor('ws-1');
|
||||
// Release the lease so the service does not hold the fake clients open.
|
||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||
return Object.keys(toolset.tools);
|
||||
}
|
||||
|
||||
describe('external MCP tool-allowlist filtering (via toolsFor, #476)', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const RAW = () => ({
|
||||
alpha: fakeTool(),
|
||||
beta: fakeTool(),
|
||||
gamma: fakeTool(),
|
||||
});
|
||||
|
||||
it("['alpha'] lets ONLY alpha through", async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: ['alpha'] })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual(['srv_alpha']);
|
||||
});
|
||||
|
||||
it('null (no restriction) lets every tool through', async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: null })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys.sort()).toEqual(['srv_alpha', 'srv_beta', 'srv_gamma']);
|
||||
});
|
||||
|
||||
it('[] (deny-all) yields ZERO tools — an empty array is authoritative, not falsy (#476)', async () => {
|
||||
// This is the regression the #476 change guards: `[]` used to fall through
|
||||
// the old `allow.length > 0` check and expose ALL tools. It must expose NONE.
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: [] })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
|
||||
it('the corrupt-row fallback ([] from the repo) also yields ZERO tools (#476)', async () => {
|
||||
// The repo turns a present-but-corrupt tool_allowlist into `[]` (fail-closed,
|
||||
// see normalizeRow in ai-mcp-server.repo.ts + the int-spec); this pins that
|
||||
// the toolset builder honours that fallback as deny-all rather than allow-all.
|
||||
const corruptFallback: string[] = [];
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: corruptFallback })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
|
||||
it('allowlisted names not exposed by the server are ignored (no phantom tools)', async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[
|
||||
server({
|
||||
id: 'id-1',
|
||||
name: 'srv',
|
||||
toolAllowlist: ['alpha', 'does-not-exist'],
|
||||
}),
|
||||
],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual(['srv_alpha']);
|
||||
});
|
||||
|
||||
it('a deny-all server contributes no prompt instructions (0 tools merged)', async () => {
|
||||
const repoStub = {
|
||||
listEnabled: jest.fn().mockResolvedValue([
|
||||
{
|
||||
...server({ id: 'id-1', name: 'srv', toolAllowlist: [] }),
|
||||
instructions: 'use the tools wisely',
|
||||
},
|
||||
]),
|
||||
};
|
||||
const service = new McpClientsService(repoStub as never, {} as never);
|
||||
jest
|
||||
.spyOn(
|
||||
service as unknown as { connect: (s: FakeServer) => unknown },
|
||||
'connect',
|
||||
)
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
tools: () => Promise.resolve(RAW()),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
);
|
||||
|
||||
const toolset = await service.toolsFor('ws-1');
|
||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||
expect(Object.keys(toolset.tools)).toEqual([]);
|
||||
// mergeNamespaced reported 0 contributed tools, so no guidance is attached.
|
||||
expect(toolset.instructions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -285,13 +285,9 @@ export class McpClientsService {
|
||||
try {
|
||||
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||
// Allowlist semantics (#476): null/absent = no restriction (all tools);
|
||||
// ANY array — including `[]` — is authoritative, so an EMPTY allowlist
|
||||
// yields ZERO tools (deny-all). Do NOT add a `.length > 0` escape here:
|
||||
// that read `[]` as falsy and silently widened deny-all to allow-all
|
||||
// (the repo also fails corrupt rows closed to `[]` for the same reason).
|
||||
const allow = server.toolAllowlist;
|
||||
const picked = Array.isArray(allow) ? pick(raw, allow) : raw;
|
||||
const picked =
|
||||
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
||||
// Bound each tool's execute with a per-call total-timeout guard before
|
||||
// merging, so a single chatty-but-stuck call is aborted after the cap.
|
||||
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
|
||||
|
||||
@@ -100,8 +100,7 @@ export class McpServersService {
|
||||
transport: dto.transport,
|
||||
url: dto.url,
|
||||
headersEnc,
|
||||
// undefined => unchanged; null => no restriction; `[]` is persisted
|
||||
// verbatim and means deny-all (#476).
|
||||
// undefined => unchanged; [] / value handled by repo (empty => null).
|
||||
toolAllowlist: dto.toolAllowlist,
|
||||
// undefined => unchanged; blank => cleared (null) by the repo.
|
||||
instructions: dto.instructions,
|
||||
|
||||
@@ -27,12 +27,10 @@ import type { DocmostClientLike } from './docmost-client.loader';
|
||||
*/
|
||||
|
||||
describe('tool tier metadata (#332)', () => {
|
||||
it('core set is the documented 13 + searchInPage + insertFootnote + getTree + getPageContext (17, #443)', () => {
|
||||
expect(CORE_TOOL_KEYS).toHaveLength(17);
|
||||
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => {
|
||||
expect(CORE_TOOL_KEYS).toHaveLength(15);
|
||||
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
|
||||
expect(CORE_TOOL_SET.has('getTree')).toBe(true); // #443, promoted to core
|
||||
expect(CORE_TOOL_SET.has('getPageContext')).toBe(true); // #443, promoted to core
|
||||
// loadTools is a meta-tool, not a normal core key.
|
||||
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,14 +39,12 @@ export interface ToolCatalogEntry {
|
||||
|
||||
/**
|
||||
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage`
|
||||
* (#330) + `insertFootnote` (#410) + `getTree`/`getPageContext` (#443).
|
||||
* `searchInPage` is core because it is frequent for the editorial roles this
|
||||
* feature targets; `insertFootnote` is core so the footnote tool is NOT hidden
|
||||
* while its natural sibling `editPageText` is always active (that asymmetry is
|
||||
* exactly what pushed the agent to write literal `^[...]`). `getTree` and
|
||||
* `getPageContext` are the single-call navigation/lookup tools — core so the
|
||||
* agent never has to loadTools just to orient itself. `loadTools` is active too
|
||||
* but is not a normal tool key (it is added to activeTools separately).
|
||||
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent
|
||||
* for the editorial roles this feature targets; `insertFootnote` is core so the
|
||||
* footnote tool is NOT hidden while its natural sibling `editPageText` is always
|
||||
* active (that asymmetry is exactly what pushed the agent to write literal
|
||||
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added
|
||||
* to activeTools separately).
|
||||
*/
|
||||
export const CORE_TOOL_KEYS = [
|
||||
'searchPages',
|
||||
@@ -68,11 +66,6 @@ export const CORE_TOOL_KEYS = [
|
||||
// #410 insertFootnote — core so pinpoint citations to already-written text
|
||||
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
||||
'insertFootnote',
|
||||
// #443 getTree + getPageContext — cheap single-call navigation/lookup tools
|
||||
// (the core listPages even points to getTree); core so the agent never has
|
||||
// to loadTools just to orient itself.
|
||||
'getTree',
|
||||
'getPageContext',
|
||||
] as const;
|
||||
|
||||
/** O(1) membership test for the core tier. */
|
||||
|
||||
@@ -35,25 +35,4 @@ describe('jsonbBind', () => {
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toBeDefined();
|
||||
});
|
||||
|
||||
// preserveEmpty (#476): opts a column OUT of the empty-to-null collapse so an
|
||||
// empty container is persisted verbatim (e.g. `[]` = deny-all for
|
||||
// tool_allowlist). null stays null regardless of the flag.
|
||||
describe('preserveEmpty', () => {
|
||||
it('returns a (non-null) bind for an empty array', () => {
|
||||
const out = jsonbBind([], { preserveEmpty: true });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a (non-null) bind for an empty object', () => {
|
||||
const out = jsonbBind({}, { preserveEmpty: true });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toBeDefined();
|
||||
});
|
||||
|
||||
it('still returns null for null (null means null, flag or not)', () => {
|
||||
expect(jsonbBind(null, { preserveEmpty: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,9 +78,7 @@ export class AiMcpServerRepo {
|
||||
headersEnc: values.headersEnc ?? null,
|
||||
// jsonb column: the postgres driver would otherwise encode a JS array as
|
||||
// a Postgres array literal. Bind the JSON text and cast it to jsonb.
|
||||
// preserveEmpty (#476): `[]` is a real value here (deny-all), distinct
|
||||
// from null ("no restriction") — it must round-trip as `[]`, not null.
|
||||
toolAllowlist: jsonbBind(values.toolAllowlist, { preserveEmpty: true }),
|
||||
toolAllowlist: jsonbBind(values.toolAllowlist),
|
||||
// Plain text column: blank/whitespace-only guidance is stored as null.
|
||||
instructions: blankToNull(values.instructions),
|
||||
enabled: values.enabled ?? true,
|
||||
@@ -113,10 +111,7 @@ export class AiMcpServerRepo {
|
||||
if (patch.url !== undefined) set.url = patch.url;
|
||||
if (patch.headersEnc !== undefined) set.headersEnc = patch.headersEnc;
|
||||
if (patch.toolAllowlist !== undefined) {
|
||||
// preserveEmpty (#476): see insert — `[]` (deny-all) must not become null.
|
||||
set.toolAllowlist = jsonbBind(patch.toolAllowlist, {
|
||||
preserveEmpty: true,
|
||||
});
|
||||
set.toolAllowlist = jsonbBind(patch.toolAllowlist);
|
||||
}
|
||||
if (patch.instructions !== undefined) {
|
||||
// Blank/whitespace-only guidance clears the column (stored as null).
|
||||
@@ -163,9 +158,7 @@ export function blankToNull(value: string | null | undefined): string | null {
|
||||
* fix), so the driver hands back a string like `'["a","b"]'` rather than an
|
||||
* array. Be tolerant: normalize a JSON string to its value, then accept it only
|
||||
* if it is an array of strings; null / a non-array / unparseable value / an
|
||||
* array with a non-string element all become null. NOTE: null here only means
|
||||
* "could not parse" — the null-vs-deny-all policy decision lives in
|
||||
* normalizeRow (#476: present-but-corrupt fails CLOSED to `[]`).
|
||||
* array with a non-string element all become null (unrestricted).
|
||||
*/
|
||||
export function parseToolAllowlist(value: unknown): string[] | null {
|
||||
// Shape guard only; the legacy double-encoding self-heal lives in
|
||||
@@ -180,20 +173,17 @@ export function parseToolAllowlist(value: unknown): string[] | null {
|
||||
/**
|
||||
* Normalize a DB row so `toolAllowlist` is always `string[] | null`.
|
||||
*
|
||||
* FAIL-CLOSED (#476): a stored value that is PRESENT but cannot be parsed into
|
||||
* a string[] (corrupt JSON, a non-array, non-string elements) degrades to `[]`
|
||||
* = deny-all, so a corrupted allowlist can never silently widen to "the agent
|
||||
* gets ALL of the server's tools" (the old fail-open null). An error line is
|
||||
* logged (server id only, never the contents) so the admin can repair the row.
|
||||
* A column that is truly NULL/absent stays `null` = "no restriction".
|
||||
* FAIL-OPEN logging: a stored value that is present but cannot be parsed into a
|
||||
* string[] (corrupt JSON, a non-array, non-string elements) degrades to `null` =
|
||||
* "no restriction", so the agent silently gets ALL of the server's tools. Log
|
||||
* one line (server id only, never the contents) so that widening is not silent.
|
||||
*/
|
||||
function normalizeRow(row: AiMcpServer): AiMcpServer {
|
||||
const parsed = parseToolAllowlist(row.toolAllowlist);
|
||||
if (parsed === null && row.toolAllowlist != null) {
|
||||
logger.error(
|
||||
`Corrupt tool_allowlist for MCP server ${row.id}; failing closed (NO tools allowed) — re-save the server's allowlist to repair it`,
|
||||
logger.warn(
|
||||
`Corrupt tool_allowlist for MCP server ${row.id}; ignoring it (no tool restriction applied)`,
|
||||
);
|
||||
return { ...row, toolAllowlist: [] };
|
||||
}
|
||||
return { ...row, toolAllowlist: parsed };
|
||||
}
|
||||
|
||||
@@ -78,30 +78,18 @@ export function violatedConstraint(err: unknown): string | undefined {
|
||||
* verbatim); `::jsonb` then parses it into a real array/object. Read-side
|
||||
* parsers repair rows written the old buggy way without a migration.
|
||||
*
|
||||
* Returns `null` for null/undefined. By default it ALSO returns `null` for
|
||||
* "empty" values (an empty array, or an object with no own enumerable keys) —
|
||||
* most callers treat empty as "clear/unset", so an empty config never
|
||||
* round-trips as `[]`/`{}`.
|
||||
*
|
||||
* `preserveEmpty` (issue #476) opts a column OUT of that empty-to-null
|
||||
* normalization so `[]`/`{}` are persisted as real jsonb values. Needed where
|
||||
* empty and null mean DIFFERENT things: an empty `tool_allowlist` is
|
||||
* deny-all ("zero tools allowed"), while null is "no restriction" — collapsing
|
||||
* `[]` to null silently widened deny-all to allow-all. Deliberately an opt-in
|
||||
* flag, NOT a global change: the other jsonb callers (model_config, source)
|
||||
* keep the empty-means-unset contract.
|
||||
* Returns `null` for null/undefined and for "empty" values (an empty array, or
|
||||
* an object with no own enumerable keys) — callers treat empty as "clear/unset",
|
||||
* so an empty allowlist/config never round-trips as `[]`/`{}`.
|
||||
*/
|
||||
export function jsonbBind<T>(
|
||||
value: T | null | undefined,
|
||||
opts?: { preserveEmpty?: boolean },
|
||||
): RawBuilder<T> | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!opts?.preserveEmpty) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
} else if (typeof value === 'object') {
|
||||
if (Object.keys(value as object).length === 0) return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
} else if (typeof value === 'object') {
|
||||
if (Object.keys(value as object).length === 0) return null;
|
||||
}
|
||||
return sql<T>`${JSON.stringify(value)}::text::jsonb`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
||||
|
||||
@@ -55,11 +54,7 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
|
||||
expect(Array.isArray(found?.toolAllowlist)).toBe(true);
|
||||
});
|
||||
|
||||
// #476 (deliberate behaviour change): an empty allowlist used to be
|
||||
// normalized to SQL NULL, which downstream means "no restriction" — so an
|
||||
// admin's deny-all `[]` silently became allow-all. It must now round-trip as
|
||||
// a real jsonb `[]` (deny-all), distinct from NULL.
|
||||
it('an empty allowlist round-trips as jsonb [] (deny-all), not null (#476)', async () => {
|
||||
it('an empty allowlist is normalized to null (no restriction), not []', async () => {
|
||||
const row = await repo.insert({
|
||||
workspaceId: ws,
|
||||
name: `srv-${randomUUID()}`,
|
||||
@@ -67,27 +62,7 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
|
||||
url: 'https://example.com/mcp',
|
||||
toolAllowlist: [],
|
||||
});
|
||||
// The column holds a real (empty) jsonb ARRAY, not SQL NULL.
|
||||
expect(await jsonbTypeof(row.id)).toBe('array');
|
||||
expect((await repo.findById(row.id, ws))?.toolAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it('update to [] persists jsonb [] and update to null clears to SQL NULL (#476)', async () => {
|
||||
const row = await repo.insert({
|
||||
workspaceId: ws,
|
||||
name: `srv-${randomUUID()}`,
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
toolAllowlist: ['search'],
|
||||
});
|
||||
|
||||
// Deny-all via update: [] must survive as a real jsonb array.
|
||||
await repo.update(row.id, ws, { toolAllowlist: [] });
|
||||
expect(await jsonbTypeof(row.id)).toBe('array');
|
||||
expect((await repo.findById(row.id, ws))?.toolAllowlist).toEqual([]);
|
||||
|
||||
// Explicit clear (null) still means "no restriction" = SQL NULL.
|
||||
await repo.update(row.id, ws, { toolAllowlist: null });
|
||||
// The column is SQL NULL, so jsonb_typeof returns SQL NULL (JS null).
|
||||
expect(await jsonbTypeof(row.id)).toBeNull();
|
||||
expect((await repo.findById(row.id, ws))?.toolAllowlist).toBeNull();
|
||||
});
|
||||
@@ -117,60 +92,23 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
|
||||
expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
// #476 (deliberate behaviour change, replaces the old FAIL-OPEN pin): a
|
||||
// present-but-corrupt tool_allowlist used to degrade to null ("no
|
||||
// restriction"), silently handing the agent ALL of the server's tools. It
|
||||
// must now FAIL CLOSED to `[]` (deny-all) and log an error.
|
||||
it('FAIL-CLOSED: a present-but-corrupt tool_allowlist reads back as [] (deny-all) + error log (#476)', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
// The column is PRESENT but does not parse into a string[] — a jsonb
|
||||
// string scalar holding unparseable text (a truncated legacy write).
|
||||
const id = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
|
||||
VALUES (
|
||||
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
|
||||
to_jsonb(${'{oops'}::text)
|
||||
)
|
||||
`.execute(db);
|
||||
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
|
||||
expect(await jsonbTypeof(id)).toBe('string');
|
||||
// ...and the read degrades to [] (fail-closed deny-all), not null.
|
||||
expect((await repo.findById(id, ws))?.toolAllowlist).toEqual([]);
|
||||
// The narrowing is not silent: an error names the server id (never the
|
||||
// corrupt contents).
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Corrupt tool_allowlist for MCP server ${id}`),
|
||||
);
|
||||
expect(
|
||||
errorSpy.mock.calls.some((c) => String(c[0]).includes('{oops')),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('FAIL-CLOSED: corrupt non-array JSON (an object) also reads back as [] (#476)', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const id = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
|
||||
VALUES (
|
||||
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
|
||||
to_jsonb(${'{"not":"an array"}'}::text)
|
||||
)
|
||||
`.execute(db);
|
||||
expect(await jsonbTypeof(id)).toBe('string');
|
||||
expect((await repo.findById(id, ws))?.toolAllowlist).toEqual([]);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
it('FAIL-OPEN: a present-but-corrupt tool_allowlist reads back as null (no restriction)', async () => {
|
||||
// #185 re-review pt 8: normalizeRow's fail-open branch — the column is
|
||||
// PRESENT but does not parse into a string[] (here a jsonb string scalar
|
||||
// holding non-array JSON). The read must degrade to `null` ("no restriction"),
|
||||
// not crash. (A warn is logged with the server id; not asserted here.)
|
||||
const id = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
|
||||
VALUES (
|
||||
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
|
||||
to_jsonb(${'{"not":"an array"}'}::text)
|
||||
)
|
||||
`.execute(db);
|
||||
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
|
||||
expect(await jsonbTypeof(id)).toBe('string');
|
||||
// ...yet the read degrades to null (fail-open).
|
||||
expect((await repo.findById(id, ws))?.toolAllowlist).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+22
-2
@@ -293,8 +293,28 @@ so capable clients steer the model automatically.
|
||||
the debounced REST snapshot), then **reads → transforms → writes synchronously** in one
|
||||
tick so no remote update can interleave, and **waits for persistence acknowledgement**
|
||||
before returning.
|
||||
- **Per-page write serialization.** A per-`pageId` async mutex ensures two MCP writes to
|
||||
the same page never overlap; different pages never block each other.
|
||||
- **Per-page write serialization.** A per-`pageId` async mutex (keyed by the resolved
|
||||
page **UUID**, never a slugId) ensures two MCP writes to the same page never overlap;
|
||||
different pages never block each other. The lock helper fails fast if it is ever handed
|
||||
a non-UUID key, so a write path that forgot to resolve the id can never silently lock
|
||||
under a split key.
|
||||
|
||||
**Deploy requirement — single instance or sticky sessions.** This mutex is an
|
||||
in-process `Map`, and the cached collab sessions and the `stash_page` blob store are
|
||||
RAM-only and process-local. Behind a **multi-replica** load balancer **without sticky
|
||||
sessions**, two replicas can each "hold" the lock for the same page at once and per-page
|
||||
serialization is silently lost. Run the MCP/app as a **single instance**, or pin each
|
||||
page's traffic to one replica (sticky sessions / consistent hashing on the page id).
|
||||
There is deliberately no cross-process (e.g. Postgres advisory) lock yet — a conscious
|
||||
documented constraint. See the `Dockerfile` comment and the `MCP collaboration write
|
||||
path` block in `.env.example`.
|
||||
|
||||
**Rights-staleness window.** A cached collab session writes under the token captured at
|
||||
connect time (and the collab-token cache reuses a token for its TTL), so a **revoked**
|
||||
page access can lag by up to `MCP_COLLAB_SESSION_MAX_AGE_MS` (the hard session lifetime,
|
||||
default 10 min) before the next re-auth picks it up. Lower it to shorten the lag at the
|
||||
cost of more reconnects. This bounded window is an accepted trade-off; there is no
|
||||
push-based cache invalidation on a rights change.
|
||||
- **Transparent re-authentication.** Login uses email/password; expired tokens are
|
||||
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
||||
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
||||
|
||||
@@ -302,9 +302,27 @@ Docmost-MCP не сочетают:
|
||||
правки, которых ещё нет в дебаунс-снапшоте REST), затем **читает → трансформирует →
|
||||
пишет синхронно** в одном тике, чтобы никакое удалённое обновление не вклинилось, и
|
||||
**ждёт подтверждения сохранения** до возврата.
|
||||
- **Сериализация записи по странице.** Асинхронный мьютекс по `pageId` гарантирует, что
|
||||
две записи MCP в одну страницу никогда не пересекаются; разные страницы друг друга не
|
||||
блокируют.
|
||||
- **Сериализация записи по странице.** Асинхронный мьютекс по разрешённому **UUID**
|
||||
страницы (никогда не по slugId) гарантирует, что две записи MCP в одну страницу никогда
|
||||
не пересекаются; разные страницы друг друга не блокируют. Хелпер блокировки падает сразу
|
||||
(fail-fast), если ему передали не-UUID ключ, — путь записи, забывший разрезолвить id, не
|
||||
сможет молча взять лок под расщеплённым ключом.
|
||||
|
||||
**Требование к деплою — один инстанс или sticky-сессии.** Этот мьютекс — процесс-локальный
|
||||
`Map`, а кэш collab-сессий и хранилище `stashPage` живут только в RAM одного процесса. За
|
||||
**мультиреплика**-балансировщиком **без sticky-сессий** две реплики могут одновременно
|
||||
«держать» лок одной страницы, и сериализация по странице молча теряется. Запускайте
|
||||
MCP/приложение **одним инстансом** либо прибивайте трафик страницы к одной реплике
|
||||
(sticky-сессии / consistent hashing по id страницы). Кросс-процессной блокировки (например,
|
||||
Postgres advisory-lock) намеренно пока нет — осознанное задокументированное ограничение.
|
||||
См. комментарий в `Dockerfile` и блок `MCP collaboration write path` в `.env.example`.
|
||||
|
||||
**Окно устаревших прав.** Кэшированная collab-сессия пишет под токеном, захваченным в
|
||||
момент connect (а кэш collab-токена переиспользует токен в пределах своего TTL), поэтому
|
||||
**отозванный** доступ к странице может лагать до `MCP_COLLAB_SESSION_MAX_AGE_MS` (жёсткий
|
||||
срок жизни сессии, по умолчанию 10 мин), пока следующая переавторизация его не подхватит.
|
||||
Уменьшите значение, чтобы сократить лаг ценой большего числа переподключений. Это
|
||||
ограниченное окно — принятый trade-off; push-инвалидации кэша при смене прав нет.
|
||||
- **Прозрачная переавторизация.** Логин по email/паролю; истёкшие токены обновляются
|
||||
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
||||
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
||||
|
||||
+89
-5191
File diff suppressed because it is too large
Load Diff
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ICommentsMixin> & 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<string, any> = { 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<string, any> = {
|
||||
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<void> {
|
||||
// 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;
|
||||
}
|
||||
@@ -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.<method>` 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
|
||||
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
|
||||
// so the mixins compose into one prototype chain sharing this context.
|
||||
export type GConstructor<T = {}> = 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<string> } // 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<string>;
|
||||
// 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<string, string>,
|
||||
) => 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<string>) | 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<string>) | 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<string, string>) => 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<void> | 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<string, string>();
|
||||
|
||||
// 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.<m>`
|
||||
// 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<string> {
|
||||
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<any> {
|
||||
throw new Error("getPage not wired (missing ReadMixin)");
|
||||
}
|
||||
getPageJson(_pageId: string): Promise<any> {
|
||||
throw new Error("getPageJson not wired (missing ReadMixin)");
|
||||
}
|
||||
listComments(_pageId: string, _includeResolved?: boolean): Promise<any> {
|
||||
throw new Error("listComments not wired (missing CommentsMixin)");
|
||||
}
|
||||
deleteComment(_commentId: string): Promise<any> {
|
||||
throw new Error("deleteComment not wired (missing CommentsMixin)");
|
||||
}
|
||||
listPageHistory(_pageId: string, _cursor?: string): Promise<any> {
|
||||
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<string> {
|
||||
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<MutationResult> {
|
||||
// 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<T = any>(
|
||||
endpoint: string,
|
||||
basePayload: Record<string, any> = {},
|
||||
limit: number = 100,
|
||||
): Promise<T[]> {
|
||||
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<string, any> = {
|
||||
...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.<uuid>` — 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.<slugId>`). 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<string> {
|
||||
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.
|
||||
*/
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDocValidateMixin> & 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;
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDrawioMixin> & TBase {
|
||||
abstract class DrawioMixin extends Base implements IDrawioMixin {
|
||||
/**
|
||||
* Resolve a drawio node on a page by `attrs.id` or `#<index>` 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 "#<index>" 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 <diagram>). 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 `<mxGraphModel>` or a list of `<mxCell>` (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 "#<index>" (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 "#<index>" 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 "#<index>" — 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 "#<index>" handle. Anchor ` +
|
||||
`on a top-level block (or append) so the diagram can be re-read.`,
|
||||
);
|
||||
}
|
||||
|
||||
// The returned handle is POSITIONAL ("#<index>"): 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 "#<index>" 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<string, { x: number; y: number }> | 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;
|
||||
}
|
||||
@@ -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:
|
||||
* `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`
|
||||
* or, when the request never got a response:
|
||||
* `<METHOD> <path> failed: <code> (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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IMediaMixin> & 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/<id>/<file> 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<string> {
|
||||
const { buffer } = await this.fetchInternalFile(src);
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||
* drawio node.
|
||||
*/
|
||||
}
|
||||
return MediaMixin;
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & INodesWriteMixin> & 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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any>;
|
||||
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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IPagesMixin> & 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<string> {
|
||||
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<any> {
|
||||
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<string>): Set<string> => {
|
||||
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<string>());
|
||||
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<string, any> = { 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;
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IReadMixin> & 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<string, any> = { 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<string, any> = { 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<string>();
|
||||
|
||||
// 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/<id>/<file>`; `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 `#<index>` 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 (`<span data-comment-id>`, 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 `#<index>`) 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 "#<index>" 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 `#<index>` 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 `#<index>` (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 "#<index>" 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 `#<index>` 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<string, any> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IStashMixin> & TBase {
|
||||
abstract class StashMixin extends Base implements IStashMixin {
|
||||
/**
|
||||
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
|
||||
* `src` is normalized to `/api/files/<id>/<file>`; `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<string, Array<{ node: any; origSrc: string }>>();
|
||||
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;
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITablesMixin> & 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 `#<index>` 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 "#<index>" 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 `#<index>` 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 "#<index>" 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
|
||||
* `#<index>` 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 "#<index>" 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;
|
||||
}
|
||||
@@ -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<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITransformsMixin> & 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<string>(),
|
||||
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<string, any> = {
|
||||
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;
|
||||
}
|
||||
@@ -440,9 +440,16 @@ export class CollabSession {
|
||||
// must stay synchronous (no await). While the JS event loop is not
|
||||
// yielded, no incoming remote update can interleave, so any already-synced
|
||||
// concurrent edits are preserved in liveDoc.
|
||||
//
|
||||
// INVARIANT 1 is machine-checked: the BEGIN/END markers below delimit the
|
||||
// no-await window, and test/unit/no-await-critical-window.test.mjs scans
|
||||
// this source and FAILS if any `await` (or `for await`/`yield`) appears
|
||||
// between them. Do NOT add an await inside this block — an accidental
|
||||
// async boundary here silently reopens the clobber-live-edits race (#152).
|
||||
let newDoc: any;
|
||||
let beforeDoc: any;
|
||||
try {
|
||||
// === MUTATE-CRITICAL-WINDOW: BEGIN (no await between here and END #449) ===
|
||||
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
|
||||
if (
|
||||
!liveDoc ||
|
||||
@@ -480,6 +487,7 @@ export class CollabSession {
|
||||
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
|
||||
// end of the document on every agent write.
|
||||
applyDocToFragment(this.ydoc, newDoc);
|
||||
// === MUTATE-CRITICAL-WINDOW: END (#449) ===
|
||||
} catch (e) {
|
||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
||||
// "text not found"): propagate them verbatim to the caller.
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
|
||||
const chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). This is the SINGLE source of truth for
|
||||
// "is this a canonical page UUID?" in the MCP: client.ts's resolvePageId
|
||||
// imports isUuid from here to decide whether a pageId already IS a UUID (and so
|
||||
// needs no /pages/info round-trip). page.repo.ts treats any non-UUID pageId as
|
||||
// a slugId; a 10-char nanoid slugId never contains dashes, so it can never be
|
||||
// misread as a UUID here.
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
// The returned promise carries the real result/rejection of `fn` and MUST be
|
||||
// awaited/handled by the caller; only the internal chaining tail swallows
|
||||
// errors (purely to gate ordering).
|
||||
@@ -17,6 +31,25 @@ export function withPageLock<T>(
|
||||
pageId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
// STRUCTURAL INVARIANT (issue #449, "resolve-then-lock"): the mutex key MUST
|
||||
// be the canonical page UUID, never a raw slugId. The whole write path relies
|
||||
// on the lock key AND the CollabSession cache key being the resolved UUID
|
||||
// (#260) — if a future write method forgot to call resolvePageId and locked
|
||||
// under a slugId, two writes to the same page would take DIFFERENT mutex keys
|
||||
// and silently lose serialization (clobbering live human edits). This was an
|
||||
// invariant enforced only by comments/convention; assert it in CODE so the
|
||||
// violation fails fast and loud at the lock instead of corrupting data in
|
||||
// prod. The centralizing helper (mutatePageContent/replacePageContent) already
|
||||
// guards a raw-input caller, but this backstop catches ANY path.
|
||||
if (!isUuid(pageId)) {
|
||||
throw new Error(
|
||||
`withPageLock: key must be a canonical page UUID, got '${pageId}'. ` +
|
||||
`The write path must resolvePageId(pageId) BEFORE locking so the ` +
|
||||
`mutex/CollabSession cache key is the UUID (invariant "resolve-then-` +
|
||||
`lock", #260/#449). A slugId or other non-UUID key would silently lose ` +
|
||||
`per-page serialization.`,
|
||||
);
|
||||
}
|
||||
// Wait for the previous op on this page; swallow its error so a failure does
|
||||
// not poison the queue for the next caller.
|
||||
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => {});
|
||||
|
||||
@@ -316,8 +316,7 @@ async function main() {
|
||||
const [idA, idB, idC] = seedIds;
|
||||
|
||||
// patchNode: replace the middle paragraph; siblings' ids must be unchanged.
|
||||
// #413 XOR input: the raw ProseMirror node goes under the `node` key.
|
||||
await client.patchNode(nid, idB, { node: mkPara(idB, "Bravo PATCHED.") });
|
||||
await client.patchNode(nid, idB, mkPara(idB, "Bravo PATCHED."));
|
||||
await new Promise((r) => setTimeout(r, 16000));
|
||||
const afterPatch = (await client.getPageJson(nid)).content;
|
||||
const patchText = JSON.stringify(afterPatch);
|
||||
@@ -328,7 +327,7 @@ async function main() {
|
||||
// insertNode: place a new block after the first paragraph.
|
||||
await client.insertNode(
|
||||
nid,
|
||||
{ node: mkPara("nodeops-ins", "Inserted paragraph.") },
|
||||
mkPara("nodeops-ins", "Inserted paragraph."),
|
||||
{ position: "after", anchorNodeId: idA },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 16000));
|
||||
@@ -434,71 +433,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 6h. markdown converter fixpoint (#476): pins the converter fixpoint
|
||||
// THROUGH the live server/collab path, not just the package tests. The
|
||||
// unit corpus (docmost-md-roundtrip) proves the converter alone is a
|
||||
// fixpoint; this asserts the property survives the real pipeline — export
|
||||
// (REST read, PM -> MD) -> import (MD -> PM -> collab replace -> server
|
||||
// persistence) -> export — where the server schema, the Yjs structural
|
||||
// diff or the collab write path could still mangle the doc while every
|
||||
// unit test stays green. importPageMarkdown is the designed inverse of
|
||||
// exportPageMarkdown (the self-contained envelope with meta/comments
|
||||
// blocks); updatePageMarkdown (client.updatePage) takes plain authoring
|
||||
// markdown and would re-import the envelope blocks as literal content.
|
||||
{
|
||||
const FIXMD = [
|
||||
"# Fixpoint heading",
|
||||
"",
|
||||
"Paragraph with **bold**, *italic* and a [link](https://example.com).",
|
||||
"",
|
||||
"## Second level",
|
||||
"",
|
||||
"- bullet one",
|
||||
"- bullet two",
|
||||
"",
|
||||
"1. ordered one",
|
||||
"2. ordered two",
|
||||
"",
|
||||
"```js",
|
||||
"const answer = 42; // code block must survive byte-identically",
|
||||
"```",
|
||||
"",
|
||||
"| A | B |",
|
||||
"| --- | --- |",
|
||||
"| one | two |",
|
||||
"",
|
||||
":::info",
|
||||
"Callout body.",
|
||||
":::",
|
||||
].join("\n");
|
||||
const fx = await client.createPage("E2E md fixpoint " + Date.now(), FIXMD, spaceId);
|
||||
const fxid = fx.data.id;
|
||||
try {
|
||||
const md1 = await client.exportPageMarkdown(fxid);
|
||||
await client.importPageMarkdown(fxid, md1);
|
||||
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
|
||||
const md2 = await client.exportPageMarkdown(fxid);
|
||||
// On failure, name the first diverging line of the two exports.
|
||||
const firstDiff = (a, b) => {
|
||||
const al = a.split("\n");
|
||||
const bl = b.split("\n");
|
||||
for (let i = 0; i < Math.max(al.length, bl.length); i++) {
|
||||
if (al[i] !== bl[i]) {
|
||||
return `first diff at line ${i + 1}: ${JSON.stringify(al[i] ?? "<EOF>")} -> ${JSON.stringify(bl[i] ?? "<EOF>")}`;
|
||||
}
|
||||
}
|
||||
return "same lines, different bytes (line endings?)";
|
||||
};
|
||||
check(
|
||||
"markdown fixpoint: export -> import -> export is byte-identical",
|
||||
md1 === md2,
|
||||
md1 === md2 ? "" : firstDiff(md1, md2),
|
||||
);
|
||||
} finally {
|
||||
try { await client.deletePage(fxid); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. shares: create (idempotent), public access, list, unshare
|
||||
const share = await client.sharePage(pageId);
|
||||
check("sharePage: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
|
||||
|
||||
@@ -372,7 +372,11 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
|
||||
// single flush. This proves the flush actually executes queued callbacks, so
|
||||
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
||||
let freeRan = false;
|
||||
const freeDone = withPageLock(`page.free-${UUID}`, async () => {
|
||||
// A DIFFERENT canonical UUID (unrelated to the page under test). withPageLock
|
||||
// now asserts its key is a canonical UUID (#449), so the "free" probe key must
|
||||
// also be a valid — but distinct — UUID, not a synthetic label.
|
||||
const FREE_UUID = "99999999-9999-4999-8999-999999999999";
|
||||
const freeDone = withPageLock(FREE_UUID, async () => {
|
||||
freeRan = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
__sessionCountForTests,
|
||||
} from "../../build/lib/collab-session.js";
|
||||
import { withPageLock } from "../../build/lib/page-lock.js";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
// A stand-in for HocuspocusProvider: it shares the ydoc (so the real yjs
|
||||
// read/transform/write in CollabSession.mutate runs unchanged), auto-completes
|
||||
@@ -90,7 +89,6 @@ const ENV_KEYS = [
|
||||
"MCP_COLLAB_SESSION_IDLE_MS",
|
||||
"MCP_COLLAB_SESSION_MAX_AGE_MS",
|
||||
"MCP_COLLAB_SESSION_MAX_ENTRIES",
|
||||
"MCP_COLLAB_TOKEN_TTL_MS",
|
||||
];
|
||||
let savedEnv;
|
||||
|
||||
@@ -325,7 +323,9 @@ test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)",
|
||||
});
|
||||
|
||||
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
|
||||
const pageId = "page-lock";
|
||||
// withPageLock now asserts a canonical UUID key (#449); this flow takes the
|
||||
// real page lock (mirroring replaceImage), so the key must be a valid UUID.
|
||||
const pageId = "77777777-7777-4777-8777-777777777777";
|
||||
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
|
||||
// each going through the non-locking acquireCollabSession.
|
||||
const result = await withPageLock(pageId, async () => {
|
||||
@@ -345,86 +345,6 @@ test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not dea
|
||||
);
|
||||
});
|
||||
|
||||
// --- #439: the collab-token cache is what makes the session cache ACTUALLY hit ---
|
||||
//
|
||||
// WHY these two tests exist (the #435 incident): the session registry keys on
|
||||
// (wsUrl, pageId, token) for identity isolation, but BOTH production token
|
||||
// sources mint a FRESH JWT on every call (the in-app provider re-signs a JWT
|
||||
// whose iat/exp changes every second; the external MCP POSTs /auth/collab-token
|
||||
// per call). The fresh token per call made the session-registry key unstable,
|
||||
// so the prod hit-rate was 0% — connect storms, 25s timeouts, zombie sessions —
|
||||
// while every other test in this file stayed green because they pass a FIXED
|
||||
// "tok" string. The #439 fix is the per-client collab-token cache
|
||||
// (DocmostClient.getCollabTokenWithReauth + MCP_COLLAB_TOKEN_TTL_MS); these
|
||||
// tests drive the token through it with a source that returns a DIFFERENT
|
||||
// fresh JWT per mint, exactly like prod, so a regression in EITHER the token
|
||||
// cache or the registry keying turns them red.
|
||||
//
|
||||
// getCollabTokenWithReauth is TS-private, but the compiled JS exposes it; the
|
||||
// tests call it directly because that is exactly the per-op composition of the
|
||||
// production call sites (updatePage etc.: mint the token, then acquire).
|
||||
|
||||
test("#439 token cache ON: fresh-JWT-per-mint source, two ops => ONE connect (session cache hits)", async () => {
|
||||
process.env.MCP_COLLAB_TOKEN_TTL_MS = "300000"; // cache ON (explicit, not default-dependent)
|
||||
let mints = 0;
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://h/api",
|
||||
getToken: async () => "user-jwt",
|
||||
// Like both prod sources: a DIFFERENT fresh JWT on every mint.
|
||||
getCollabToken: async () => `fresh-jwt-${++mints}`,
|
||||
});
|
||||
|
||||
// Op 1: mint the collab token through the client, then acquire + mutate.
|
||||
const tok1 = await client.getCollabTokenWithReauth();
|
||||
const s1 = await acquireCollabSession("page-1", tok1, "http://h/api");
|
||||
await s1.mutate(() => docWith("one"));
|
||||
|
||||
// Op 2: the same identity mints again — the cache must serve the SAME token.
|
||||
const tok2 = await client.getCollabTokenWithReauth();
|
||||
const s2 = await acquireCollabSession("page-1", tok2, "http://h/api");
|
||||
await s2.mutate(() => docWith("two"));
|
||||
|
||||
assert.equal(mints, 1, "the second op is served from the token cache");
|
||||
assert.equal(tok2, tok1, "stable token => stable session-registry key");
|
||||
assert.equal(s2, s1, "the live session is reused");
|
||||
assert.equal(
|
||||
FakeProvider.connectCount,
|
||||
1,
|
||||
"two mutations over one identity must cost exactly ONE real connect",
|
||||
);
|
||||
assert.equal(__sessionCountForTests(), 1);
|
||||
});
|
||||
|
||||
test("#439 negative control: token cache OFF (TTL=0) reproduces the #435 churn — two ops => TWO connects", async () => {
|
||||
process.env.MCP_COLLAB_TOKEN_TTL_MS = "0"; // explicit 0 disables the cache (fetch-per-call legacy)
|
||||
let mints = 0;
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://h/api",
|
||||
getToken: async () => "user-jwt",
|
||||
getCollabToken: async () => `fresh-jwt-${++mints}`,
|
||||
});
|
||||
|
||||
const tok1 = await client.getCollabTokenWithReauth();
|
||||
const s1 = await acquireCollabSession("page-1", tok1, "http://h/api");
|
||||
await s1.mutate(() => docWith("one"));
|
||||
|
||||
const tok2 = await client.getCollabTokenWithReauth();
|
||||
const s2 = await acquireCollabSession("page-1", tok2, "http://h/api");
|
||||
await s2.mutate(() => docWith("two"));
|
||||
|
||||
assert.equal(mints, 2, "without the cache every op mints its own token");
|
||||
assert.notEqual(tok2, tok1, "unstable token => unstable session-registry key");
|
||||
assert.notEqual(s2, s1, "no session reuse");
|
||||
assert.equal(
|
||||
FakeProvider.connectCount,
|
||||
2,
|
||||
"a full reconnect per op — the #435 storm in miniature",
|
||||
);
|
||||
// The first session lingers under its now-unreachable key until its idle
|
||||
// TTL — the zombie-session symptom of the incident.
|
||||
assert.equal(__sessionCountForTests(), 2);
|
||||
});
|
||||
|
||||
test("destroyAllSessions tears down every cached session", async () => {
|
||||
await acquireCollabSession("page-1", "tok", "http://h/api");
|
||||
await acquireCollabSession("page-2", "tok", "http://h/api");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Issue #449, invariant 2 ("no-await-окно"): the atomicity of the
|
||||
// read -> transform -> write section in CollabSession.mutate depends on there
|
||||
// being NO `await` (nor any other async yield point) between
|
||||
// `TiptapTransformer.fromYdoc` and `applyDocToFragment`. Yjs applies queued
|
||||
// remote updates only when the event loop yields, so an accidental await in that
|
||||
// window would let a concurrent human edit interleave and be clobbered (#152).
|
||||
//
|
||||
// That was an invariant enforced only by a comment. This test turns a violation
|
||||
// RED: it reads the SOURCE of collab-session.ts, extracts the block delimited by
|
||||
// the machine-readable BEGIN/END markers, and asserts no async boundary appears
|
||||
// inside it. Introducing an `await` (or `for await`, or `yield`) between the
|
||||
// markers fails this test.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// Scan the .ts SOURCE (not the compiled .js): the markers live in the source and
|
||||
// transpilation could rewrite/erase them, so the source is the authoritative
|
||||
// artifact the human edits.
|
||||
const sourcePath = join(here, "..", "..", "src", "lib", "collab-session.ts");
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
|
||||
const BEGIN = "=== MUTATE-CRITICAL-WINDOW: BEGIN";
|
||||
const END = "=== MUTATE-CRITICAL-WINDOW: END";
|
||||
|
||||
test("critical-window markers exist exactly once each", () => {
|
||||
const begins = source.split(BEGIN).length - 1;
|
||||
const ends = source.split(END).length - 1;
|
||||
assert.equal(
|
||||
begins,
|
||||
1,
|
||||
`expected exactly one '${BEGIN}' marker, found ${begins}`,
|
||||
);
|
||||
assert.equal(ends, 1, `expected exactly one '${END}' marker, found ${ends}`);
|
||||
});
|
||||
|
||||
test("the read->write critical window contains no async boundary (no await/yield)", () => {
|
||||
const beginIdx = source.indexOf(BEGIN);
|
||||
const endIdx = source.indexOf(END);
|
||||
assert.ok(beginIdx !== -1, "BEGIN marker not found");
|
||||
assert.ok(endIdx !== -1, "END marker not found");
|
||||
assert.ok(endIdx > beginIdx, "END marker must come after BEGIN marker");
|
||||
|
||||
// The block strictly between the two marker lines. Move past the end of the
|
||||
// BEGIN marker line so the marker comment text itself is not scanned.
|
||||
const afterBeginLine = source.indexOf("\n", beginIdx) + 1;
|
||||
const block = source.slice(afterBeginLine, endIdx);
|
||||
|
||||
// Detect any real async yield keyword as a whole word. `\bawait\b` also matches
|
||||
// inside `for await`, which is exactly what we want to forbid here.
|
||||
const forbidden = [/\bawait\b/, /\byield\b/];
|
||||
for (const re of forbidden) {
|
||||
const m = block.match(re);
|
||||
assert.equal(
|
||||
m,
|
||||
null,
|
||||
`forbidden async boundary '${m?.[0]}' found inside the no-await critical ` +
|
||||
`window of CollabSession.mutate. INVARIANT 1 (#449): the block between ` +
|
||||
`TiptapTransformer.fromYdoc and applyDocToFragment must be fully ` +
|
||||
`synchronous — an await there reopens the clobber-live-edits race (#152).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the critical window still spans fromYdoc -> applyDocToFragment", () => {
|
||||
// Guards the markers from drifting off the code they are meant to protect: if
|
||||
// someone moves the read/write out of the window, this catches it.
|
||||
const beginIdx = source.indexOf(BEGIN);
|
||||
const endIdx = source.indexOf(END);
|
||||
const afterBeginLine = source.indexOf("\n", beginIdx) + 1;
|
||||
const block = source.slice(afterBeginLine, endIdx);
|
||||
assert.ok(
|
||||
block.includes("TiptapTransformer.fromYdoc"),
|
||||
"critical window must contain the TiptapTransformer.fromYdoc read",
|
||||
);
|
||||
assert.ok(
|
||||
block.includes("applyDocToFragment"),
|
||||
"critical window must contain the applyDocToFragment write",
|
||||
);
|
||||
});
|
||||
@@ -1,13 +1,26 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { withPageLock } from "../../build/lib/page-lock.js";
|
||||
import { withPageLock, isUuid } from "../../build/lib/page-lock.js";
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// withPageLock now asserts its key is a canonical UUID (#449, "resolve-then-
|
||||
// lock"), so the mechanics tests below must lock under real UUIDs, not arbitrary
|
||||
// labels. Distinct valid UUIDv7-shaped ids for the distinct-page cases.
|
||||
const U = {
|
||||
same: "11111111-1111-7111-8111-111111111111",
|
||||
ordered: "22222222-2222-7222-8222-222222222222",
|
||||
poison: "33333333-3333-7333-8333-333333333333",
|
||||
poison2: "44444444-4444-7444-8444-444444444444",
|
||||
A: "aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa",
|
||||
B: "bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb",
|
||||
leak: "55555555-5555-7555-8555-555555555555",
|
||||
};
|
||||
|
||||
test("two ops on the same pageId run strictly sequentially (no overlap)", async () => {
|
||||
const events = [];
|
||||
const pageId = "same-page";
|
||||
const pageId = U.same;
|
||||
|
||||
const p1 = withPageLock(pageId, async () => {
|
||||
events.push("start-1");
|
||||
@@ -33,7 +46,7 @@ test("two ops on the same pageId run strictly sequentially (no overlap)", async
|
||||
});
|
||||
|
||||
test("same pageId ordering holds for many queued ops", async () => {
|
||||
const pageId = "ordered-page";
|
||||
const pageId = U.ordered;
|
||||
const order = [];
|
||||
const active = { count: 0, maxConcurrent: 0 };
|
||||
|
||||
@@ -60,7 +73,7 @@ test("same pageId ordering holds for many queued ops", async () => {
|
||||
});
|
||||
|
||||
test("a rejecting op does not poison the chain for the same page", async () => {
|
||||
const pageId = "poison-page";
|
||||
const pageId = U.poison;
|
||||
const events = [];
|
||||
|
||||
const failing = withPageLock(pageId, async () => {
|
||||
@@ -87,7 +100,7 @@ test("a rejecting op does not poison the chain for the same page", async () => {
|
||||
});
|
||||
|
||||
test("failing op queued before a success both resolve/reject correctly", async () => {
|
||||
const pageId = "poison-page-2";
|
||||
const pageId = U.poison2;
|
||||
const order = [];
|
||||
|
||||
const failing = withPageLock(pageId, async () => {
|
||||
@@ -111,14 +124,14 @@ test("failing op queued before a success both resolve/reject correctly", async (
|
||||
test("ops on different pageIds run concurrently (overlap)", async () => {
|
||||
const events = [];
|
||||
|
||||
const pA = withPageLock("page-A", async () => {
|
||||
const pA = withPageLock(U.A, async () => {
|
||||
events.push("A-start");
|
||||
await delay(40);
|
||||
events.push("A-end");
|
||||
return "A";
|
||||
});
|
||||
|
||||
const pB = withPageLock("page-B", async () => {
|
||||
const pB = withPageLock(U.B, async () => {
|
||||
events.push("B-start");
|
||||
await delay(10);
|
||||
events.push("B-end");
|
||||
@@ -134,7 +147,7 @@ test("ops on different pageIds run concurrently (overlap)", async () => {
|
||||
});
|
||||
|
||||
test("no functional leak: many sequential ops on same page keep working", async () => {
|
||||
const pageId = "leak-page";
|
||||
const pageId = U.leak;
|
||||
|
||||
// Run a long series of fully sequential ops (each awaited before the next is
|
||||
// queued) so the internal map entry is created and dropped repeatedly.
|
||||
@@ -151,3 +164,56 @@ test("no functional leak: many sequential ops on same page keep working", async
|
||||
const final = await withPageLock(pageId, async () => "still-works");
|
||||
assert.equal(final, "still-works");
|
||||
});
|
||||
|
||||
// --- Issue #449: fail-fast on a non-canonical lock key ---------------------
|
||||
// A write method that reaches the lock path with an unresolved slugId (or any
|
||||
// non-UUID key) must fail IMMEDIATELY and LOUDLY, not lock under a split key and
|
||||
// silently lose per-page serialization. These assert withPageLock rejects such
|
||||
// a key before ever running fn.
|
||||
|
||||
test("withPageLock throws on a raw 10-char slugId (unresolved key)", () => {
|
||||
let ran = false;
|
||||
assert.throws(
|
||||
() =>
|
||||
withPageLock("p7Xk29Lm4Q", async () => {
|
||||
ran = true;
|
||||
return "should-not-run";
|
||||
}),
|
||||
/canonical page UUID|resolve-then-lock/i,
|
||||
"a slugId key must fail-fast at the lock",
|
||||
);
|
||||
// The work must NOT have started: fail-fast means no serialization was
|
||||
// silently skipped under a bad key.
|
||||
assert.equal(ran, false, "fn must not run when the key is rejected");
|
||||
});
|
||||
|
||||
test("withPageLock throws on other non-UUID keys (label, empty, non-string)", () => {
|
||||
for (const bad of ["same-page", "", "not-a-uuid", "1234"]) {
|
||||
assert.throws(
|
||||
() => withPageLock(bad, async () => "x"),
|
||||
/canonical page UUID/i,
|
||||
`expected withPageLock to reject key ${JSON.stringify(bad)}`,
|
||||
);
|
||||
}
|
||||
// A non-string key is also rejected (guards a mistyped call site).
|
||||
assert.throws(
|
||||
() => withPageLock(/** @type {any} */ (undefined), async () => "x"),
|
||||
/canonical page UUID/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("withPageLock accepts a canonical UUID key (no false positive)", async () => {
|
||||
const uuid = "0192f3a4-b5c6-7d8e-9f01-23456789abcd";
|
||||
assert.equal(isUuid(uuid), true);
|
||||
const r = await withPageLock(uuid, async () => "ok");
|
||||
assert.equal(r, "ok");
|
||||
});
|
||||
|
||||
test("isUuid discriminates UUIDs from slugIds (shared predicate)", () => {
|
||||
// The predicate withPageLock asserts on is the SAME one resolvePageId uses to
|
||||
// decide whether a pageId is already a UUID (imported from page-lock).
|
||||
assert.equal(isUuid("0192f3a4-b5c6-7d8e-9f01-23456789abcd"), true);
|
||||
assert.equal(isUuid("p7Xk29Lm4Q"), false); // 10-char nanoid slugId
|
||||
assert.equal(isUuid("not-a-uuid"), false);
|
||||
assert.equal(isUuid(""), false);
|
||||
});
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot-smoke for the exact Docker image that is about to be pushed (issue #476).
|
||||
#
|
||||
# Retrospective class "local logic is right, the integration property was never
|
||||
# checked" (#353/#452/#361): every other CI job builds and tests code from the
|
||||
# working tree, but the IMAGE watchtower pulls was never actually started
|
||||
# anywhere before this gate. This script boots the built image against the
|
||||
# publish job's postgres/redis services and asserts four integration
|
||||
# properties end-to-end:
|
||||
# S1 the app boots and /api/health answers (startup migrator + boot)
|
||||
# S2 the first-run workspace setup endpoint works (API + DB writes)
|
||||
# S3 the client dist is inside the image and served
|
||||
# S4 hashed assets are served immutable (#452) with the precompressed
|
||||
# brotli copy shipped in the image
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: image-smoke.sh <image>}"
|
||||
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
|
||||
# Boot the exact image that will be pushed, wired to the job services via host
|
||||
# network (postgres on localhost:5432, redis on localhost:6379). The container
|
||||
# is deliberately NOT removed on failure so the workflow's dump-on-failure step
|
||||
# can read `docker logs gitmost-smoke`.
|
||||
docker run -d --name gitmost-smoke --network host \
|
||||
-e DATABASE_URL=postgresql://docmost:docmost@localhost:5432/docmost \
|
||||
-e REDIS_URL=redis://localhost:6379 \
|
||||
-e APP_SECRET=ci-smoke-secret-change-me-min-32-characters \
|
||||
-e APP_URL=http://localhost:3000 \
|
||||
"$IMAGE"
|
||||
|
||||
# S1: wait for /api/health — covers the startup migrator + boot inside the
|
||||
# shipped image (#361-boot, #353 runtime class): a migration the Kysely startup
|
||||
# migrator rejects, or a runtime module missing from the image, dies right here.
|
||||
healthy=0
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$healthy" -eq 1 ] || fail "S1: /api/health did not answer within 120s (boot or startup migration failed)"
|
||||
echo "OK S1: image booted and /api/health answers"
|
||||
|
||||
# S2: the first-run workspace setup works end-to-end (controller -> service ->
|
||||
# DB write chain inside the shipped image, not just a static health probe).
|
||||
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Smoke","email":"smoke@example.com","password":"SmokePassword123","workspaceName":"Smoke"}' \
|
||||
> /dev/null || fail "S2: POST /api/auth/setup failed"
|
||||
echo "OK S2: workspace setup succeeded"
|
||||
|
||||
# S3: the client dist is actually inside the image and served — the SPA HTML
|
||||
# must reference hashed /assets/ bundles (a broken client COPY in the
|
||||
# Dockerfile would serve an empty shell that every other job stays green on).
|
||||
HTML=$(curl -fsS http://localhost:3000/) || fail "S3: fetching / failed"
|
||||
grep -q '/assets/' <<<"$HTML" || fail "S3: served HTML references no /assets/ bundle (client dist missing from the image?)"
|
||||
echo "OK S3: client dist served (HTML references /assets/)"
|
||||
|
||||
# S4: hashed /assets/ files must be served with an immutable cache-control
|
||||
# (#452 class: static.module.ts resolveStaticAssetHeaders owns the header) AND
|
||||
# with the precompressed brotli neighbour. Both checks are mandatory — verified
|
||||
# against the code: resolveStaticAssetHeaders marks every /assets/ path
|
||||
# immutable, and the client build (vite-plugin-compression2, include covers
|
||||
# .js) emits a .br copy next to every bundle that the Dockerfile ships and
|
||||
# @fastify/static serves via preCompressed:true.
|
||||
ASSET=$(grep -oE '/assets/[A-Za-z0-9._@/-]+\.js' <<<"$HTML" | head -1 || true)
|
||||
[ -n "$ASSET" ] || fail "S4: no /assets/*.js path found in the served HTML"
|
||||
HDRS=$(curl -fsSI -H 'Accept-Encoding: br' "http://localhost:3000$ASSET") || fail "S4: HEAD $ASSET failed"
|
||||
grep -qi '^cache-control:.*immutable' <<<"$HDRS" || fail "S4: $ASSET served without an immutable cache-control (#452)"
|
||||
echo "OK S4: hashed asset served with immutable cache-control"
|
||||
grep -qi '^content-encoding:.*br' <<<"$HDRS" || fail "S4: $ASSET not served brotli-precompressed (content-encoding: br missing)"
|
||||
echo "OK S4: hashed asset served with the precompressed brotli copy"
|
||||
|
||||
# Remove the container ONLY on success, so the failure path keeps it around for
|
||||
# the workflow's "Dump smoke container log on failure" step.
|
||||
docker rm -f gitmost-smoke > /dev/null
|
||||
echo "OK image smoke passed"
|
||||
Reference in New Issue
Block a user