Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder be70bd2e8e Merge remote-tracking branch 'gitea/develop' into fix/541-reconnect-timeout 2026-07-12 04:37:47 +03:00
agent_coder 69e04349a0 fix(client): вход в reconnect ограничивает ожидание getRun таймаутом (#541)
При живом обрыве SSE вход в лестницу reconnect происходил только
после резолва getRun(cid) (ре-сид из персиста). Путь REJECT
обрабатывался через .catch, но ЗАВИСШИЙ getRun (соединение есть,
ответа нет) был не ограничен: axios-клиент (lib/api-client.ts) не
имеет timeout, а stalled-idle-cap взводится только ПОСЛЕ входа в
reconnecting/polling. Итог — FSM залипала в `streaming` без баннера
и без поллинга до сокет-таймаута браузера (минуты). Это тот самый
класс тихого зависания, который устраняет эпик #497.

Оборачиваю ожидание getRun гонкой с таймаутом
(RECONNECT_RESEED_TIMEOUT_MS = 4s): по таймауту берётся ТОТ ЖЕ
фолбэк, что и на reject — dropLivePartialAndReplayFromStart() +
enterReconnect(runId), так что лестница/поллинг стартуют и
stalled-idle-cap взводится. Локальный флаг `settled` делает ветви
resolve/reject/timeout взаимоисключающими: поздний резолв getRun
после сработавшего таймаута полностью игнорируется (не входит в
reconnect повторно, не перетирает replay-from-start устаревшим
ре-сидом, ничего не перевзводит). Таймер живёт в reseedTimerRef и
очищается при размонтировании (никаких висящих setTimeout).

Тесты: hang-кейс (getRun не резолвится -> по таймауту FSM в
reconnecting, live-partial сброшен, replay-from-start) и
late-resolve safety (поздний резолв — no-op). Мутация: замена гонки
на голый getRun краснит оба #541-теста.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:29:25 +03:00
70 changed files with 479 additions and 3700 deletions
-10
View File
@@ -251,16 +251,6 @@ MCP_DOCMOST_PASSWORD=
# Default 120000 (2 min).
# AI_MCP_CALL_TIMEOUT_MS=120000
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
# guaranteed to actually flip when an operator flips it during an incident. When
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
# the api-key management endpoints return 404. The resolved state is logged at boot
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
# API_KEYS_ENABLED=true
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
# small for a long AI-chat research turn: the client resends the FULL message
# history (every tool call + search result) on each turn, so a deep conversation's
-7
View File
@@ -226,13 +226,6 @@ jobs:
- name: Build mcp
run: pnpm --filter @docmost/mcp build
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
# so build it here or tsc fails with TS2307 Cannot find module
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
- name: Build token-estimate
run: pnpm --filter @docmost/token-estimate build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
-8
View File
@@ -159,14 +159,6 @@ jobs:
- name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build
# @docmost/token-estimate is a shared workspace package the client vitest
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
# build it before the recursive test run or the client suite fails with
# "Failed to resolve import '@docmost/token-estimate'" (#490).
- name: Build token-estimate
run: pnpm --filter @docmost/token-estimate build
- name: Run unit tests
run: pnpm -r test
-19
View File
@@ -392,25 +392,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
activation is also cached in the chat metadata to avoid re-resolving it each
turn. (#490)
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
when a diagram is opened in the draw.io editor.** Agent-created diagrams
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
SVG's `content=` attribute as base64; the draw.io editor decodes that via
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
`ё`, ``) split into garbage and the editor's autosave then persisted the
corrupted model, breaking the page preview too. Both write paths
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
DOM as UTF-8 — so labels open intact. The decoder reads both the new
entity-encoded form and the old base64 form, so existing diagrams still open.
*Healing pre-fix diagrams:* only a diagram that still holds its original
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
same XML (rewrites the attachment in the new form); no migration script is
needed. A diagram that was already opened in the editor persisted the
mojibake at rest, so `drawioGet` reads the already-corrupted text and
`drawioUpdate` faithfully rewrites it — that text is lost and is not
recoverable by a rewrite. (#507)
- **A chat with one malformed message part no longer 500s on every turn, and a
failed send no longer duplicates the user's message.** Incoming client parts
are now whitelisted to `text` (a forged tool-result part can no longer reach
-8
View File
@@ -59,14 +59,6 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
# prod install would resolve a broken workspace symlink and the server would die
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
# package + its manifest, mirroring prosemirror-markdown above.
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
@@ -1254,4 +1254,88 @@ describe("ChatThread — live reconnect + stalled", () => {
});
expect(onResumeFallback).toHaveBeenCalledWith(false); // POLL_IDLE_CAP -> idle -> disarm
});
it("#541: getRun HANGS on a live disconnect — the timeout race still enters reconnect (no silent freeze in `streaming`)", async () => {
// MUTATION-VERIFY: revert the race to a bare `getRun(cid).then/.catch` and this
// reddens — a HUNG (never-settling, NOT rejected) getRun leaves the FSM stuck in
// `streaming` with no reconnect banner and no poll (the axios client sets no
// request timeout, and the stalled-idle cap only arms AFTER reconnecting/polling).
renderLive();
h.state.getRun.mockReset();
h.state.getRun.mockReturnValue(new Promise(() => {})); // getRun HANGS forever
await disconnect(); // live partial = liveMsg (id "a2")
expect(h.state.getRun).toHaveBeenCalledWith("c1");
// BEFORE the bound fires the FSM is still in the live turn — the very freeze bug.
expect(screen.queryByText(/reconnecting/i)).toBeNull();
// The recovery-start bound fires -> the SAME fallback as the reject path.
act(() => {
vi.advanceTimersByTime(4_000);
});
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
// The live partial (a2) was DROPPED from the store (replay-from-start, no stale
// tail-apply base). Mirrors the getRun-REJECT test's inspection.
const removedLivePartial = (
h.state.setMessages as unknown as {
mock: { calls: [unknown][] };
}
).mock.calls.some(([updater]) => {
if (typeof updater !== "function") return false;
const out = (updater as (p: { id: string }[]) => { id: string }[])([
{ id: "a2" },
{ id: "u1" },
]);
return !out.some((m) => m.id === "a2");
});
expect(removedLivePartial).toBe(true);
// ...and the anchor was NULLED -> replay-from-start (no ?anchor=&n= over the partial).
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
});
it("#541: a getRun resolve AFTER the timeout already fired is IGNORED (no double reconnect, no stale re-seed)", async () => {
// The timeout wins first and enters the ladder via replay-from-start. When the
// hung getRun FINALLY answers, its late `.then` must be a full no-op: it must not
// re-seed the store from the (now stale) persisted row, must not re-set the
// anchor, and must not re-enter reconnect. The local `settled` flag makes the
// resolve/reject/timeout branches mutually exclusive.
// MUTATION-VERIFY: drop the `settled` guard (let the late `.then` run) and the
// late re-seed re-sets the anchor (URL regains ?anchor=a2&n=3) + calls setMessages.
renderLive();
let resolveGetRun!: (v: unknown) => void;
h.state.getRun.mockReset();
h.state.getRun.mockReturnValue(
new Promise((r) => {
resolveGetRun = r;
}),
);
await disconnect();
act(() => {
vi.advanceTimersByTime(4_000); // bound fires -> replay-from-start, reconnecting
});
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
// Anchor is null -> replay-from-start URL (the pre-condition the late resolve must
// not undo).
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
const setMessagesCallsBefore = h.state.setMessages.mock.calls.length;
// NOW the hung getRun finally resolves with a persisted anchor (id a2, steps 3).
await act(async () => {
resolveGetRun(persistedAnchor());
await Promise.resolve();
});
// The late resolve did NOT re-seed the store...
expect(h.state.setMessages.mock.calls.length).toBe(setMessagesCallsBefore);
// ...did NOT re-set the anchor (URL stays replay-from-start, no ?anchor=&n=)...
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
// ...and did NOT trigger a fresh reconnect attach.
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
});
});
@@ -81,6 +81,17 @@ const STREAM_THROTTLE_MS = 50;
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
// #541: the bound on the persist re-seed (getRun) wait when ENTERING the reconnect
// ladder after a live SSE drop. The axios client (lib/api-client.ts) sets NO request
// timeout, and the stalled-idle cap only arms AFTER the FSM enters reconnecting/
// polling — which never happens if getRun HANGS (connection established, no response).
// Without this bound a live drop + a hung getRun sticks the FSM in `streaming` with
// no banner and no poll until the browser socket timeout (minutes) — the silent-freeze
// class #497 eliminates. This is a deliberate RECOVERY-START bound (drop the live
// partial + enter the ladder so the poll/idle-cap arms), intentionally much shorter
// than any network socket timeout — not a network read timeout.
const RECONNECT_RESEED_TIMEOUT_MS = 4_000;
/** The #487 active (non-terminal) run statuses — mirrors the server's
* ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
function isActiveRunStatus(status: string | null | undefined): boolean {
@@ -286,6 +297,9 @@ export default function ChatThread({
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// #541: the persist re-seed (getRun) timeout race on the disconnect->reconnect
// entry. Held in a ref so unmount (DISPOSE cleanup) clears it — no dangling timer.
const reseedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
// tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
@@ -748,37 +762,68 @@ export default function ChatThread({
anchorRef.current = null;
};
if (cid) {
void getRun(cid)
.then((res) => {
if (!mountedRef.current) return;
const persisted = res.message;
if (persisted && persisted.role === "assistant") {
anchorRef.current = {
id: persisted.id,
stepsPersisted: stepsPersistedOf(persisted),
};
// Replace the live partial with the persisted row IN PLACE by id —
// the re-seed from persist. The attach's tail (steps >= N) then
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
} else {
// No persisted assistant row (pre-first-frame break): drop the live
// partial + replay from start (no anchor/n) so nothing is duplicated.
dropLivePartialAndReplayFromStart();
}
enterReconnect(res.run?.id ?? runId);
})
.catch(() => {
if (!mountedRef.current) return;
// Persist read FAILED: we cannot re-seed from fresh persist, and a
// stale mount-time anchor over the live partial would tail-apply
// already-present steps -> duplication (a flaky-network blip:
// SSE + getRun both fail, network recovers in ~1s, the registry still
// covers from the mount frontier). Restore the removed-filter guarantee
// instead: drop the live partial + replay from start / 204 -> poll.
// #541: bound the persist re-seed wait with a timeout race. getRun goes
// through the axios client, which has NO request timeout; a HUNG getRun
// (connection open, no response) — distinct from a REJECT, which the
// `.catch` already handles — would otherwise never let us enter the ladder,
// leaving the FSM stuck in `streaming` with no banner and no poll until the
// browser socket timeout. `settled` makes the three branches (resolve /
// reject / timeout) mutually exclusive: whichever fires FIRST wins and the
// others become no-ops. So a LATE getRun resolve AFTER the timeout is fully
// ignored — it cannot (i) re-enter reconnect a second time, (ii) overwrite
// the timeout's replay-from-start with a stale re-seed, or (iii) re-arm any
// timer. On timeout we take the SAME fallback as the reject path (drop the
// live partial + enter the ladder, so the poll and the stalled-idle cap arm).
let settled = false;
const finishReseed = (apply: () => void): void => {
if (settled) return;
settled = true;
if (reseedTimerRef.current) {
clearTimeout(reseedTimerRef.current);
reseedTimerRef.current = null;
}
if (!mountedRef.current) return;
apply();
};
reseedTimerRef.current = setTimeout(() => {
finishReseed(() => {
dropLivePartialAndReplayFromStart();
enterReconnect(runId);
});
}, RECONNECT_RESEED_TIMEOUT_MS);
void getRun(cid)
.then((res) => {
finishReseed(() => {
const persisted = res.message;
if (persisted && persisted.role === "assistant") {
anchorRef.current = {
id: persisted.id,
stepsPersisted: stepsPersistedOf(persisted),
};
// Replace the live partial with the persisted row IN PLACE by id —
// the re-seed from persist. The attach's tail (steps >= N) then
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
} else {
// No persisted assistant row (pre-first-frame break): drop the live
// partial + replay from start (no anchor/n) so nothing is duplicated.
dropLivePartialAndReplayFromStart();
}
enterReconnect(res.run?.id ?? runId);
});
})
.catch(() => {
finishReseed(() => {
// Persist read FAILED: we cannot re-seed from fresh persist, and a
// stale mount-time anchor over the live partial would tail-apply
// already-present steps -> duplication (a flaky-network blip:
// SSE + getRun both fail, network recovers in ~1s, the registry still
// covers from the mount frontier). Restore the removed-filter guarantee
// instead: drop the live partial + replay from start / 204 -> poll.
dropLivePartialAndReplayFromStart();
enterReconnect(runId);
});
});
} else {
dropLivePartialAndReplayFromStart();
enterReconnect(runId);
@@ -903,6 +948,12 @@ export default function ChatThread({
}
return () => {
mountedRef.current = false;
// #541: clear the in-flight persist re-seed timeout (not an FSM effect timer,
// so DISPOSE does not touch it) — no dangling setTimeout after unmount.
if (reseedTimerRef.current) {
clearTimeout(reseedTimerRef.current);
reseedTimerRef.current = null;
}
dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
};
// Mount-only by design; the parent remounts per chat via `key`.
@@ -281,12 +281,10 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
if (isOpen) {
const node = treeModel.find(data, id) as SpaceTreeNode | null;
// Same "unloaded branch" predicate the realtime insert paths use
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
// (`insertByPosition` / `placeByPosition`) can never disagree about what
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
// does not yet route through it — see #525 follow-up.
if (treeModel.isUnloadedBranch(node)) {
if (
node?.hasChildren &&
(!node.children || node.children.length === 0)
) {
const fetched = await fetchAllAncestorChildren({
pageId: id,
spaceId: node.spaceId,
@@ -74,48 +74,6 @@ describe("treeModel.isDescendant", () => {
});
});
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
// gate and the insert paths. Unloaded == server says hasChildren but none are
// present locally (canonical form `children: []`, also `undefined`). A parent
// without hasChildren is genuinely empty, not unloaded.
describe("treeModel.isUnloadedBranch", () => {
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
it("true for hasChildren + empty array (canonical unloaded form)", () => {
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
expect(treeModel.isUnloadedBranch(n)).toBe(true);
});
it("true for hasChildren + undefined children", () => {
const n: PH = { id: "p", name: "P", hasChildren: true };
expect(treeModel.isUnloadedBranch(n)).toBe(true);
});
it("false for hasChildren + already-loaded children", () => {
const n: PH = {
id: "p",
name: "P",
hasChildren: true,
children: [{ id: "c", name: "C" }],
};
expect(treeModel.isUnloadedBranch(n)).toBe(false);
});
it("false for a genuinely-empty parent (no hasChildren)", () => {
expect(
treeModel.isUnloadedBranch({
id: "p",
name: "P",
hasChildren: false,
children: [],
} as PH),
).toBe(false);
expect(
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
).toBe(false);
});
it("false for null/undefined", () => {
expect(treeModel.isUnloadedBranch(null)).toBe(false);
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
});
});
describe("treeModel.visible", () => {
it("returns only root nodes when no openIds", () => {
const v = treeModel.visible(fixture, new Set());
@@ -239,64 +197,43 @@ describe("treeModel.insertByPosition", () => {
]);
});
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
// hide the parent's other real children. The canonical unloaded form here is
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
// The node is left to be lazy-loaded; the chevron stays enabled.
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
const parent = treeModel.find(t, "p");
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
// here and reredden this expectation.
expect(parent?.children).toEqual([]);
expect(treeModel.find(t, "x")).toBeNull();
// ...and the chevron stays enabled so the user can expand to load it.
expect((parent as PH).hasChildren).toBe(true);
});
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
const parent = treeModel.find(t, "p");
expect(parent?.children).toBeUndefined();
expect(treeModel.find(t, "x")).toBeNull();
expect((parent as PH).hasChildren).toBe(true);
});
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
// No server children (`hasChildren: false`), so materializing the first child
// is correct — nothing is hidden.
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
});
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
// #159 #1: inserting/moving a node under a parent whose children are NOT
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
// parent's other real children. The node is left to be lazy-loaded; only
// `hasChildren` is flagged so the chevron appears.
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
const parent = treeModel.find(t, "p");
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
// full set, including this node, on expand).
expect(parent?.children).toBeUndefined();
expect(treeModel.find(t, "x")).toBeNull();
// ...but the chevron is enabled so the user can expand to load it.
expect((parent as PH).hasChildren).toBe(true);
});
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
// A loaded (empty) child list is complete, so the node IS inserted.
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
});
@@ -43,26 +43,6 @@ export const treeModel = {
};
},
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
// but none are present locally. The canonical unloaded form in this codebase
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
// only checks `=== undefined` misses the real case and materializes a misleading
// partial list (#525). This is the SINGLE source of truth for "should a
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
// can never drift apart again. (The local raw `insert` primitive and its DnD/
// create-page callers do NOT yet route through this predicate — see #525
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
// (no server children) — inserting its first child is correct, not deferred.
isUnloadedBranch<T extends object>(
node: TreeNode<T> | null | undefined,
): boolean {
if (!node) return false;
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
return hasChildren && (node.children == null || node.children.length === 0);
},
isDescendant<T extends object>(
tree: TreeNode<T>[],
ancestorId: string,
@@ -147,15 +127,14 @@ export const treeModel = {
}
const parent = treeModel.find(tree, parentId);
// The parent is in the tree but its children have NOT been lazy-loaded yet
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
// here would MATERIALIZE a misleading partial child list (`[node]`) that
// defeats the lazy-load gate — which fetches only when children are
// absent/empty — so the parent's OTHER real children would never load and the
// moved/added node would be the only one shown (a silent data loss, #159 #1).
// Instead, leave the children unloaded and just flag `hasChildren` so the
// chevron appears; expanding fetches the FULL set (including this node).
if (parent && treeModel.isUnloadedBranch(parent)) {
if (parent && parent.children === undefined) {
return treeModel.update(
tree,
parentId,
@@ -82,19 +82,17 @@ describe("applyMoveTreeNode", () => {
]);
});
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
// The pre-#525 predicate (`children === undefined`) missed this form and
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
// HIDING the parent's other real children. Now the move leaves children
// unloaded (so expanding fetches the FULL set, including src).
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded
// (children === undefined). The OLD behavior inserted `src` as the ONLY
// child ([src]), which defeated the lazy-load gate and HID the parent's
// other real children. Now the move leaves children unloaded (so expanding
// fetches the FULL set, including src) and just flags hasChildren.
const tree: SpaceTreeNode[] = [
node("dstCollapsed", {
position: "a0",
hasChildren: true,
children: [],
hasChildren: false,
children: undefined as unknown as SpaceTreeNode[],
}),
node("src", { position: "a9" }),
];
@@ -107,10 +105,9 @@ describe("applyMoveTreeNode", () => {
pageData: {},
});
const dst = treeModel.find(next, "dstCollapsed");
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
// `=== undefined` predicate would insert [src] here and redden this.
expect(dst?.children).toEqual([]);
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
// src) on expand, rather than showing a misleading partial [src] list.
expect(dst?.children).toBeUndefined();
expect(dst?.hasChildren).toBe(true);
// src moved away from its old root slot (it lives under dstCollapsed
// server-side and reappears when the parent is expanded/loaded).
@@ -16,7 +16,6 @@ import { TransclusionService } from '../core/page/transclusion/transclusion.serv
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
import { StorageModule } from '../integrations/storage/storage.module';
import { EnvironmentModule } from '../integrations/environment/environment.module';
import { ApiKeyModule } from '../core/api-key/api-key.module';
@Module({
providers: [
@@ -32,7 +31,6 @@ import { ApiKeyModule } from '../core/api-key/api-key.module';
exports: [CollaborationGateway],
imports: [
TokenModule,
ApiKeyModule,
WatcherModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
@@ -141,57 +141,7 @@ export function htmlToJson(html: string) {
}
}
/**
* Deterministic text-serializer overrides for the `format:"text"` page read
* (#502). Non-text nodes render to a STABLE placeholder instead of their
* (structure-dependent) inner text, so a machine diff of two text reads is
* driven only by the page's actual prose output stability across package
* versions IS the contract (pinned by a snapshot test). Returning a string from
* a `textSerializer` also stops `generateText` descending into the node, so a
* table renders as ONE token rather than its flattened cell text.
*
* Only nodes with no meaningful flat-text form are overridden; every other node
* (paragraph/heading/list/code/blockquote/callout/) keeps its natural text so
* a config written as markdown reads back byte-identical.
*/
const TEXT_READ_SERIALIZERS: Record<string, (props: { node: any }) => string> =
{
// Image atom: no inner text -> a fixed placeholder.
image: () => '[image]',
// Table: `[table RxC]` where R = row count, C = the first row's cell count
// (a table's columns are uniform per the schema). Computed from the PM node,
// so it is independent of cell contents.
table: ({ node }) => {
const rows = node?.childCount ?? 0;
const cols = rows > 0 ? (node.child(0)?.childCount ?? 0) : 0;
return `[table ${rows}x${cols}]`;
},
};
/**
* Serialize a ProseMirror/TipTap document to plain text.
*
* Default (no options): the long-standing search-index behavior bare
* concatenated node text with `generateText`'s default `\n\n` block separator.
* This feeds the page `textContent` tsvector and MUST NOT change.
*
* `deterministic:true` (#502 `format:"text"` page read): a flat, machine-diffable
* rendering one line per block (`\n` block separator; `hardBreak` already
* serializes to `\n`), inline marks/anchors/autoformat dropped, and non-text
* nodes replaced by the stable placeholders above (`[image]`, `[table RxC]`).
*/
export function jsonToText(
// `any` (like jsonToHtml/jsonToMarkdown) so a loosely-typed DB `page.content`
// (JsonValue) can be passed straight through, as the controller does.
tiptapJson: any,
options?: { deterministic?: boolean },
) {
if (options?.deterministic) {
return generateText(tiptapJson, tiptapExtensions, {
blockSeparator: '\n',
textSerializers: TEXT_READ_SERIALIZERS,
});
}
export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
}
@@ -52,7 +52,6 @@ describe('AuthenticationExtension.onAuthenticate', () => {
let pageRepo: { findById: jest.Mock };
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
let pagePermissionRepo: { canUserEditPage: jest.Mock };
let apiKeyService: { validate: jest.Mock };
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
// starts false; the extension flips it to true on a read-only downgrade.
@@ -80,15 +79,12 @@ describe('AuthenticationExtension.onAuthenticate', () => {
}),
};
apiKeyService = { validate: jest.fn().mockResolvedValue({ user: {}, workspace: {} }) };
ext = new AuthenticationExtension(
tokenService as any,
userRepo as any,
pageRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
apiKeyService as any,
);
// Silence the extension's logger (it warns/debugs on denial branches).
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
@@ -235,73 +231,4 @@ describe('AuthenticationExtension.onAuthenticate', () => {
// No internal ai_chats row for an MCP/service-account collab edit → null.
expect(ctx.aiChatId).toBeNull();
});
// --- #501: api-key laundering guard (fail-closed discriminator) ----------
describe('api-key laundering guard', () => {
it('api_key principal → row-checks the key on connect (valid key proceeds)', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
);
const data = buildData();
await ext.onAuthenticate(data as any);
expect(apiKeyService.validate).toHaveBeenCalledTimes(1);
expect(apiKeyService.validate).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: 'key-1', type: JwtType.API_KEY }),
);
});
it('REVOKED api_key → Unauthorized on connect, BEFORE any page/user lookup', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
);
// The shared validator denies a revoked key.
apiKeyService.validate.mockRejectedValue(new UnauthorizedException());
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
// No new collab connection: the key check gates before page access.
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('api_key principal missing apiKeyId → Unauthorized (malformed)', async () => {
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'api_key' }));
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
expect(apiKeyService.validate).not.toHaveBeenCalled();
});
it('session principal → NO api-key check (session-backed, incl. internal agent)', async () => {
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'session' }));
await ext.onAuthenticate(buildData() as any);
expect(apiKeyService.validate).not.toHaveBeenCalled();
});
it('claimless token WITHIN the grace window → trusted (legacy pre-rollout)', async () => {
// Default rolloutAt = now, so we are inside the grace window.
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
await expect(ext.onAuthenticate(buildData() as any)).resolves.toBeDefined();
expect(apiKeyService.validate).not.toHaveBeenCalled();
});
it('claimless token AFTER the grace window → Unauthorized (fail-closed)', async () => {
// Move the rollout reference far into the past so the grace has elapsed.
(ext as any).rolloutAt = Date.now() - 25 * 60 * 60 * 1000;
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('infra error from the api-key row-check propagates (not masked)', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
);
const boom = new Error('db down');
apiKeyService.validate.mockRejectedValue(boom);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toBe(boom);
});
});
});
@@ -14,37 +14,20 @@ import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { isUserDisabled } from '../../common/helpers';
import { getPageId } from '../collaboration.util';
import {
JwtApiKeyPayload,
JwtCollabPayload,
JwtType,
} from '../../core/auth/dto/jwt-payload';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
import { ApiKeyService } from '../../core/api-key/api-key.service';
// Max lifetime of a collab token (generateCollabToken uses expiresIn '24h'). Used
// as the rollout grace window below: once this long has elapsed since this
// process started serving the #501 code, every STILL-VALID collab token was
// necessarily minted post-rollout and MUST carry the `principal` discriminator,
// so a claimless one is a bug and is rejected (fail-closed) rather than trusted.
const COLLAB_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class AuthenticationExtension implements Extension {
private readonly logger = new Logger(AuthenticationExtension.name);
// Reference instant for the claimless-rejection grace window. Overridable so a
// unit test can drive the pre-/post-grace boundary without wall-clock waits.
protected rolloutAt = Date.now();
constructor(
private tokenService: TokenService,
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly apiKeyService: ApiKeyService,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -71,36 +54,6 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException('Invalid collab token');
}
// #501 — fail-closed api-key laundering guard. A collab token minted by an
// api-key principal carries principal='api_key' + apiKeyId; re-check the key
// on connect so a REVOKED key gets NO new collab connections (a collab token
// outlives its 24h, but a revoked key can no longer open fresh ones). An
// api-key token missing its apiKeyId is malformed → reject. A claimless token
// (no recognized principal) is trusted only DURING the rollout grace window
// (a legacy pre-rollout session token, which api keys could never mint);
// once the grace has elapsed every valid token must carry the discriminator,
// so a claimless one is a bug and is rejected (not silently trusted for 24h).
const principal = jwtPayload.principal;
if (principal === 'api_key') {
if (!jwtPayload.apiKeyId) {
throw new UnauthorizedException();
}
// Row-check via the SHARED validator: throws Unauthorized on a revoked/
// expired/disabled key; an infra error propagates (not masked). No new
// connection for a dead key.
await this.apiKeyService.validate({
sub: jwtPayload.sub,
workspaceId: jwtPayload.workspaceId,
apiKeyId: jwtPayload.apiKeyId,
type: JwtType.API_KEY,
} as JwtApiKeyPayload);
} else if (principal !== 'session') {
// Unrecognized/absent discriminator: reject once past the grace window.
if (Date.now() - this.rolloutAt >= COLLAB_TOKEN_GRACE_MS) {
throw new UnauthorizedException();
}
}
const userId = jwtPayload.sub;
const workspaceId = jwtPayload.workspaceId;
@@ -1,116 +0,0 @@
import { jsonToText } from './collaboration.util';
// #502 READ contract: `jsonToText(json, { deterministic: true })` — the flat,
// machine-diffable text rendering behind getPage `format:"text"`. Block-per-line
// (`\n` separator), inline marks/anchors dropped, hardBreak -> `\n`, and non-text
// nodes replaced by STABLE placeholders (`[image]`, `[table RxC]`). Output
// stability across package versions IS the contract, so it is pinned by a
// snapshot below. The DEFAULT (no options) path is the search-index serializer
// and MUST be unchanged — asserted separately.
const doc = (...content: any[]) => ({ type: 'doc', content });
const para = (...content: any[]) => ({ type: 'paragraph', content });
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
describe('jsonToText — default (search index) behavior is unchanged', () => {
it('uses the `\\n\\n` block separator and drops non-text nodes to empty', () => {
const d = doc(para(text('alpha')), para(text('beta')));
expect(jsonToText(d)).toBe('alpha\n\nbeta');
});
it('an image contributes no text in the default (tsvector) mode', () => {
const d = doc(para(text('cap')), { type: 'image', attrs: { src: 's' } });
// No `[image]` placeholder leaks into the search index.
expect(jsonToText(d)).not.toContain('[image]');
});
});
describe('jsonToText — deterministic:true (getPage format:"text")', () => {
it('renders one line per block with `\\n` separators, marks dropped', () => {
const d = doc(
{ type: 'heading', attrs: { level: 2 }, content: [text('Title')] },
para(
text('hello ', [{ type: 'bold' }]),
text('world', [{ type: 'italic' }]),
),
);
expect(jsonToText(d, { deterministic: true })).toBe('Title\nhello world');
});
it('a hardBreak renders as a newline', () => {
const d = doc(para(text('a'), { type: 'hardBreak' }, text('b')));
expect(jsonToText(d, { deterministic: true })).toBe('a\nb');
});
it('an image node -> the stable `[image]` placeholder', () => {
const d = doc(para(text('before')), { type: 'image', attrs: { src: 's' } });
expect(jsonToText(d, { deterministic: true })).toBe('before\n[image]');
});
it('a table -> `[table RxC]` (rows x columns) and its cell text is NOT flattened in', () => {
const cell = (t: string) => ({
type: 'tableCell',
content: [para(text(t))],
});
const row = (...cells: any[]) => ({ type: 'tableRow', content: cells });
const table = {
type: 'table',
content: [
row(cell('CELLONE'), cell('CELLTWO'), cell('CELLTHREE')),
row(cell('CELLFOUR'), cell('CELLFIVE'), cell('CELLSIX')),
],
};
const d = doc(para(text('grid:')), table);
const out = jsonToText(d, { deterministic: true });
expect(out).toBe('grid:\n[table 2x3]');
expect(out).not.toContain('CELL'); // cell text is not flattened into the read
});
it('SNAPSHOT: a mixed document renders to a stable, deterministic string', () => {
const cell = (t: string) => ({
type: 'tableCell',
content: [para(text(t))],
});
const d = doc(
{ type: 'heading', attrs: { level: 1 }, content: [text('Config')] },
para(text('key = ', [{ type: 'bold' }]), text('value')),
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
},
{ type: 'image', attrs: { src: 's' } },
{
type: 'table',
content: [{ type: 'tableRow', content: [cell('x'), cell('y')] }],
},
);
expect(jsonToText(d, { deterministic: true })).toMatchInlineSnapshot(`
"Config
key = value
one
two
[image]
[table 1x2]"
`);
});
it('SCENARIO: a config written as a code block reads back byte-identical (diff empty)', () => {
const config =
'export TICKET_LIFETIME=$2592000\nservers:\n - www.internal.host';
const d = doc({
type: 'codeBlock',
attrs: { language: 'yaml' },
content: [text(config)],
});
// The code block is one block; its text (dollars, bare domain, newlines) is
// preserved verbatim, so a read-as-text of a stored config diffs empty.
expect(jsonToText(d, { deterministic: true })).toBe(config);
});
});
@@ -189,11 +189,10 @@ export function stepBudgetWarning(stepNumber: number): string {
//
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
// whole system prompt for the step. `activatedTools` is a closure Set grown by
// loadTools and owned by the streaming loop; the caller seeds it from and
// persists it to the chat's metadata across turns (#490), but this function only
// READS the Set it is handed, so it stays a pure function of its arguments (not
// module-global).
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
// in (not module-global, not persisted) so this stays a pure function of its
// arguments.
//
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
@@ -1411,11 +1410,10 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
const baseTools = { ...external.tools, ...docmostTools };
// Deferred tool loading state (#332), scoped to THIS streaming loop:
// - `activatedTools` is a fresh closure Set per streamText call (not
// module-global), SEEDED from the chat's persisted metadata.activatedTools
// (#490, just below) so activation carries across turns. loadTools.execute
// adds to it; prepareAgentStep reads it to widen `activeTools` on the NEXT
// step; turn end persists it back.
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
// per streamText call, NOT module-global and NOT persisted, so a new turn
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
// widen `activeTools` on the NEXT step.
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
// external tool is loadable by its namespaced name. loadTools rejects any
@@ -1,193 +0,0 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ApiKeyController } from './api-key.controller';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
/**
* Authorization contract for the /api-keys management surface.
*
* - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method)
* GitHub-PAT semantics closing post-revocation laundering.
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
* their own.
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
*/
function makeController(over: any = {}) {
const apiKeyService = {
create: jest.fn().mockResolvedValue({
token: 'tok',
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
}),
revoke: jest.fn().mockResolvedValue(undefined),
...(over.apiKeyService ?? {}),
};
const apiKeyRepo = {
findAllInWorkspace: jest.fn().mockResolvedValue([]),
findByCreator: jest.fn().mockResolvedValue([]),
findById: jest.fn(),
...(over.apiKeyRepo ?? {}),
};
const workspaceAbility = {
createForUser: jest.fn().mockReturnValue({
can: (_a: any, _s: any) => over.canManage ?? false,
}),
...(over.workspaceAbility ?? {}),
};
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
...(over.environmentService ?? {}),
};
const auditService = { log: jest.fn() };
const controller = new ApiKeyController(
apiKeyService as any,
apiKeyRepo as any,
workspaceAbility as any,
environmentService as any,
auditService as any,
);
return {
controller,
apiKeyService,
apiKeyRepo,
workspaceAbility,
environmentService,
auditService,
};
}
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const workspace = { id: 'ws-1' } as any;
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
const reqApiKey = () =>
({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any;
describe('ApiKeyController — a token cannot manage tokens', () => {
it('403 on create for an api_key principal', async () => {
const { controller, apiKeyService } = makeController();
await expect(
controller.create({ name: 'x' } as any, user, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
expect(apiKeyService.create).not.toHaveBeenCalled();
});
it('403 on list for an api_key principal', async () => {
const { controller } = makeController();
await expect(
controller.list(user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('403 on revoke for an api_key principal', async () => {
const { controller } = makeController();
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('ApiKeyController — kill-switch OFF → 404', () => {
it('create/list/revoke all 404 when disabled', async () => {
const { controller } = makeController({ enabled: false });
await expect(
controller.create({ name: 'x' } as any, user, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.list(user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ApiKeyController — list scoping', () => {
it('admin (Manage on API) lists ALL workspace keys', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: true });
await controller.list(user, workspace, reqAccess());
expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1');
expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled();
});
it('member lists ONLY their own keys', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: false });
await controller.list(user, workspace, reqAccess());
expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1');
expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled();
});
});
describe('ApiKeyController — revoke scoping', () => {
it("member cannot revoke another user's key (403)", async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: false,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'someone-else',
workspaceId: 'ws-1',
});
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(ForbiddenException);
expect(apiKeyService.revoke).not.toHaveBeenCalled();
});
it('member CAN revoke their own key', async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: false,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'u-1',
workspaceId: 'ws-1',
});
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
});
it("admin CAN revoke another user's key", async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: true,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'someone-else',
workspaceId: 'ws-1',
});
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
});
it('404 when the key does not exist', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: true });
apiKeyRepo.findById.mockResolvedValue(undefined);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ApiKeyController — create emits audit + returns token once', () => {
it('logs API_KEY_CREATED and returns the token + computed expiry', async () => {
const { controller, auditService } = makeController();
const res = await controller.create(
{ name: 'ci' } as any,
user,
reqAccess(),
);
expect(res.token).toBe('tok');
expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' });
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({ event: 'api_key.created' }),
);
});
});
// Sanity: the CASL subject used is the workspace API subject.
it('uses WorkspaceCaslSubject.API with the Manage action', () => {
expect(WorkspaceCaslSubject.API).toBe('api_key');
expect(WorkspaceCaslAction.Manage).toBeDefined();
});
@@ -1,201 +0,0 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
Logger,
NotFoundException,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FastifyRequest } from 'fastify';
import { ApiKeyService } from './api-key.service';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
@UseGuards(JwtAuthGuard)
@Controller('api-keys')
export class ApiKeyController {
private readonly logger = new Logger(ApiKeyController.name);
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly apiKeyRepo: ApiKeyRepo,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
// it must look like the feature does not exist.
private assertEnabled(): void {
if (!this.environmentService.isApiKeysEnabled()) {
throw new NotFoundException();
}
}
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
// refused on the management surface, so a leaked key cannot mint a replacement
// or revoke the keys that would lock it out (closes post-revocation laundering).
private rejectApiKeyPrincipal(req: FastifyRequest): void {
if ((req.raw as { authType?: string }).authType === 'api_key') {
throw new ForbiddenException('API keys cannot manage API keys');
}
}
private clientIp(req: FastifyRequest): string {
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
}
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
@Post('create')
async create(
@Body() dto: CreateApiKeyDto,
@AuthUser() user: User,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
// undefined -> service default (1 year); null -> unlimited; string -> Date.
const expiresAt =
dto.expiresAt === undefined
? undefined
: dto.expiresAt === null
? null
: new Date(dto.expiresAt);
const { token, key } = await this.apiKeyService.create(
user,
dto.name,
expiresAt,
);
this.auditService.log({
event: AuditEvent.API_KEY_CREATED,
resourceType: AuditResource.API_KEY,
resourceId: key.id,
});
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
// build). No token material — the JWT is only ever returned in the response.
this.logger.log(
`API key created: id=${key.id} name=${JSON.stringify(
key.name,
)} actor=${user.id} expiresAt=${
key.expiresAt ? new Date(key.expiresAt).toISOString() : 'never'
} ip=${this.clientIp(req)}`,
);
// Return the token ONCE (never retrievable again) and the computed expiry so
// the caller/UI can surface "expires <date>" (the year-default time-bomb
// early-warning).
return {
token,
apiKey: {
id: key.id,
name: key.name,
expiresAt: key.expiresAt,
createdAt: key.createdAt,
},
};
}
@HttpCode(HttpStatus.OK)
@Post('list')
async list(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
const ability = this.workspaceAbility.createForUser(user, workspace);
// Admin (CASL Manage on API): every key in the workspace, attributed to its
// creator (closes "a leaked key of a departed employee is invisible"). A
// member sees only their own.
const keys = ability.can(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.API,
)
? await this.apiKeyRepo.findAllInWorkspace(workspace.id)
: await this.apiKeyRepo.findByCreator(user.id, workspace.id);
return keys.map((k) => ({
id: k.id,
name: k.name,
expiresAt: k.expiresAt,
lastUsedAt: k.lastUsedAt,
createdAt: k.createdAt,
creator: k.creator,
}));
}
@HttpCode(HttpStatus.OK)
@Post('revoke')
async revoke(
@Body() dto: RevokeApiKeyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
// Uniform 404 for a missing/already-revoked key regardless of who asks — no
// existence oracle for keys the caller may not own.
if (!key) {
throw new NotFoundException();
}
const ability = this.workspaceAbility.createForUser(user, workspace);
const canManageAll = ability.can(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.API,
);
// Admin may revoke any key in the workspace; a member only their own.
if (!canManageAll && key.creatorId !== user.id) {
throw new ForbiddenException();
}
await this.apiKeyService.revoke(key.id, workspace.id);
this.auditService.log({
event: AuditEvent.API_KEY_DELETED,
resourceType: AuditResource.API_KEY,
resourceId: key.id,
});
this.logger.log(
`API key revoked: id=${key.id} name=${JSON.stringify(
key.name,
)} actor=${user.id} ip=${this.clientIp(req)}`,
);
return { success: true };
}
}
@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { ApiKeyController } from './api-key.controller';
import { TokenModule } from '../auth/token.module';
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
// DatabaseModule (global) provides ApiKeyRepo/UserRepo/WorkspaceRepo; CaslModule
// (global) provides WorkspaceAbilityFactory; TokenModule provides TokenService
// (the no-exp api-key signer). ApiKeyService is exported so AuthModule (for
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
// replacing the absent EE `ee/api-key` dynamic require.
@Module({
imports: [TokenModule],
controllers: [ApiKeyController],
providers: [ApiKeyService],
exports: [ApiKeyService],
})
export class ApiKeyModule {}
@@ -1,299 +0,0 @@
import { UnauthorizedException } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { JwtType } from '../auth/dto/jwt-payload';
/**
* Security contract for ApiKeyService.validate the single validator shared by
* jwt.strategy (REST) and the /mcp Bearer router.
*
* Invariants under test:
* - Anti-enumeration: every DEFINITE deny (missing/revoked/expired row, disabled
* user, workspace mismatch, kill-switch off) throws the SAME bare
* UnauthorizedException an agent cannot distinguish expired from revoked.
* - deny-on-decision / 5xx-on-infra: an UNEXPECTED (infra) error PROPAGATES as
* itself ( 5xx), never masked as a 401.
* - Expiry is read from the ROW, never a JWT exp claim.
* - No validate cache (each call re-reads the row immediate revocation).
*/
const APP_SECRET = 'secret';
function makeDeps(over: Partial<Record<string, any>> = {}) {
const apiKeyRepo = {
findById: jest.fn(),
insert: jest.fn(),
softDelete: jest.fn(),
touchLastUsed: jest.fn().mockResolvedValue(undefined),
...(over.apiKeyRepo ?? {}),
};
const userRepo = {
findById: jest.fn(),
...(over.userRepo ?? {}),
};
const workspaceRepo = {
findById: jest.fn().mockResolvedValue({ id: 'ws-1' }),
...(over.workspaceRepo ?? {}),
};
const tokenService = {
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
...(over.tokenService ?? {}),
};
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(true),
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
...(over.environmentService ?? {}),
};
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
apiKeyRepo,
userRepo,
workspaceRepo,
tokenService,
environmentService,
);
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
}
const payload = (over: Record<string, any> = {}) => ({
sub: 'u-1',
workspaceId: 'ws-1',
apiKeyId: 'key-1',
type: JwtType.API_KEY,
...over,
});
const activeRow = (over: Record<string, any> = {}) => ({
id: 'key-1',
name: 'k',
creatorId: 'u-1',
workspaceId: 'ws-1',
expiresAt: null,
lastUsedAt: null,
deletedAt: null,
...over,
});
const activeUser = (over: Record<string, any> = {}) => ({
id: 'u-1',
workspaceId: 'ws-1',
deactivatedAt: null,
deletedAt: null,
isAgent: false,
...over,
});
describe('ApiKeyService.validate', () => {
it('returns { user, workspace } for a valid active key', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
userRepo.findById.mockResolvedValue(activeUser());
const res = await service.validate(payload() as any);
expect(res.user).toMatchObject({ id: 'u-1' });
expect(res.workspace).toMatchObject({ id: 'ws-1' });
// Loads isAgent so downstream provenance does not silently degrade.
expect(userRepo.findById).toHaveBeenCalledWith('u-1', 'ws-1', {
includeIsAgent: true,
});
});
// --- Anti-enumeration: every deny is the SAME bare 401 -------------------
const denyCases: Array<[string, (d: ReturnType<typeof makeDeps>) => void]> = [
[
'missing/orphaned row',
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
],
[
'revoked (soft-deleted → findById returns undefined)',
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
],
[
'expired (expires_at in the past)',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
);
d.userRepo.findById.mockResolvedValue(activeUser());
},
],
[
'disabled user',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
d.userRepo.findById.mockResolvedValue(
activeUser({ deactivatedAt: new Date() }),
);
},
],
[
'user not found',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
d.userRepo.findById.mockResolvedValue(undefined);
},
],
[
'creator/sub mismatch',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'other' }));
d.userRepo.findById.mockResolvedValue(activeUser());
},
],
[
'workspace row missing',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
d.userRepo.findById.mockResolvedValue(activeUser());
d.workspaceRepo.findById.mockResolvedValue(undefined);
},
],
[
'kill-switch OFF',
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
],
[
'malformed payload (missing apiKeyId)',
() => undefined,
],
];
it.each(denyCases)(
'throws a bare UnauthorizedException with NO message for: %s',
async (label, arrange) => {
const deps = makeDeps();
arrange(deps);
const p =
label === 'malformed payload (missing apiKeyId)'
? (payload({ apiKeyId: undefined }) as any)
: (payload() as any);
const err = await deps.service.validate(p).catch((e) => e);
expect(err).toBeInstanceOf(UnauthorizedException);
// Anti-enumeration: identical, class-less message for every failure mode.
expect(err.message).toBe('Unauthorized');
},
);
it('kill-switch OFF denies BEFORE any DB read', async () => {
const { service, apiKeyRepo, environmentService } = makeDeps();
environmentService.isApiKeysEnabled.mockReturnValue(false);
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
});
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
it('PROPAGATES an infra (DB) error instead of masking it as 401', async () => {
const { service, apiKeyRepo } = makeDeps();
const boom = new Error('connection terminated');
apiKeyRepo.findById.mockRejectedValue(boom);
await expect(service.validate(payload() as any)).rejects.toBe(boom);
});
// --- No cache: revocation is immediate on the next call ------------------
it('re-reads the row on every call (no validate cache → immediate revocation)', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
userRepo.findById.mockResolvedValue(activeUser());
await service.validate(payload() as any);
// Now the key is revoked (row invisible) — the very next call denies.
apiKeyRepo.findById.mockResolvedValue(undefined);
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(apiKeyRepo.findById).toHaveBeenCalledTimes(2);
});
// --- last_used_at is throttled + fire-and-forget -------------------------
it('touches last_used_at when stale and skips when fresh', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
userRepo.findById.mockResolvedValue(activeUser());
// Stale (never used) -> touch.
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
await service.validate(payload() as any);
expect(apiKeyRepo.touchLastUsed).toHaveBeenCalledTimes(1);
// Fresh (used 1 minute ago) -> skip.
apiKeyRepo.touchLastUsed.mockClear();
apiKeyRepo.findById.mockResolvedValue(
activeRow({ lastUsedAt: new Date(Date.now() - 60_000) }),
);
await service.validate(payload() as any);
expect(apiKeyRepo.touchLastUsed).not.toHaveBeenCalled();
});
it('a failing last_used_at touch never fails the request', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
userRepo.findById.mockResolvedValue(activeUser());
apiKeyRepo.touchLastUsed.mockRejectedValue(new Error('write failed'));
await expect(service.validate(payload() as any)).resolves.toMatchObject({
user: { id: 'u-1' },
});
});
});
describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
it('mints the JWT BEFORE inserting the row and returns the token once', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
const order: string[] = [];
tokenService.generateApiToken.mockImplementation(async () => {
order.push('mint');
return 'tok';
});
apiKeyRepo.insert.mockImplementation(async (row: any) => {
order.push('insert');
return { ...row, createdAt: new Date() };
});
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const res = await service.create(user, 'my key');
expect(order).toEqual(['mint', 'insert']);
expect(res.token).toBe('tok');
// The id is generated before minting and reused for the row.
const mintArg = tokenService.generateApiToken.mock.calls[0][0];
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
expect(mintArg.apiKeyId).toBe(insertArg.id);
});
it('applies the 1-year default when expiresAt is undefined', async () => {
const { service, apiKeyRepo } = makeDeps();
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
await service.create(user, 'k');
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
const days =
(insertArg.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
expect(days).toBeGreaterThan(364);
expect(days).toBeLessThan(367);
});
it('honors an explicit null (unlimited)', async () => {
const { service, apiKeyRepo } = makeDeps();
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
await service.create(user, 'k', null);
expect(apiKeyRepo.insert.mock.calls[0][0].expiresAt).toBeNull();
});
it('does NOT insert a row when minting fails (mint-then-insert → inert)', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
tokenService.generateApiToken.mockRejectedValue(new Error('mint failed'));
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
await expect(service.create(user, 'k')).rejects.toThrow('mint failed');
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
});
});
@@ -1,191 +0,0 @@
import {
Injectable,
Logger,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { v7 as uuid7 } from 'uuid';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { TokenService } from '../auth/services/token.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../common/helpers';
// Default lifetime for a new key when the caller does not specify one: 1 year.
// The owner runs a homelab where agents live for years; forcing rotation is
// operational pain, so an explicit `null` (unlimited) is also allowed.
const DEFAULT_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
// last_used_at is a best-effort forensics stamp, not an access record; we only
// refresh it when it is older than this to avoid a write on every request. The
// 1h resolution is a deliberate constant (forensics granularity, not accounting).
const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
/**
* Core API-key lifecycle service. Owns minting (create), the single validator
* shared by BOTH the REST jwt.strategy path and the /mcp Bearer path (validate),
* and revocation (revoke). The `api_keys` ROW is the sole source of truth for a
* key's lifetime and revocation never the JWT, which carries no `exp` claim.
*/
@Injectable()
export class ApiKeyService implements OnModuleInit {
private readonly logger = new Logger(ApiKeyService.name);
constructor(
private readonly apiKeyRepo: ApiKeyRepo,
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
) {}
onModuleInit() {
// Boot log so the kill-switch state after each deploy is verifiable in logs.
const enabled = this.environmentService.isApiKeysEnabled();
const raw = this.environmentService.getApiKeysEnabledRaw();
this.logger.log(
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
raw ?? 'unset'
})`,
);
}
/**
* Mint a new key for `user`. mint-then-insert ordering (R1):
* 1. generate the id first it must be in the JWT payload before the row.
* 2. mint the JWT (no `exp` claim). A mint failure aborts before any row is
* written (inert), so a half-created key cannot exist.
* 3. insert the row last. A lost response leaves an orphaned row that is
* visible in `list` and self-heals (the user revokes it).
* The token is returned ONCE and never stored the JWT is self-contained, so
* no token material lives in the table.
*
* `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
* a Date -> that instant (a past date is rejected at the DTO layer).
*/
async create(
user: User,
name: string,
expiresAt?: Date | null,
): Promise<{ token: string; key: ApiKey }> {
const resolvedExpiresAt =
expiresAt === undefined
? new Date(Date.now() + DEFAULT_LIFETIME_MS)
: expiresAt;
const apiKeyId = uuid7();
const token = await this.tokenService.generateApiToken({
apiKeyId,
user,
workspaceId: user.workspaceId,
});
const key = await this.apiKeyRepo.insert({
id: apiKeyId,
name,
creatorId: user.id,
workspaceId: user.workspaceId,
expiresAt: resolvedExpiresAt,
});
return { token, key };
}
/**
* The single validator for an api-key principal, shared by jwt.strategy and the
* /mcp Bearer router. Returns `{ user, workspace }` (the same shape the access
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
* unchanged.
*
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact feature
* disabled, missing/revoked/expired row, workspace mismatch, disabled user
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
* an agent cannot distinguish expired from revoked, and its reaction is
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
* the surface returns 5xx, never a masked 401 (deny-on-decision / 5xx-on-infra).
* There is NO validate cache: it is 23 PK lookups (~1ms), two orders of
* magnitude cheaper than the bcrypt it replaces; a cache would only add a
* Date-serialization trap and a revocation lag. Revocation is immediate.
*/
async validate(
payload: JwtApiKeyPayload,
): Promise<{ user: User; workspace: Workspace }> {
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
// endpoints additionally 404 at the controller; here we deny the token.
if (!this.environmentService.isApiKeysEnabled()) {
throw new UnauthorizedException();
}
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
throw new UnauthorizedException();
}
const row = await this.apiKeyRepo.findById(
payload.apiKeyId,
payload.workspaceId,
);
// Absent row = revoked (soft-deleted, invisible to findById), orphaned
// (creator/workspace cascade-deleted), or never existed. All terminal deny.
if (!row) {
throw new UnauthorizedException();
}
// Expiry is read from the ROW, never an `exp` JWT claim.
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
throw new UnauthorizedException();
}
const user = await this.userRepo.findById(
payload.sub,
payload.workspaceId,
{ includeIsAgent: true },
);
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException();
}
// The key acts only as its creator (defence in depth against a token whose
// signed `sub` ever drifted from the row's owner).
if (row.creatorId !== user.id) {
throw new UnauthorizedException();
}
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) {
throw new UnauthorizedException();
}
// Best-effort, throttled, fire-and-forget forensics stamp AFTER all checks.
this.touchLastUsed(row);
return { user, workspace };
}
/**
* Revoke (soft-delete) a key. Authorization/ownership is decided by the caller
* (the controller, via CASL); this only performs the terminal write. Idempotent:
* a second revoke is a no-op (the row is already invisible).
*/
async revoke(id: string, workspaceId: string): Promise<void> {
await this.apiKeyRepo.softDelete(id, workspaceId);
}
// Throttled best-effort last_used_at bump: skip if it was touched within the
// window; otherwise fire-and-forget so a stamp write never fails or slows the
// request (mirrors SessionActivityService.trackActivity).
private touchLastUsed(row: ApiKey): void {
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
if (Date.now() - last < LAST_USED_THROTTLE_MS) return;
void this.apiKeyRepo.touchLastUsed(row.id).catch((err) => {
this.logger.warn(
`Failed to update api_key last_used_at for ${row.id}: ${
(err as Error)?.message ?? err
}`,
);
});
}
}
@@ -1,51 +0,0 @@
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
registerDecorator,
ValidationOptions,
} from 'class-validator';
/**
* `expiresAt` must be strictly in the future. Skips validation for `null`
* (explicit "unlimited") and `undefined` (server applies the 1-year default),
* so only an actually-supplied date is range-checked. Rejecting a PAST date at
* the DTO layer means a caller cannot mint an already-dead key.
*/
function IsFutureDateString(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isFutureDateString',
target: object.constructor,
propertyName,
options: {
message: 'expiresAt must be a date in the future',
...options,
},
validator: {
validate(value: unknown) {
if (value === null || value === undefined) return true;
if (typeof value !== 'string') return false;
const t = Date.parse(value);
return !Number.isNaN(t) && t > Date.now();
},
},
});
};
}
export class CreateApiKeyDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
// undefined -> default 1 year (applied server-side); null -> unlimited
// (explicit); an ISO date string -> that instant, which must be in the future.
@IsOptional()
@IsDateString()
@IsFutureDateString()
expiresAt?: string | null;
}
@@ -1,6 +0,0 @@
import { IsUUID } from 'class-validator';
export class RevokeApiKeyDto {
@IsUUID()
id: string;
}
@@ -20,71 +20,3 @@ describe('AuthController', () => {
expect(controller).toBeDefined();
});
});
// The collab-token handler is the ARM-SEAM of the #501 anti-laundering defense:
// it derives the api-key origin args ({ apiKeyId }) from the SIGNED-derived
// `req.raw` fields (stamped by jwt.strategy) and threads them into the collab
// token mint, forcing principal='api_key' so a later key revoke rejects NEW
// collab connections. A regression here (reading `body`, dropping `apiKeyId`,
// or inverting the ternary) would silently mint api-key requests as
// principal='session' and reopen the laundering hole with a green suite. These
// tests pin the EXACT args the controller passes to authService.getCollabToken.
describe('AuthController.collabToken arms the api-key origin (#501)', () => {
let controller: AuthController;
let getCollabToken: jest.Mock;
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const workspace = { id: 'ws-1' } as any;
beforeEach(() => {
getCollabToken = jest.fn().mockResolvedValue({ token: 'ct' });
controller = new AuthController(
{ getCollabToken } as any, // authService
{} as any, // sessionService
{} as any, // environmentService
{} as any, // moduleRef
{} as any, // auditService
);
});
it('threads { apiKeyId } when req.raw marks an api_key principal (ARMED)', async () => {
const req = { raw: { authType: 'api_key', apiKeyId: 'k-1' } } as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledTimes(1);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', {
apiKeyId: 'k-1',
});
});
it('passes undefined for a normal session request (NOT armed)', async () => {
const req = { raw: {} } as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledTimes(1);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
});
it('does NOT arm when api_key authType lacks an apiKeyId', async () => {
const req = { raw: { authType: 'api_key' } } as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
});
it('reads the SIGNED req.raw, never a spoofable client body', async () => {
// A client-supplied body claiming api_key must be ignored: only the
// jwt.strategy-stamped req.raw can arm the api-key origin.
const req = {
raw: {},
body: { authType: 'api_key', apiKeyId: 'attacker' },
} as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
});
});
+1 -13
View File
@@ -207,20 +207,8 @@ export class AuthController {
async collabToken(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
// Thread the api-key origin (#501): when the requester authenticated with an
// api key (jwt.strategy stamped req.raw.authType/apiKeyId), the minted collab
// token carries principal='api_key' + apiKeyId so a later revoke of the key
// rejects NEW collab connections. A normal session request mints a
// principal='session' token. Reading the SIGNED-derived req.raw fields (never
// a client body) keeps it unspoofable.
const raw = req.raw as { authType?: string; apiKeyId?: string };
const apiKey =
raw.authType === 'api_key' && raw.apiKeyId
? { apiKeyId: raw.apiKeyId }
: undefined;
return this.authService.getCollabToken(user, workspace.id, apiKey);
return this.authService.getCollabToken(user, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
+1 -5
View File
@@ -5,13 +5,9 @@ import { JwtStrategy } from './strategies/jwt.strategy';
import { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service';
import { TokenModule } from './token.module';
import { ApiKeyModule } from '../api-key/api-key.module';
@Module({
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
// api_key Bearer/cookie token is validated directly (replacing the absent EE
// `ee/api-key` dynamic require).
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
imports: [TokenModule, WorkspaceModule],
controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy],
exports: [SignupService, AuthService],
@@ -33,17 +33,6 @@ export type JwtPayload = {
aiChatId?: string | null;
};
// The AUTH-PRINCIPAL kind behind a collab token — distinct from `actor`
// (provenance). Stamped into EVERY newly-minted collab token (#501): 'session'
// for a normal user/session (incl. the internal AI agent, which is session-
// backed), 'api_key' when the token was minted by an api-key principal (an
// external MCP agent). The discriminator keys on the token's ORIGIN, so the
// collab seam can re-check a revoked api key on connect and reject a claimless
// token after the rollout grace window. NOT keyed on `actor:'agent'` — the
// internal agent is 'agent' but session-backed, so it must stay on the no-check
// path.
export type CollabPrincipal = 'session' | 'api_key';
export type JwtCollabPayload = {
sub: string;
workspaceId: string;
@@ -55,13 +44,6 @@ export type JwtCollabPayload = {
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
// an 'agent' actor with a null aiChatId.
aiChatId?: string | null;
// Auth-principal discriminator (#501). Present on every post-rollout token;
// its absence on a still-valid token past the grace window is treated as an
// error, not trust (fail-closed).
principal?: CollabPrincipal;
// Only when principal === 'api_key': the minting key's id, so the collab seam
// can row-check (and reject) a revoked key on connect.
apiKeyId?: string;
};
export type JwtExchangePayload = {
@@ -375,20 +375,10 @@ export class AuthService {
}
}
async getCollabToken(
user: User,
workspaceId: string,
// Origin of the request minting this collab token (#501). When the caller is
// an api-key principal, its apiKeyId is threaded into the token so the collab
// seam can re-check the key on connect (closing api-key -> long-lived-collab
// laundering). Absent for a normal session/human request.
apiKey?: { apiKeyId: string },
) {
async getCollabToken(user: User, workspaceId: string) {
const token = await this.tokenService.generateCollabToken(
user,
workspaceId,
undefined,
apiKey,
);
return { token };
}
@@ -1,5 +1,4 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { TokenService } from './token.service';
import { JwtType } from '../dto/jwt-payload';
@@ -214,175 +213,4 @@ describe('TokenService.generateCollabToken', () => {
aiChatId: 'chat-456',
});
});
// #501 fail-closed discriminator: EVERY collab token carries a principal.
it("defaults principal to 'session' with NO apiKeyId (normal/internal-agent path)", async () => {
const { service, jwtService } = makeTokenService();
await service.generateCollabToken(makeUser() as never, 'ws-1');
const [payload] = jwtService.sign.mock.calls[0];
expect(payload.principal).toBe('session');
expect(payload).not.toHaveProperty('apiKeyId');
});
it("the internal agent (provenance, NO apiKey) still gets principal='session'", async () => {
const { service, jwtService } = makeTokenService();
await service.generateCollabToken(
makeUser() as never,
'ws-1',
{ actor: 'agent', aiChatId: 'chat-1' },
);
const [payload] = jwtService.sign.mock.calls[0];
// Keyed on api-key ORIGIN, not actor: an is_agent session token is 'session'.
expect(payload.principal).toBe('session');
expect(payload).not.toHaveProperty('apiKeyId');
});
it("stamps principal='api_key' + apiKeyId when minted by an api-key principal", async () => {
const { service, jwtService } = makeTokenService();
await service.generateCollabToken(
makeUser() as never,
'ws-1',
undefined,
{ apiKeyId: 'key-9' },
);
const [payload] = jwtService.sign.mock.calls[0];
expect(payload.principal).toBe('api_key');
expect(payload.apiKeyId).toBe('key-9');
});
});
/**
* API-key token minting MUST carry NO `exp` claim the ONLY source of truth for
* a key's lifetime/revocation is its `api_keys` row, checked on every request.
*
* This is a LIVE-bug regression: the shared JwtService is registered with a
* global `signOptions.expiresIn` (default '90d') that merges into every sign(),
* so an api-key minted through it silently gets exp=now+90d and an "unlimited"
* key dies in 90 days. TokenService.generateApiToken mints through a dedicated
* no-expiry signer instead. These tests construct the REAL signer (a real secret
* via the stubbed EnvironmentService) and decode the produced JWT to assert the
* observable property: no `exp`.
*/
describe('TokenService.generateApiToken (no exp claim ever)', () => {
const APP_SECRET_LOCAL = 'apikey-secret';
function makeRealSignerService() {
// Give the SHARED jwtService a global expiresIn so a regression (minting
// through it) would show up as an exp claim — the exact live bug.
const { JwtService } = require('@nestjs/jwt');
const sharedJwt = new JwtService({
secret: APP_SECRET_LOCAL,
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
});
const environmentService = {
getAppSecret: () => APP_SECRET_LOCAL,
};
const service = new (TokenService as unknown as new (
...args: unknown[]
) => TokenService)(sharedJwt, environmentService);
return { service };
}
const user = makeUser({ id: 'svc-1', workspaceId: 'ws-1' });
it('mints an api-key JWT with NO exp claim and issuer Docmost', async () => {
const { service } = makeRealSignerService();
const token = await service.generateApiToken({
apiKeyId: 'key-1',
user: user as never,
workspaceId: 'ws-1',
});
const decoded = jwt.decode(token) as Record<string, unknown>;
// The observable security property: no expiry lives in the JWT.
expect(decoded.exp).toBeUndefined();
expect(decoded).toMatchObject({
sub: 'svc-1',
apiKeyId: 'key-1',
workspaceId: 'ws-1',
type: JwtType.API_KEY,
iss: 'Docmost',
});
});
it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
const { JwtService } = require('@nestjs/jwt');
const sharedJwt = new JwtService({
secret: APP_SECRET_LOCAL,
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
});
// Even with empty per-call options the global expiresIn merges in.
const leaky = sharedJwt.sign({ sub: 'x', type: JwtType.API_KEY }, {});
expect((jwt.decode(leaky) as Record<string, unknown>).exp).toBeDefined();
});
it('refuses to mint for a disabled user', async () => {
const { service } = makeRealSignerService();
await expect(
service.generateApiToken({
apiKeyId: 'key-1',
user: makeUser({ deactivatedAt: new Date() }) as never,
workspaceId: 'ws-1',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
/**
* verifyJwtOneOf is the type-routing primitive: verify the signature once and
* assert the token type is on an explicit allowlist. It must NOT degrade into a
* "return whatever type" helper a token whose type is off the allowlist is
* rejected with the same generic error as a single-type mismatch.
*/
describe('TokenService.verifyJwtOneOf (allowlist type-routing)', () => {
it('returns the payload when the type is on the allowlist', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.API_KEY, sub: 'u-1' });
const { service } = makeTokenService({ verifyAsync });
const payload = await service.verifyJwtOneOf(token123(), [
JwtType.ACCESS,
JwtType.API_KEY,
]);
expect(payload).toMatchObject({ type: JwtType.API_KEY, sub: 'u-1' });
expect(verifyAsync).toHaveBeenCalledTimes(1);
});
it('accepts the OTHER allowed type too', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.ACCESS, sub: 'u-1' });
const { service } = makeTokenService({ verifyAsync });
await expect(
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
).resolves.toMatchObject({ type: JwtType.ACCESS });
});
it('rejects a token whose type is OFF the allowlist (confused-deputy guard)', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.COLLAB, sub: 'u-1' });
const { service } = makeTokenService({ verifyAsync });
await expect(
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('verifies the signature exactly ONCE', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.ACCESS });
const { service } = makeTokenService({ verifyAsync });
await service.verifyJwtOneOf(token123(), [JwtType.ACCESS]);
expect(verifyAsync).toHaveBeenCalledTimes(1);
});
});
function token123(): string {
return 'a.b.c';
}
@@ -4,6 +4,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtApiKeyPayload,
@@ -61,12 +62,6 @@ export class TokenService {
// token carries no actor/aiChatId and is treated as 'user' downstream.
// aiChatId is nullable for an external agent with no internal ai_chats row.
provenance?: { actor: 'agent'; aiChatId: string | null },
// Optional api-key origin (#501). When the collab token is minted by an
// api-key principal (an external MCP agent), the caller passes the key id so
// the token carries principal='api_key' + apiKeyId and the collab seam can
// re-check the key on connect. Absent -> principal='session' (a normal
// user/session, including the internal session-backed AI agent).
apiKey?: { apiKeyId: string },
): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();
@@ -76,10 +71,6 @@ export class TokenService {
sub: user.id,
workspaceId,
type: JwtType.COLLAB,
// Fail-closed discriminator on EVERY minted token: 'api_key' when minted by
// an api-key principal, else 'session'.
principal: apiKey ? 'api_key' : 'session',
...(apiKey ? { apiKeyId: apiKey.apiKeyId } : {}),
...(provenance
? { actor: provenance.actor, aiChatId: provenance.aiChatId }
: {}),
@@ -132,8 +123,9 @@ export class TokenService {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: StringValue | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId } = opts;
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -145,32 +137,7 @@ export class TokenService {
type: JwtType.API_KEY,
};
// API-key tokens carry NO `exp` claim EVER — the ONLY source of truth for a
// key's lifetime and revocation is its `api_keys` row (checked on every
// request), not the JWT. This CANNOT use `this.jwtService`: TokenModule
// registers it with a global `signOptions.expiresIn` (JWT_TOKEN_EXPIRES_IN,
// default '90d'), which merges into EVERY sign() call — even `sign(payload,
// {})` — and `{ expiresIn: undefined }` THROWS rather than stripping it
// (verified empirically). So an "unlimited" key minted through the shared
// signer would silently get exp=now+90d and die in 90 days regardless of its
// row. We mint through a dedicated no-expiry signer, re-stamping only
// `issuer: 'Docmost'` for claim parity with the shared signer.
return this.apiKeyJwtService().sign(payload);
}
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost'
// issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
// `exp` claim. Built once and cached. Verification still goes through the
// shared verifier (same secret); `verifyAsync` does not require an `exp`.
private _apiKeyJwtService?: JwtService;
private apiKeyJwtService(): JwtService {
if (!this._apiKeyJwtService) {
this._apiKeyJwtService = new JwtService({
secret: this.environmentService.getAppSecret(),
signOptions: { issuer: 'Docmost' },
});
}
return this._apiKeyJwtService;
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async generatePdfRenderToken(
@@ -210,31 +177,4 @@ export class TokenService {
return payload;
}
/**
* Verify a token's signature ONCE and assert its `type` is one of `allowed`.
*
* This is the type-routing primitive for surfaces that legitimately accept
* more than one token type on the same Bearer slot (the /mcp Bearer path
* accepts both an ACCESS and an API_KEY token). It is deliberately NOT a
* "verify-and-return-whatever-type" helper that would be a reusable
* confused-deputy footgun (any caller could then feed an attachment/collab
* token where an access token is expected). An explicit allowlist preserves
* the type-pinning property of `verifyJwt`: a token whose `type` is not in the
* allowlist is rejected with the SAME generic error as a type mismatch, and
* the signature is verified exactly once (no double-verify).
*/
async verifyJwtOneOf(token: string, allowed: JwtType[]) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
});
if (!allowed.includes(payload.type)) {
throw new UnauthorizedException(
'Invalid JWT token. Token type does not match.',
);
}
return payload;
}
}
@@ -22,8 +22,7 @@ describe('JwtStrategy — provenance derivation', () => {
const userSessionRepo: any = { findActiveById: jest.fn() };
const sessionActivityService: any = { trackActivity: jest.fn() };
const environmentService: any = { getAppSecret: () => 'test-secret' };
// ACCESS-path tests never touch the api-key seam; a bare stub suffices.
const apiKeyService: any = { validate: jest.fn() };
const moduleRef: any = {};
const strategy = new JwtStrategy(
userRepo,
@@ -31,7 +30,7 @@ describe('JwtStrategy — provenance derivation', () => {
userSessionRepo,
sessionActivityService,
environmentService,
apiKeyService,
moduleRef,
);
return { strategy, userRepo };
}
@@ -123,29 +122,25 @@ describe('JwtStrategy — provenance derivation', () => {
});
/**
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486
* + #501).
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486).
*
* The access-token path stamped provenance; the API-key path returned early
* WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker.
* The API-key payload carries no signed claim, so provenance is resolved from the
* SERVER-SIDE user returned by ApiKeyService.validate: isAgent -> 'agent',
* SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent',
* otherwise 'user'; aiChatId is always null (an API key has no ai_chats row).
*
* #501 wires the CORE ApiKeyService (the EE `ee/api-key` module is absent in the
* fork) directly into the strategy no dynamic require. The strategy also stamps
* `req.raw.authType='api_key'` + `req.raw.apiKeyId` for the "a token cannot manage
* tokens" guard on the /api-keys surface.
* The enterprise ApiKeyService is not bundled in the OSS build, so the strategy
* loads it through an overridable `resolveApiKeyService` seam that we stub here.
*/
describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
function makeApiKeyStrategy(validateImpl: (p: any) => Promise<any>) {
describe('JwtStrategy — API-key provenance derivation (#486)', () => {
function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise<any>) {
const userRepo: any = { findById: jest.fn() };
const workspaceRepo: any = { findById: jest.fn() };
const userSessionRepo: any = { findActiveById: jest.fn() };
const sessionActivityService: any = { trackActivity: jest.fn() };
const environmentService: any = { getAppSecret: () => 'test-secret' };
const validate = jest.fn(validateImpl);
const apiKeyService: any = { validate };
const moduleRef: any = {};
const strategy = new JwtStrategy(
userRepo,
@@ -153,9 +148,14 @@ describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
userSessionRepo,
sessionActivityService,
environmentService,
apiKeyService,
moduleRef,
);
return { strategy, validate };
// Stub the EE ApiKeyService seam (the real module is not in the OSS build).
const validateApiKey = jest.fn(validateApiKeyImpl);
jest
.spyOn(strategy as any, 'resolveApiKeyService')
.mockReturnValue({ validateApiKey });
return { strategy, validateApiKey };
}
const makeReq = () => ({ raw: {} as Record<string, any> });
@@ -166,23 +166,22 @@ describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
type: JwtType.API_KEY,
});
it("stamps actor='agent' + authType/apiKeyId for an is_agent API key", async () => {
it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => {
const validated = {
user: { id: 'svc-1', isAgent: true },
workspace: { id: 'ws-1' },
};
const { strategy, validate } = makeApiKeyStrategy(async () => validated);
const { strategy, validateApiKey } = makeApiKeyStrategy(
async () => validated,
);
const req = makeReq();
const result = await strategy.validate(req, apiKeyPayload() as any);
expect(validate).toHaveBeenCalledTimes(1);
expect(validateApiKey).toHaveBeenCalledTimes(1);
expect(req.raw.actor).toBe('agent');
// API keys carry no internal ai_chats row -> null.
expect(req.raw.aiChatId).toBeNull();
// Principal-kind markers for the management-surface guard.
expect(req.raw.authType).toBe('api_key');
expect(req.raw.apiKeyId).toBe('key-1');
// The validated auth object is returned unchanged (req.user shape preserved).
expect(result).toBe(validated);
});
@@ -198,19 +197,25 @@ describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
expect(req.raw.actor).toBe('user');
expect(req.raw.aiChatId).toBeNull();
expect(req.raw.authType).toBe('api_key');
});
it('propagates a validate() rejection and stamps nothing', async () => {
const { strategy } = makeApiKeyStrategy(async () => {
throw new UnauthorizedException();
});
it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => {
const userRepo: any = { findById: jest.fn() };
const strategy = new JwtStrategy(
userRepo,
{ findById: jest.fn() } as any,
{ findActiveById: jest.fn() } as any,
{ trackActivity: jest.fn() } as any,
{ getAppSecret: () => 'test-secret' } as any,
{} as any,
);
// EE not bundled: the seam returns null.
jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null);
const req = makeReq();
await expect(
strategy.validate(req, apiKeyPayload() as any),
).rejects.toThrow(UnauthorizedException);
expect(req.raw.actor).toBeUndefined();
expect(req.raw.authType).toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@@ -9,18 +9,20 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { SessionActivityService } from '../../session/session-activity.service';
import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
import { resolveProvenance } from '../../../common/decorators/auth-provenance.decorator';
import { ApiKeyService } from '../../api-key/api-key.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private logger = new Logger('JwtStrategy');
constructor(
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
private userSessionRepo: UserSessionRepo,
private sessionActivityService: SessionActivityService,
private readonly environmentService: EnvironmentService,
private readonly apiKeyService: ApiKeyService,
private moduleRef: ModuleRef,
) {
super({
jwtFromRequest: (req: FastifyRequest) => {
@@ -100,17 +102,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
// The fork ships the core `ApiKeyService` (the EE `ee/api-key` module is
// absent). `validate` throws a bare UnauthorizedException on any definite
// deny (missing/revoked/expired row, disabled user, kill-switch off) and
// propagates infra errors (→ 5xx) rather than masking them as a 401.
const result = await this.apiKeyService.validate(payload);
const apiKeyService = this.resolveApiKeyService();
if (!apiKeyService) {
throw new UnauthorizedException('Enterprise API Key module missing');
}
// Stamp the principal kind + key id so the /api-keys management surface can
// enforce "a token cannot manage tokens". Done in this branch because it
// returns before the shared ACCESS-path stamping below.
req.raw.authType = 'api_key';
req.raw.apiKeyId = payload.apiKeyId;
const result = await apiKeyService.validateApiKey(payload);
// Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the
// access-token path above, it CANNOT be resolved before this point: the
@@ -122,10 +119,32 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
// SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable
// — mirroring the access-token path. Passing `null` for the claim means the
// actor is decided solely by user.isAgent.
const provenance = resolveProvenance(result.user, null);
const provenance = resolveProvenance((result as any)?.user, null);
req.raw.actor = provenance.actor;
req.raw.aiChatId = provenance.aiChatId;
return result;
}
/**
* Resolve the enterprise ApiKeyService, or `null` when the EE module is not
* bundled in this build (community build). Extracted as an overridable seam so
* the API-key provenance stamping can be unit-tested without the EE package
* present (docmost is OSS + a separate EE bundle; `require` of the EE path
* throws here). Any load/resolve error is treated as "module missing".
*/
protected resolveApiKeyService(): {
validateApiKey: (payload: JwtApiKeyPayload) => Promise<unknown>;
} | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ApiKeyModule = require('./../../../ee/api-key/api-key.service');
return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
} catch (err) {
this.logger.debug(
'API Key module requested but enterprise module not bundled in this build',
);
return null;
}
}
}
-2
View File
@@ -6,7 +6,6 @@ import {
} from '@nestjs/common';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
import { ApiKeyModule } from './api-key/api-key.module';
import { WorkspaceModule } from './workspace/workspace.module';
import { PageModule } from './page/page.module';
import { AttachmentModule } from './attachment/attachment.module';
@@ -30,7 +29,6 @@ import { ClsMiddleware } from 'nestjs-cls';
imports: [
UserModule,
AuthModule,
ApiKeyModule,
WorkspaceModule,
PageModule,
AttachmentModule,
@@ -10,12 +10,6 @@ import { Transform } from 'class-transformer';
export type ContentFormat = 'json' | 'markdown' | 'html';
// READ-only rendering formats for `getPage` (#502). A superset of the writable
// `ContentFormat` with `text` — a flat, deterministic, machine-diffable text
// rendering — added. Kept SEPARATE from `ContentFormat` so the write path
// (createPage/updatePage `parseProsemirrorContent`) can never be handed `text`.
export type PageReadFormat = ContentFormat | 'text';
export class CreatePageDto {
@IsOptional()
@IsString()
+3 -3
View File
@@ -8,7 +8,7 @@ import {
} from 'class-validator';
import { Transform } from 'class-transformer';
import { PageReadFormat } from './create-page.dto';
import { ContentFormat } from './create-page.dto';
import { IsPageIdOrSlugId } from './page-identity.validator';
export class PageIdDto {
@@ -43,8 +43,8 @@ export class PageInfoDto extends PageIdDto {
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['json', 'markdown', 'html', 'text'])
format?: PageReadFormat;
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
}
export class DeletePageDto extends PageIdDto {
@@ -1,76 +0,0 @@
import { NotFoundException } from '@nestjs/common';
import { PageController } from './page.controller';
import { jsonToText, jsonToMarkdown } from '../../collaboration/collaboration.util';
// #502 READ: getPage `format:"text"` routes through the deterministic jsonToText
// path (placeholders for non-text nodes, block-per-line), while `format:"json"`
// (or none) returns the raw content and `format:"markdown"` still converts. This
// pins the CONTROLLER wiring with lightweight mocks (no DB needed — the jsonToText
// output contract itself is pinned in json-to-text-deterministic.spec.ts).
const CONTENT = {
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Config' }] },
{
type: 'paragraph',
content: [
{ type: 'text', text: 'key=', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'value' },
],
},
{ type: 'image', attrs: { src: 's' } },
],
};
function makeController(page: any): PageController {
const pageRepo = { findById: jest.fn().mockResolvedValue(page) } as any;
const pageAccessService = {
validateCanViewWithPermissions: jest
.fn()
.mockResolvedValue({ canEdit: true, hasRestriction: false }),
} as any;
// Only pageRepo + pageAccessService are exercised by getPage; the rest are
// never touched on this path, so undefined placeholders are fine.
return new PageController(
undefined as any, // pageService
pageRepo,
undefined as any, // pageHistoryService
undefined as any, // spaceAbility
pageAccessService,
undefined as any, // backlinkService
undefined as any, // labelService
undefined as any, // auditService
);
}
const user = { id: 'u1' } as any;
describe('PageController.getPage — format:"text" (#502)', () => {
it('returns deterministic flat text with placeholders for non-text nodes', async () => {
const controller = makeController({ id: 'p1', content: CONTENT });
const res: any = await controller.getPage({ pageId: 'p1', format: 'text' } as any, user);
expect(res.content).toBe(jsonToText(CONTENT, { deterministic: true }));
expect(res.content).toBe('Config\nkey=value\n[image]');
expect(res.permissions).toEqual({ canEdit: true, hasRestriction: false });
});
it('markdown format still converts to markdown (unchanged)', async () => {
const controller = makeController({ id: 'p1', content: CONTENT });
const res: any = await controller.getPage({ pageId: 'p1', format: 'markdown' } as any, user);
expect(res.content).toBe(jsonToMarkdown(CONTENT));
});
it('json / no format returns the raw ProseMirror content object', async () => {
const controller = makeController({ id: 'p1', content: CONTENT });
const res: any = await controller.getPage({ pageId: 'p1' } as any, user);
expect(res.content).toEqual(CONTENT); // untouched object, not a string
});
it('missing page -> NotFoundException', async () => {
const controller = makeController(null);
await expect(
controller.getPage({ pageId: 'nope', format: 'text' } as any, user),
).rejects.toBeInstanceOf(NotFoundException);
});
});
+4 -11
View File
@@ -49,7 +49,6 @@ import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
import {
jsonToHtml,
jsonToMarkdown,
jsonToText,
} from '../../collaboration/collaboration.util';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
@@ -94,16 +93,10 @@ export class PageController {
const permissions = { canEdit, hasRestriction };
if (dto.format && dto.format !== 'json' && page.content) {
let contentOutput: string;
if (dto.format === 'markdown') {
contentOutput = jsonToMarkdown(page.content);
} else if (dto.format === 'text') {
// #502: flat, deterministic, machine-diffable text (block-per-line,
// inline marks/anchors dropped, non-text nodes -> stable placeholders).
contentOutput = jsonToText(page.content, { deterministic: true });
} else {
contentOutput = jsonToHtml(page.content);
}
const contentOutput =
dto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return {
...page,
content: contentOutput,
@@ -37,7 +37,6 @@ import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
@@ -130,7 +129,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
AiMcpServerRepo,
AiAgentRoleRepo,
PageEmbeddingRepo,
ApiKeyRepo,
PageListener,
],
exports: [
@@ -166,7 +164,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
AiMcpServerRepo,
AiAgentRoleRepo,
PageEmbeddingRepo,
ApiKeyRepo,
],
})
export class DatabaseModule implements OnApplicationBootstrap {
@@ -1,110 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { DB, Users } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils';
import { ApiKey, InsertableApiKey } from '@docmost/db/types/entity.types';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely';
// Public-facing shape: the api_keys row plus a compact creator attribution used
// by the admin list (the workspace-wide view attributes each key to its author).
export type ApiKeyWithCreator = ApiKey & {
creator: Pick<Users, 'id' | 'name' | 'email' | 'avatarUrl'> | null;
};
@Injectable()
export class ApiKeyRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
// Compact creator attribution embedded in the admin list rows. A nested
// subquery (jsonObjectFrom) keeps it one round-trip; the token material is
// never stored, so nothing sensitive is joined in.
private withCreator(eb: ExpressionBuilder<DB, 'apiKeys'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.email', 'users.avatarUrl'])
.whereRef('users.id', '=', 'apiKeys.creatorId'),
).as('creator');
}
async findById(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ApiKey | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('apiKeys')
.selectAll()
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
// Convention (soft-delete): a revoked key is invisible to reads.
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async findByCreator(
creatorId: string,
workspaceId: string,
): Promise<ApiKeyWithCreator[]> {
return this.db
.selectFrom('apiKeys')
.selectAll('apiKeys')
.select((eb) => this.withCreator(eb))
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.execute() as unknown as Promise<ApiKeyWithCreator[]>;
}
async findAllInWorkspace(
workspaceId: string,
): Promise<ApiKeyWithCreator[]> {
return this.db
.selectFrom('apiKeys')
.selectAll('apiKeys')
.select((eb) => this.withCreator(eb))
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.execute() as unknown as Promise<ApiKeyWithCreator[]>;
}
async insert(
insertable: InsertableApiKey,
trx?: KyselyTransaction,
): Promise<ApiKey> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('apiKeys')
.values(insertable)
.returningAll()
.executeTakeFirst();
}
// Soft-delete = revoke. Terminal: the row stays for forensics but is invisible
// to every read above, so validate() denies it immediately (no grace window).
async softDelete(id: string, workspaceId: string): Promise<void> {
await this.db
.updateTable('apiKeys')
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.execute();
}
// Throttled best-effort forensics stamp. Not on the critical path: callers
// fire-and-forget and swallow errors, so it never fails a request.
async touchLastUsed(id: string): Promise<void> {
await this.db
.updateTable('apiKeys')
.set({ lastUsedAt: new Date() })
.where('id', '=', id)
.where('deletedAt', 'is', null)
.execute();
}
}
@@ -70,23 +70,6 @@ export class EnvironmentService {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
// Kill-switch for the agent API-key feature. Default ON (unset -> enabled): a
// deploy that never sets the variable must NOT silently kill every agent. The
// parse is STRICT — the value is validated by environment.validation to be
// exactly 'true' or 'false' (or absent), so a typo like `=0`/`=off`/`=False`
// fails at boot rather than being read as "enabled" (which would leave an
// operator who yanked the switch during an incident with it still on). When
// OFF: validate() denies every api-key token and the issuance endpoints 404.
isApiKeysEnabled(): boolean {
return this.configService.get<string>('API_KEYS_ENABLED', 'true') !== 'false';
}
// Raw value (or undefined) for the boot log, so the state after each deploy is
// verifiable in container logs: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`.
getApiKeysEnabledRaw(): string | undefined {
return this.configService.get<string>('API_KEYS_ENABLED');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
@@ -103,15 +103,6 @@ export class EnvironmentVariables {
@IsString()
TYPESENSE_LOCALE: string;
// Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the
// literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot
// instead of being silently read as "enabled" — the switch must actually flip
// when an operator flips it. See EnvironmentService.isApiKeysEnabled.
@IsOptional()
@IsIn(['true', 'false'])
@IsString()
API_KEYS_ENABLED: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@@ -85,14 +85,6 @@ export class ImportController {
throw new BadRequestException('spaceId is required');
}
// #502: optional multipart field. Only the MCP agent `createPage` path sends
// `disableMarkdownExtensions=true` (its body is agent-authored plain prose /
// config, so a `$…$` span must stay literal and a bare `www.host` must not
// autolink). A HUMAN file upload omits the field, so it stays false and math
// + autolink remain ON for human imports. Settable ONLY via this API param.
const disableMarkdownExtensions =
file.fields?.disableMarkdownExtensions?.value === 'true';
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
@@ -103,7 +95,6 @@ export class ImportController {
user.id,
spaceId,
workspace.id,
disableMarkdownExtensions,
);
const ext = path.extname(file.filename).toLowerCase();
@@ -1,131 +0,0 @@
// p-limit and @sindresorhus/slugify are ESM-only and not in jest's transform
// allowlist; both are irrelevant to createDrawioSvg (a pure fs + string method),
// so they are mocked out to keep the module graph loadable under ts-jest.
jest.mock('p-limit', () => ({
__esModule: true,
default: () => (fn: () => unknown) => fn(),
}));
jest.mock('@sindresorhus/slugify', () => ({
__esModule: true,
default: (input: string) => String(input),
}));
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ImportAttachmentService } from './import-attachment.service';
/**
* Unit test for ImportAttachmentService.createDrawioSvg (issue #507).
*
* The Confluence import wraps a `.drawio` file into a `.drawio.svg` attachment.
* The `content=` payload MUST be the mxfile XML entity-escaped (draw.io's native
* form), NOT base64 draw.io's editor decodes a base64 content= via Latin-1
* atob, mangling every non-ASCII char into mojibake. createDrawioSvg touches no
* injected dependency, so the service is built with placeholder deps.
*/
describe('ImportAttachmentService.createDrawioSvg (#507)', () => {
const service = new ImportAttachmentService(
{} as any,
{} as any,
{} as any,
);
const call = (p: string): Promise<Buffer> =>
(service as any).createDrawioSvg(p);
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'drawio-507-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
const writeDrawio = async (name: string, xml: string): Promise<string> => {
const p = path.join(tmpDir, name);
await fs.writeFile(p, xml, 'utf-8');
return p;
};
it('writes content= as entity-encoded XML, not base64', async () => {
const drawio =
'<mxfile host="Confluence"><diagram name="Схема — ёж">' +
'<mxGraphModel><root><mxCell id="0"/>' +
'<mxCell id="2" value="Старт-бит" vertex="1" parent="0"/>' +
'</root></mxGraphModel></diagram></mxfile>';
const p = await writeDrawio('cyrillic.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// Entity-encoded XML form, starting with &lt;mxfile — never a base64 blob.
expect(content).toMatch(/^&lt;mxfile/);
expect(content).toContain('&lt;');
// Non-ASCII survives as raw UTF-8, with no Latin-1 mojibake.
expect(content).toContain('Старт-бит');
expect(content).toContain('Схема — ёж');
expect(content).not.toContain('Ð');
// Decoding the attribute (un-escaping) yields the original drawio file.
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
it('encodes literal tab/newline/CR as numeric char-refs, not literal control chars (#507 F1)', async () => {
// A literal tab/newline/CR inside the mxfile XML would be collapsed to a
// single space by XML attribute-value normalization when the draw.io editor
// reads content=, silently flattening multi-line labels and tab-bearing
// values. They must be emitted as numeric char-refs instead.
const drawio =
'<mxfile><diagram name="p">' +
'<mxGraphModel><root><mxCell id="0"/>' +
'<mxCell id="2" value="col1\tcol2" style="html=1;\nshadow=0" vertex="1" parent="0"/>' +
'<mxCell id="3" value="Line1\nLine2\rLine3" vertex="1" parent="0"/>' +
'</root></mxGraphModel></diagram></mxfile>';
const p = await writeDrawio('ctrl.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// No literal control chars survive in the attribute value.
expect(content).not.toMatch(/[\t\n\r]/);
// They round-trip as numeric char-refs.
expect(content).toContain('&#x9;');
expect(content).toContain('&#xa;');
expect(content).toContain('&#xd;');
// Decoding (char-refs back to literal, entities back) recovers the file.
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x9;/gi, '\t')
.replace(/&#xa;/gi, '\n')
.replace(/&#xd;/gi, '\r')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
it('escapes XML metacharacters in the drawio payload', async () => {
const drawio = '<mxfile><diagram name="a &amp; b">"q" &lt;x&gt;</diagram></mxfile>';
const p = await writeDrawio('meta.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// The attribute value must contain no bare `<`, `>` or `"` that would break
// out of the content="..." attribute or the SVG element.
expect(content).not.toMatch(/[<>"]/);
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
});
@@ -8,7 +8,6 @@ import { createReadStream } from 'node:fs';
import { promises as fs } from 'fs';
import { Readable } from 'stream';
import { getMimeType, sanitizeFileName } from '../../../common/helpers';
import { htmlEscape } from '../../../common/helpers/html-escaper';
import { v7 } from 'uuid';
import { FileTask } from '@docmost/db/types/entity.types';
import { getAttachmentFolderPath } from '../../../core/attachment/attachment.utils';
@@ -850,12 +849,7 @@ export class ImportAttachmentService {
): Promise<Buffer> {
try {
const drawioContent = await fs.readFile(drawioPath, 'utf-8');
// Write the mxfile XML XML-entity-escaped (draw.io's native content= form),
// NOT base64. draw.io's editor decodes a base64 content= via Latin-1 atob
// (no UTF-8 step), turning every non-ASCII char (Cyrillic, ё, —) into
// mojibake; the entity-encoded form is decoded by the DOM as UTF-8 and
// opens intact. Docmost's own decoder reads both forms.
const drawioEscaped = this.xmlEscapeContent(drawioContent);
const drawioBase64 = Buffer.from(drawioContent).toString('base64');
let imageElement = '';
// If we have a PNG, include it in the SVG
@@ -881,7 +875,7 @@ export class ImportAttachmentService {
width="600"
height="400"
viewBox="0 0 600 400"
content="${drawioEscaped}">${imageElement}</svg>`;
content="${drawioBase64}">${imageElement}</svg>`;
return Buffer.from(svgContent, 'utf-8');
} catch (error) {
@@ -890,24 +884,6 @@ export class ImportAttachmentService {
}
}
/**
* Escape a string so it is safe as the value of a double-quoted XML attribute
* (the `content=` payload of a `.drawio.svg`). The shared `htmlEscape` covers
* `& < > " '` (a strict superset of what this attribute needs; the extra `'`
* escape is harmless in a `"`-delimited value). On top of that, the numeric
* char-refs for tab/newline/CR are required: a literal tab/newline/CR inside
* an attribute value is collapsed to a single space by XML attribute-value
* normalization on DOM read (both our decoder and the real draw.io editor),
* silently flattening multi-line labels and tab-bearing values. Char-refs
* survive that normalization (#507).
*/
private xmlEscapeContent(s: string): string {
return htmlEscape(s)
.replace(/\t/g, '&#x9;')
.replace(/\n/g, '&#xa;')
.replace(/\r/g, '&#xd;');
}
private async uploadWithRetry(opts: {
abs: string;
storageFilePath: string;
@@ -1,67 +0,0 @@
// Importing ImportService transitively loads import-formatter.ts, which imports
// the ESM-only @sindresorhus/slugify (not in jest's transform allowlist). It is
// irrelevant to this path, so mock it to keep the module graph loadable (mirrors
// the sibling import.service specs).
jest.mock('@sindresorhus/slugify', () => ({
__esModule: true,
default: (input: string) => String(input),
}));
import { ImportService } from './import.service';
// #502 BLOCKER 1: the server markdown import path (`/pages/import`) now accepts an
// optional `disableMarkdownExtensions` flag threaded into `processMarkdown`.
// - MCP agent `createPage` sends it TRUE -> extensions OFF (a `$…$` config span
// stays literal, a schemeless `www.host` is not autolinked).
// - a HUMAN file upload omits it (default FALSE) -> extensions ON, so a real
// `$x^2$` still becomes a formula (human imports unaffected).
// `processMarkdown` only uses the imported converter (no injected deps on this
// path), so the service is constructed with null deps for this focused unit test.
function makeService(): ImportService {
return new ImportService(null as any, null as any, null as any, null as any);
}
function findAll(node: any, type: string, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
return acc;
}
function hasLink(node: any): boolean {
return findAll(node, 'text').some((t: any) =>
t.marks?.some((m: any) => m.type === 'link'),
);
}
describe('ImportService.processMarkdown — #502 disableMarkdownExtensions', () => {
it('disableMarkdownExtensions=true (MCP createPage): `$…$` stays literal, no math', async () => {
const svc = makeService();
const doc = await svc.processMarkdown('export A=$FOO and B=$BAR done', true);
expect(findAll(doc, 'mathInline')).toHaveLength(0);
});
it('disableMarkdownExtensions=true: a schemeless www is NOT autolinked', async () => {
const svc = makeService();
const doc = await svc.processMarkdown('see www.example.com here', true);
expect(hasLink(doc)).toBe(false);
});
it('disableMarkdownExtensions=true: an explicit https:// STILL links', async () => {
const svc = makeService();
const doc = await svc.processMarkdown('see https://example.com here', true);
expect(hasLink(doc)).toBe(true);
});
it('DEFAULT (human upload): a real `$x^2$` DOES become a math node', async () => {
const svc = makeService();
const doc = await svc.processMarkdown('$x^2$');
expect(findAll(doc, 'mathInline')).toHaveLength(1);
});
it('DEFAULT (human upload): a schemeless www IS autolinked', async () => {
const svc = makeService();
const doc = await svc.processMarkdown('see www.example.com here');
expect(hasLink(doc)).toBe(true);
});
});
@@ -52,12 +52,6 @@ export class ImportService {
userId: string,
spaceId: string,
workspaceId: string,
// #502: when true, the markdown importer runs with the two layered
// extensions OFF (a `$…$` span stays literal text; a schemeless `www.host` is
// NOT autolinked). ONLY the MCP agent `createPage` path sets this; a HUMAN
// file upload never passes it, so it defaults false and math/autolink stay ON
// for human imports (their `$x^2$` still becomes a formula).
disableMarkdownExtensions = false,
) {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
@@ -72,10 +66,7 @@ export class ImportService {
try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(
fileContent,
disableMarkdownExtensions,
);
prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent);
}
@@ -147,12 +138,7 @@ export class ImportService {
return createdPage;
}
async processMarkdown(
markdownInput: string,
// #502: forwarded to the importer. DEFAULT false keeps math + fuzzy autolink
// ON (human uploads unaffected); the MCP agent `createPage` path passes true.
disableMarkdownExtensions = false,
): Promise<any> {
async processMarkdown(markdownInput: string): Promise<any> {
// Canonical markdown -> ProseMirror JSON directly via
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate and no
// second editor-ext markdown layer. Foreign markdown surfaces the strict
@@ -161,12 +147,7 @@ export class ImportService {
// The HTML-cleanup pass (`normalizeImportHtml`) is intentionally skipped here:
// it targets foreign *HTML* (Notion/XWiki), which only ever arrives on the
// `.html` path (`processHTML`), never as canonical markdown.
return markdownToProseMirror(
normalizeForeignMarkdown(markdownInput),
disableMarkdownExtensions
? { parseMath: false, fuzzyLinkify: false }
: undefined,
);
return markdownToProseMirror(normalizeForeignMarkdown(markdownInput));
}
async processHTML(htmlInput: string): Promise<any> {
@@ -304,102 +304,37 @@ export function clientIp(req: ClientIpRequest): string {
return 'unknown';
}
// The decoded payload shared by the /mcp Bearer allowlist. Carries the `type`
// discriminator and the API-key `apiKeyId`, on top of the access-token fields.
export interface McpBearerPayload {
type?: JwtType;
// Minimal structural shape of the TokenService.verifyJwt method we depend on,
// so this module never imports the concrete TokenService (heavy graph).
export interface AccessJwtVerifier {
verifyJwt: (
token: string,
type: JwtType,
) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}>;
}
/**
* Bind a TokenService-like verifier into a one-arg `verifyJwt(token)` that
* ALWAYS enforces `JwtType.ACCESS`. This is the single place where the /mcp
* Bearer path pins the token type: a Bearer access token must be verified AS an
* access token (not refresh/exchange/collab/etc.), so the type literal is fixed
* here rather than at the call site. McpService.verifyMcpBearer delegates to
* this, keeping the `JwtType.ACCESS` choice testable without the heavy graph.
*/
export function bindAccessJwtVerifier(
tokenService: AccessJwtVerifier,
): (token: string) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
apiKeyId?: string;
}
// Minimal structural shape of the TokenService.verifyJwtOneOf method.
export interface OneOfJwtVerifier {
verifyJwtOneOf: (
token: string,
allowed: JwtType[],
) => Promise<McpBearerPayload>;
}
/**
* Bind a TokenService-like verifier into a one-arg `verifyJwtOneOf(token)` that
* pins the /mcp Bearer ALLOWLIST to exactly {ACCESS, API_KEY}. This is the single
* place the /mcp Bearer path pins the token type: the /mcp Bearer slot
* legitimately accepts either an ACCESS token (a
* human's session token) OR an API_KEY token (an agent's key), but NOTHING else
* (collab/exchange/attachment/etc. are rejected with the generic type error).
* The allowlist is fixed here rather than at the call site, and the signature is
* verified exactly once (see verifyMcpBearer).
*/
export function bindMcpBearerVerifier(
tokenService: OneOfJwtVerifier,
): (token: string) => Promise<McpBearerPayload> {
return (token: string) =>
tokenService.verifyJwtOneOf(token, [JwtType.ACCESS, JwtType.API_KEY]);
}
// Deps for the /mcp Bearer router. `verifyJwtOneOf` is the one-arg verifier bound
// above (allowlist {ACCESS, API_KEY}); the ACCESS-specific revocation/disabled
// deps mirror BearerVerifyDeps; `validateApiKey` is the SHARED api-key row-check.
export interface McpBearerDeps
extends Omit<BearerVerifyDeps, 'verifyJwt'> {
verifyJwtOneOf: (token: string) => Promise<McpBearerPayload>;
// Row-check for an API_KEY principal — the SAME validator REST uses. Throws
// UnauthorizedException on a definite deny; PROPAGATES an infra error (→ 5xx),
// never masking it as a 401. Not a login attempt: the Basic limiter is not
// involved on this path.
validateApiKey: (payload: McpBearerPayload) => Promise<unknown>;
}
/**
* Verify a /mcp Bearer token that may be an ACCESS token OR an API_KEY token, and
* route by type. The signature is verified EXACTLY ONCE (verifyJwtOneOf); the
* result is reused so the ACCESS branch does not re-verify.
*
* - API_KEY -> bind to THIS instance's workspace FIRST (a token for another
* workspace is rejected), THEN run the shared `validateApiKey` row-check.
* No session/limiter involvement (an API key is not a login).
* - ACCESS -> the unchanged `verifyBearerAccess` (session-active + not-disabled
* checks), fed a closure over the already-verified payload so "verify once"
* and "helper unchanged" coexist.
*
* Throws UnauthorizedException on any auth failure (uniform generic message no
* enumeration of why); propagates an infra error from `validateApiKey` as itself.
*/
export async function verifyMcpBearer(
token: string,
deps: McpBearerDeps,
): Promise<{ sub?: string; email?: string }> {
const generic = 'Invalid or expired token';
const payload = await deps.verifyJwtOneOf(token);
if (payload.type === JwtType.API_KEY) {
if (!payload.sub || !payload.workspaceId) {
throw new UnauthorizedException(generic);
}
// Instance-binding (mirrors verifyBearerAccess): reject an API_KEY token
// minted for a different workspace before touching the DB.
if (
deps.expectedWorkspaceId &&
payload.workspaceId !== deps.expectedWorkspaceId
) {
throw new UnauthorizedException(generic);
}
// Shared row-check. A definite deny throws Unauthorized; an infra error
// propagates (→ 5xx), which the caller must NOT convert to a 401.
await deps.validateApiKey(payload);
return { sub: payload.sub };
}
// ACCESS: reuse verifyBearerAccess WITHOUT re-verifying the signature.
return verifyBearerAccess(token, {
verifyJwt: async () => payload,
expectedWorkspaceId: deps.expectedWorkspaceId,
findUser: deps.findUser,
findActiveSession: deps.findActiveSession,
});
}> {
return (token: string) => tokenService.verifyJwt(token, JwtType.ACCESS);
}
// Minimal shapes for the Bearer revocation/disabled check. Kept structural so
@@ -793,24 +728,18 @@ export async function resolveMcpSessionConfig(
};
}
// --- 2) fallback A: Bearer JWT (user-supplied ACCESS or agent API_KEY) ---
// --- 2) fallback A: Bearer access-JWT (user-supplied token) ---
const bearer = extractBearer(authHeader);
if (bearer) {
let payload: { sub?: string; email?: string };
try {
payload = await deps.verifyAccessJwt(bearer);
} catch (err) {
// Anti-enumeration (Bearer leg): EVERY auth failure surfaces the SAME
// generic 401 — expired/revoked/wrong-type/unknown are indistinguishable
// to the caller (its reaction is identical either way). But an UNEXPECTED
// (infra) error is NOT an auth verdict: rethrow it AS ITSELF so the surface
// maps it to 5xx (mapAuthResultToResponse), never masking a DB/Redis
// outage as a bad token. verifyMcpBearer throws UnauthorizedException on a
// definite deny and lets an infra error from validateApiKey propagate.
if (err instanceof UnauthorizedException) {
throw new UnauthorizedException('Invalid or expired token');
}
throw err;
const message =
err instanceof Error && err.message
? err.message
: 'Invalid or expired token';
throw new UnauthorizedException(message);
}
return {
config: { apiUrl, getToken: async () => bearer },
@@ -115,7 +115,6 @@ function makeService(opts: {
undefined as never, // userRepo
undefined as never, // userSessionRepo
moduleRef as never, // moduleRef (read by the MFA branch)
undefined as never, // apiKeyService (unused by the login-gate path)
undefined as never, // sandboxStore (unused by the login-gate path)
);
// Stop the constructor's unref'd sweep timer leaking across tests.
@@ -4,16 +4,13 @@ import { McpService } from './mcp.service';
import { DatabaseModule } from '@docmost/db/database.module';
import { AuthModule } from '../../core/auth/auth.module';
import { TokenModule } from '../../core/auth/token.module';
import { ApiKeyModule } from '../../core/api-key/api-key.module';
// Community MCP feature: the server itself serves the Model Context Protocol
// over HTTP at /mcp. DatabaseModule (global) provides WorkspaceRepo. AuthModule
// supplies AuthService (per-user HTTP-Basic login validation) and TokenModule
// supplies TokenService (Bearer JWT verification for the token path). ApiKeyModule
// supplies ApiKeyService (the shared api-key row-check for the API_KEY Bearer
// branch, so an agent authenticates with a key instead of the bcrypt Basic path).
// supplies TokenService (Bearer access-JWT verification for the token fallback).
@Module({
imports: [DatabaseModule, AuthModule, TokenModule, ApiKeyModule],
imports: [DatabaseModule, AuthModule, TokenModule],
controllers: [McpController],
providers: [McpService],
})
@@ -6,10 +6,9 @@ import {
isCredentialsFailure,
isInitializeRequestBody,
verifyBearerAccess,
verifyMcpBearer,
bindMcpBearerVerifier,
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
extractBearer,
decideBasicGate,
mapAuthResultToResponse,
@@ -525,28 +524,13 @@ describe('resolveMcpSessionConfig', () => {
expect(resolved.identity).toBe('bearer:user-9');
});
it('Bearer invalid -> UNIFORM generic 401 (anti-enumeration, reason not leaked)', async () => {
// #501: the Bearer leg no longer surfaces the specific reason. Whether the
// token is expired, revoked, wrong-type or unknown, the caller sees ONE bare
// 'Invalid or expired token' — an agent's reaction is identical, and a leaked
// class would be an enumeration oracle.
it('Bearer invalid -> specific 401 from verifyAccessJwt', async () => {
const verifyAccessJwt = jest
.fn()
.mockRejectedValue(new UnauthorizedException('jwt expired'));
await expect(
resolveMcpSessionConfig('Bearer expired', makeDeps({ verifyAccessJwt })),
).rejects.toThrow('Invalid or expired token');
});
it('Bearer INFRA error -> propagates (NOT masked as 401)', async () => {
// A non-UnauthorizedException (e.g. a DB outage in the api-key row-check) is
// not an auth verdict: it must propagate so the surface maps it to 5xx.
const verifyAccessJwt = jest
.fn()
.mockRejectedValue(new Error('connection terminated'));
await expect(
resolveMcpSessionConfig('Bearer x', makeDeps({ verifyAccessJwt })),
).rejects.toThrow('connection terminated');
).rejects.toThrow('jwt expired');
});
it('no creds + env service account configured -> service-account config', async () => {
@@ -1017,104 +1001,48 @@ describe('clientIp (XFF-fallback precedence, item 5)', () => {
});
});
describe('bindMcpBearerVerifier pins the {ACCESS, API_KEY} allowlist (#501)', () => {
it('calls verifyJwtOneOf with exactly [ACCESS, API_KEY]', async () => {
const verifyJwtOneOf = jest
describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
it('calls TokenService.verifyJwt with JwtType.ACCESS as the second argument', async () => {
// Mock TokenService: assert the type literal is pinned to ACCESS so swapping
// to REFRESH (or omitting the type) breaks this test.
const verifyJwt = jest
.fn()
.mockResolvedValue({ type: JwtType.API_KEY, sub: 'u-1' });
await bindMcpBearerVerifier({ verifyJwtOneOf })('the.jwt');
expect(verifyJwtOneOf).toHaveBeenCalledWith('the.jwt', [
JwtType.ACCESS,
JwtType.API_KEY,
]);
// Pin the concrete enum values too.
expect(verifyJwtOneOf.mock.calls[0][1]).toEqual(['access', 'api_key']);
});
});
.mockResolvedValue({ sub: 'user-1', workspaceId: 'ws-1' });
const verify = bindAccessJwtVerifier({ verifyJwt });
describe('verifyMcpBearer routes by token type (#501)', () => {
const accessDeps = (over: any = {}) => ({
verifyJwtOneOf: jest.fn(),
expectedWorkspaceId: 'ws-1',
findUser: jest.fn().mockResolvedValue({ deactivatedAt: null }),
findActiveSession: jest
.fn()
.mockResolvedValue({ userId: 'u-1', workspaceId: 'ws-1' }),
validateApiKey: jest.fn(),
...over,
await verify('the.access.jwt');
expect(verifyJwt).toHaveBeenCalledTimes(1);
expect(verifyJwt).toHaveBeenCalledWith('the.access.jwt', JwtType.ACCESS);
// Pin the real enum value too, so renaming/repointing the enum member is caught.
expect(verifyJwt.mock.calls[0][1]).toBe('access');
});
it('API_KEY -> row-checks via validateApiKey and does NOT touch session/limiter', async () => {
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.API_KEY,
sub: 'svc-1',
workspaceId: 'ws-1',
apiKeyId: 'k-1',
}),
validateApiKey: jest.fn().mockResolvedValue({ user: { id: 'svc-1' } }),
});
const res = await verifyMcpBearer('tok', deps);
expect(deps.validateApiKey).toHaveBeenCalledTimes(1);
// No session lookup on the api-key path (not a login).
expect(deps.findActiveSession).not.toHaveBeenCalled();
expect(res).toEqual({ sub: 'svc-1' });
it('passes through the verified payload', async () => {
const payload = { sub: 'user-9', email: 'u@e.com', workspaceId: 'ws-1' };
const verifyJwt = jest.fn().mockResolvedValue(payload);
await expect(
bindAccessJwtVerifier({ verifyJwt })('t'),
).resolves.toBe(payload);
});
it('API_KEY for ANOTHER workspace -> rejected before the row-check', async () => {
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.API_KEY,
sub: 'svc-1',
workspaceId: 'ws-OTHER',
apiKeyId: 'k-1',
}),
validateApiKey: jest.fn(),
});
await expect(verifyMcpBearer('tok', deps)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(deps.validateApiKey).not.toHaveBeenCalled();
});
it('API_KEY infra error from validateApiKey PROPAGATES (not masked)', async () => {
const boom = new Error('db down');
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.API_KEY,
sub: 'svc-1',
workspaceId: 'ws-1',
apiKeyId: 'k-1',
}),
validateApiKey: jest.fn().mockRejectedValue(boom),
});
await expect(verifyMcpBearer('tok', deps)).rejects.toBe(boom);
});
it('ACCESS -> runs the session/disabled checks and does NOT call validateApiKey', async () => {
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.ACCESS,
sub: 'u-1',
workspaceId: 'ws-1',
sessionId: 'sess-1',
email: 'u@e.com',
}),
});
const res = await verifyMcpBearer('tok', deps);
expect(deps.findActiveSession).toHaveBeenCalledWith('sess-1');
expect(deps.validateApiKey).not.toHaveBeenCalled();
expect(res).toEqual({ sub: 'u-1', email: 'u@e.com' });
});
it('verifies the signature exactly ONCE (single verifyJwtOneOf, no re-verify)', async () => {
const verifyJwtOneOf = jest.fn().mockResolvedValue({
type: JwtType.ACCESS,
sub: 'u-1',
// The Bearer revocation/disabled checks (verifyBearerAccess) are covered above;
// this binds the ACCESS-type enforcement that verifyMcpBearer wires in.
it('feeds verifyBearerAccess so the whole Bearer chain enforces ACCESS', async () => {
const verifyJwt = jest.fn().mockResolvedValue({
sub: 'user-1',
workspaceId: 'ws-1',
sessionId: 'sess-1',
});
await verifyMcpBearer('tok', accessDeps({ verifyJwtOneOf }));
expect(verifyJwtOneOf).toHaveBeenCalledTimes(1);
const res = await verifyBearerAccess('t', {
verifyJwt: bindAccessJwtVerifier({ verifyJwt }),
findUser: jest.fn().mockResolvedValue({ deactivatedAt: null }),
findActiveSession: jest
.fn()
.mockResolvedValue({ userId: 'user-1', workspaceId: 'ws-1' }),
});
expect(verifyJwt).toHaveBeenCalledWith('t', JwtType.ACCESS);
expect(res).toEqual({ sub: 'user-1', email: undefined });
});
});
@@ -1270,7 +1198,6 @@ describe('McpService.onModuleDestroy — CollabSession teardown (#486)', () => {
{} as any,
{} as any,
{} as any,
{} as any,
);
}
+25 -34
View File
@@ -14,17 +14,16 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { AuthService } from '../../core/auth/services/auth.service';
import { TokenService } from '../../core/auth/services/token.service';
import { validateSsoEnforcement } from '../../core/auth/auth.util';
import { JwtApiKeyPayload } from '../../core/auth/dto/jwt-payload';
import { JwtPayload } from '../../core/auth/dto/jwt-payload';
import { Workspace } from '@docmost/db/types/entity.types';
import { ApiKeyService } from '../../core/api-key/api-key.service';
import {
FailedLoginLimiter,
resolveMcpSessionConfig,
verifyMcpBearer,
verifyBearerAccess,
isInitializeRequestBody,
sharedTokenMatches,
clientIp,
bindMcpBearerVerifier,
bindAccessJwtVerifier,
decideBasicGate,
mapAuthResultToResponse,
DocmostMcpConfig,
@@ -106,10 +105,6 @@ export class McpService implements OnModuleDestroy {
private readonly userRepo: UserRepo,
private readonly userSessionRepo: UserSessionRepo,
private readonly moduleRef: ModuleRef,
// Shared api-key row-check for the /mcp API_KEY Bearer branch (same validator
// REST uses). Also lets an agent authenticate to /mcp with an api key instead
// of the bcrypt Basic path, so parallel reads stop starving the limiter.
private readonly apiKeyService: ApiKeyService,
// Shared singleton in-RAM blob store backing the stash tool.
private readonly sandboxStore: SandboxStore,
) {
@@ -199,41 +194,37 @@ export class McpService implements OnModuleDestroy {
}
}
// Bearer verification for the /mcp token path. The Bearer slot accepts EITHER
// an ACCESS token (a human session token) OR an API_KEY token (an agent's key)
// — the allowlist is pinned in bindMcpBearerVerifier. An ACCESS token is
// checked exactly as JwtStrategy does (signature/exp/type + session-active +
// not-disabled), so the MCP path is not weaker than the cookie/header path. An
// API_KEY token is HMAC-verified (microseconds) then row-checked via the shared
// ApiKeyService.validate — NOT a login attempt, so the Basic bcrypt path and
// its anti-brute-force limiter are never touched (the parallel-reads fix).
// Bearer access-JWT verification for the /mcp token fallback. verifyJwt only
// checks signature/exp/type, but a logged-out (revoked) or disabled user can
// still hold an unexpired access JWT. JwtStrategy additionally checks the
// session is active and the user is not disabled; we mirror those exact checks
// here so the MCP Bearer path is not weaker than the normal cookie/header path.
private async verifyMcpBearer(
token: string,
): Promise<{ sub?: string; email?: string }> {
// Resolve THIS instance's workspace so the router can bind the token's
// `workspaceId` claim to it (mirrors JwtStrategy). The community build is
// single-workspace (findFirst), so this is the default workspace and the
// check is a no-op here; it only rejects a foreign-workspace token in a
// multi-workspace deployment. Undefined (no workspace configured) means no
// check — the credentials path would already have failed with no workspace.
// Resolve THIS instance's workspace so verifyBearerAccess can bind the
// token's `workspaceId` claim to it (mirrors JwtStrategy). The community
// build is single-workspace (findFirst), so this is the default workspace
// and the check is a no-op here; it only rejects a foreign-workspace token
// in a multi-workspace deployment. Undefined (no workspace configured) means
// no check — the credentials path would already have failed with no
// workspace, and an undefined here keeps the helper a no-op rather than
// rejecting every token.
const instanceWorkspace = await this.workspaceRepo.findFirst();
// The type-routing + revocation/disabled decision logic lives in the
// framework-free verifyMcpBearer helper (unit-testable without the heavy auth
// graph); this method only wires in the concrete TokenService + repos + the
// shared api-key validator.
return verifyMcpBearer(token, {
// The {ACCESS, API_KEY} allowlist enforcement lives in bindMcpBearerVerifier
// (a pure, testable seam) so the type set cannot silently drift.
verifyJwtOneOf: bindMcpBearerVerifier(this.tokenService),
// The revocation/disabled decision logic lives in the framework-free
// verifyBearerAccess helper (unit-testable without the heavy auth graph);
// this method only wires in the concrete TokenService + repos.
return verifyBearerAccess(token, {
// The JwtType.ACCESS enforcement lives in bindAccessJwtVerifier (a pure,
// testable seam) so the type literal cannot silently drift to REFRESH.
verifyJwt: bindAccessJwtVerifier(this.tokenService) as (
t: string,
) => Promise<JwtPayload>,
expectedWorkspaceId: instanceWorkspace?.id,
findUser: (sub, workspaceId) =>
this.userRepo.findById(sub, workspaceId),
findActiveSession: (sessionId) =>
this.userSessionRepo.findActiveById(sessionId),
// Shared with REST: a definite deny throws Unauthorized, an infra error
// propagates (→ 5xx). The /mcp bearer catch must preserve that distinction.
validateApiKey: (payload) =>
this.apiKeyService.validate(payload as JwtApiKeyPayload),
});
}
@@ -322,21 +322,20 @@ describe('AiChatService.stream [integration]', () => {
});
/**
* #332 + #490 deferred tool loading, the ON path. Turn 1 starts COLD (CORE +
* loadTools only) and activates a deferred tool via loadTools; that activation
* is PERSISTED into the chat's metadata.activatedTools (#490) so the NEXT turn
* SEEDS from it and the tool is active from the fresh turn's FIRST step the
* model never re-runs loadTools to re-activate the same tool. The unit tests
* only exercise pure prepareAgentStep with hand-fed Sets; this pins the real
* wiring end-to-end (loadTools.execute -> activatedTools -> persist -> next-turn
* seed -> prepareStep -> per-step activeTools) against the real streamText loop.
* We drive a MockLanguageModelV3 whose step 1 calls loadTools(['createPage'])
* and assert, via the model's recorded per-step CallOptions.tools (the AI SDK
* filters the provider tool list by activeTools), that the deferred tool becomes
* active on the SAME turn's next step AND, seeded from metadata, on the next
* turn's first step.
* #332 deferred tool loading, the ON path. The riskiest property is that the
* per-turn `activatedTools` Set is created FRESH inside each stream() call, so a
* tool a previous turn activated via loadTools is NOT still active when the next
* turn starts the new turn begins "cold" (CORE + loadTools only). The unit
* tests only exercise pure prepareAgentStep with hand-fed Sets; this pins the
* real wiring end-to-end (loadTools.execute -> activatedTools -> prepareStep ->
* per-step activeTools) against the real streamText loop, and proves there is no
* cross-turn leak. We drive a MockLanguageModelV3 whose step 1 calls
* loadTools(['createPage']) and assert, via the model's recorded per-step
* CallOptions.tools (the AI SDK filters the provider tool list by activeTools),
* that the deferred tool becomes active on the SAME turn's next step but NOT on a
* fresh turn's first step.
*/
describe('deferred tool loading ON — cross-turn activation persistence (#332 + #490)', () => {
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
// A stub deferred (non-core) tool the agent can activate. Its execute is never
// called — the model only needs to SEE it become active — but it must be a
// valid AI-SDK tool so the SDK includes it in a step's tool list once active.
@@ -452,7 +451,7 @@ describe('AiChatService.stream [integration]', () => {
} as any);
}
it('activates a deferred tool for the SAME turn, and a NEW turn SEEDS it from persisted chat metadata (#490)', async () => {
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
@@ -475,7 +474,7 @@ describe('AiChatService.stream [integration]', () => {
// Step 2 of the SAME turn sees the just-activated deferred tool.
expect(step2Tools).toContain('createPage');
// --- Turn 2 on the SAME chat: seeds the persisted activation (#490). ---
// --- Turn 2 on the SAME chat: must start cold again. ---
const model2 = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
@@ -486,10 +485,9 @@ describe('AiChatService.stream [integration]', () => {
const nextTurnFirstStep = toolNames(model2.doStreamCalls[0]);
expect(nextTurnFirstStep).toContain('loadTools');
// #490: activation PERSISTS across turns — turn 1 wrote createPage into the
// chat's metadata.activatedTools, so the next turn seeds from it and the
// deferred tool is active from the FIRST step (no need to re-run loadTools).
expect(nextTurnFirstStep).toContain('createPage');
// The activated set is per-turn: the prior turn's createPage did NOT leak,
// so the fresh turn's first step sees it deferred again.
expect(nextTurnFirstStep).not.toContain('createPage');
});
});
});
+1 -4
View File
@@ -118,10 +118,7 @@ All 41 tools, grouped by what you'd reach for them.
- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
table spans/colwidth/background, indent, `callout.icon`, `orderedList.type`, and link
`internal`/`target`/`rel`/`class`; use `getPageJson` when you need those). Pass
`format:"text"` for a flat, deterministic plain-text rendering (one line per block,
marks dropped, stable `[image]`/`[table RxC]` placeholders) to machine-diff what you
wrote against what was stored.
`internal`/`target`/`rel`/`class`; use `getPageJson` when you need those).
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
tools consume.
+1 -4
View File
@@ -123,10 +123,7 @@ Docmost-MCP не сочетают:
лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
используйте `getPageJson`, когда они нужны). Передайте `format:"text"` для плоского
детерминированного plain-text рендера (одна строка на блок, маркировка убрана,
стабильные плейсхолдеры `[image]`/`[table RxC]`) — чтобы machine-diff'ом сверить то,
что вы записали, с тем, что сохранилось.
используйте `getPageJson`, когда они нужны).
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
поблочного редактирования.
+3 -13
View File
@@ -701,20 +701,10 @@ export abstract class DocmostClientContext {
}
/**
* Raw page info including the ProseMirror JSON content and slugId.
*
* With `format:"text"` (#502) the server instead renders `content` as a flat,
* deterministic text string (its `jsonToText` path the SAME serializer that
* feeds search), so the MCP text read reuses the server's ONE serializer
* rather than shipping a second one. Every other caller omits `format` and
* gets the JSON content unchanged.
*/
async getPageRaw(pageId: string, format?: "text") {
/** Raw page info including the ProseMirror JSON content and slugId. */
async getPageRaw(pageId: string) {
await this.ensureAuthenticated();
const body: Record<string, unknown> = { pageId };
if (format) body.format = format;
const response = await this.client.post("/pages/info", body);
const response = await this.client.post("/pages/info", { pageId });
return response.data?.data ?? response.data;
}
-7
View File
@@ -93,13 +93,6 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Bas
const buildForm = () => {
const form = new FormData();
form.append("spaceId", spaceId);
// #502: this is an AGENT-authored body (plain prose / config), so tell the
// server import path to run the markdown importer with the two layered
// extensions OFF — a `$…$` span stays literal text (real math via
// `update_page_json`) and a schemeless `www.host`/email is not autolinked
// (an explicit `https://…` still links). A human file upload never sends
// this field, so human imports keep math + autolink ON.
form.append("disableMarkdownExtensions", "true");
form.append("file", fileContent, {
filename: `${title || "import"}.md`,
contentType: "text/markdown",
+2 -28
View File
@@ -72,7 +72,7 @@ export interface IReadMixin {
getTree(spaceId: string, rootPageId?: string, maxDepth?: number): any;
getPageContext(pageId: string): any;
listSidebarPages(spaceId: string, pageId?: string): any;
getPage(pageId: string, format?: "markdown" | "text"): any;
getPage(pageId: string): any;
getPageJson(pageId: string): any;
getOutline(pageId: string): any;
getNode(pageId: string, nodeId: string, format?: "markdown" | "json"): any;
@@ -420,34 +420,8 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
return convertProseMirrorToMarkdown(content, options);
}
async getPage(pageId: string, format: "markdown" | "text" = "markdown") {
async getPage(pageId: string) {
await this.ensureAuthenticated();
// #502 `format:"text"`: a flat, deterministic, machine-diffable rendering
// (block-per-line, inline marks/comment anchors dropped, non-text nodes ->
// stable placeholders like `[image]` / `[table RxC]`). The server produces
// it via its `jsonToText` path (the SAME serializer that feeds search), so
// there is no second serializer here — we just request it and pass the
// string through. Distinct from the markdown default: no PM->markdown walk,
// no markdown conversion cache, and no `{{SUBPAGES}}` substitution (the text
// renderer emits no such placeholder). Use it to diff a page you wrote as a
// config/prose against what was stored.
if (format === "text") {
const textData = await this.getPageRaw(pageId, "text");
let subpages: any[] = [];
try {
subpages = await this.listSidebarPages(textData.spaceId, textData.id);
} catch (e: any) {
console.warn("Failed to fetch subpages:", e);
}
const textContent =
typeof textData.content === "string" ? textData.content : "";
return {
data: filterPage(textData, textContent, subpages),
success: true,
};
}
const resultData = await this.getPageRaw(pageId);
// Agent read: hide resolved-comment anchors so the agent sees only active
+2 -28
View File
@@ -14,7 +14,6 @@ import {
markdownToProseMirror,
normalizeAgentMarkdown,
} from "@docmost/prosemirror-markdown";
import type { MarkdownImportOptions } from "@docmost/prosemirror-markdown";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import type { PageId } from "./page-id.js";
@@ -112,31 +111,16 @@ global.WebSocket = WebSocket;
* horizontalRule the serializer emitted, and stripping it would silently drop the
* page's leading content (#493 review). The front-matter strip stays on the
* server FILE-import boundary only (`normalizeForeignMarkdown`).
*
* #502 IMPORTANT the two layered markdown extensions (`$$` math, schemeless
* fuzzy autolink) are NOT decided here; they are the CALLER's choice via
* `options`, because the two callers of this wrapper need OPPOSITE behavior:
* - AGENT-authored plain markdown (`updatePageMarkdown`) both OFF, so a
* `$$` config span stays literal and a bare `www.host` is not autolinked
* (real math is authored via `update_page_json`).
* - FULL-FILE round-trip import (`import_page_markdown`, #328 lossless)
* DEFAULTS (both ON), because the exporter serializes a math node as readable
* `$x^2$`; re-importing with math OFF would degrade it to literal text and
* BREAK the lossless exportimport pair.
* So `options` DEFAULTS to `undefined` the package importer's defaults (ON),
* which is the safe round-trip behavior; the agent-write caller opts OUT
* explicitly. (The fragment path `importMarkdownFragment` opts out on its own.)
*/
export async function markdownToProseMirrorCanonical(
markdownContent: string,
options?: MarkdownImportOptions,
): Promise<any> {
// #419: normalize + merge glyph-forked footnote definitions BEFORE
// canonicalizing, so the canonicalizer re-hangs references and drops the
// now-orphaned duplicate definitions.
return canonicalizeFootnotes(
normalizeAndMergeFootnotes(
await markdownToProseMirror(normalizeAgentMarkdown(markdownContent), options),
await markdownToProseMirror(normalizeAgentMarkdown(markdownContent)),
),
);
}
@@ -374,17 +358,7 @@ export async function updatePageContentRealtime(
): Promise<MutationResult> {
// PAGE write: canonicalize footnotes (markdown import builds the bottom list in
// definition order; numbering is reference-ordered).
//
// #502: this is the AGENT-authored `updatePageMarkdown` body — plain prose /
// config — so the two layered markdown extensions are turned OFF: a `$…$` span
// stays literal text (real math via `update_page_json`) and a SCHEMELESS
// `www.host`/email is not autolinked (an explicit `https://…` still links).
// Contrast `import_page_markdown`, which keeps DEFAULTS for the #328 lossless
// round-trip.
const tiptapJson = await markdownToProseMirrorCanonical(markdownContent, {
parseMath: false,
fuzzyLinkify: false,
});
const tiptapJson = await markdownToProseMirrorCanonical(markdownContent);
return await mutatePageContent(
pageId,
collabToken,
+9 -36
View File
@@ -178,11 +178,10 @@ function sliceModel(xml: string): string | null {
// --- decode chain ----------------------------------------------------------
/**
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost writes
* the mxfile XML entity-encoded there (buildDrawioSvg / createDrawioSvg), which
* is also how draw.io's own SVG export stores it; older attachments stored a
* base64 payload instead. The DOM decodes entities for us, so the caller only
* has to distinguish "starts with '<'" (raw XML) from base64.
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a
* base64 payload there (createDrawioSvg); draw.io's own SVG export may store the
* XML entity-encoded instead. The DOM decodes entities for us, so the caller
* only has to distinguish "starts with '<'" (raw XML) from base64.
*/
export function extractContentAttr(svg: string): string {
const { doc, error } = parseXml(svg);
@@ -196,23 +195,12 @@ export function extractContentAttr(svg: string): string {
// value itself never contains a double-quote (base64 / entity-encoded XML).
const m = /content="([^"]*)"/.exec(svg);
if (m) {
// Decode the handful of XML entities a raw regex would leave encoded. The
// numeric char-refs for tab/newline/CR MUST be decoded here too: the DOM
// path above turns them back into the literal control chars, so this
// regex fallback has to agree or the two decode paths diverge (#507).
// `&amp;` is decoded last so an escaped `&amp;#x9;` reads back as the
// literal text `&#x9;`, not a tab.
// Decode the handful of XML entities a raw regex would leave encoded.
return m[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x9;/gi, "\t")
.replace(/&#9;/g, "\t")
.replace(/&#xa;/gi, "\n")
.replace(/&#10;/g, "\n")
.replace(/&#xd;/gi, "\r")
.replace(/&#13;/g, "\r")
.replace(/&amp;/g, "&");
}
throw new Error("drawio: SVG has no content= attribute to decode");
@@ -319,16 +307,9 @@ export function encodeDrawioFile(modelXml: string, title = "Page-1"): string {
/**
* Build the `diagram.drawio.svg` attachment. Mirrors the import service's
* createDrawioSvg contract exactly:
* <svg xmlns= xmlns:xlink= content="${xmlEscape(drawioFile)}">${inner}</svg>
* <svg xmlns= xmlns:xlink= content="${base64(drawioFile)}">${inner}</svg>
* plus width/height/viewBox from the diagram bounding box and the schematic
* preview as the visible children (`inner`).
*
* The `content=` value is the mxfile XML XML-entity-escaped (draw.io's own
* native form), NOT base64. draw.io's editor decodes a base64 content= via
* Latin-1 atob (no UTF-8 step), turning every non-ASCII char (e.g. Cyrillic,
* ё, ) into mojibake; the entity-encoded form is decoded by the DOM as UTF-8
* and opens intact. Our decoder (decodeDrawioSvg) reads both forms, so old
* base64 attachments still round-trip.
*/
export function buildDrawioSvg(
modelXml: string,
@@ -337,14 +318,14 @@ export function buildDrawioSvg(
title = "Page-1",
): string {
const file = encodeDrawioFile(modelXml, title);
const content = xmlEscape(file);
const base64 = Buffer.from(file, "utf-8").toString("base64");
const w = Math.max(1, Math.round(bbox.width));
const h = Math.max(1, Math.round(bbox.height));
return (
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`xmlns:xlink="http://www.w3.org/1999/xlink" ` +
`width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
`content="${content}">${inner}</svg>`
`content="${base64}">${inner}</svg>`
);
}
@@ -353,15 +334,7 @@ function xmlEscape(s: string): string {
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
// A literal tab/newline/CR inside an attribute value is collapsed to a
// single space by XML attribute-value normalization on DOM read (both jsdom
// here and the real draw.io editor), silently flattening multi-line labels
// and tab-bearing values. Numeric char-refs survive that normalization, so
// emit them the way draw.io's own native export does (#507).
.replace(/\t/g, "&#x9;")
.replace(/\n/g, "&#xa;")
.replace(/\r/g, "&#xd;");
.replace(/"/g, "&quot;");
}
// --- normalization + hash --------------------------------------------------
+1 -11
View File
@@ -135,17 +135,7 @@ export interface MarkdownFragment {
export async function importMarkdownFragment(
markdown: string,
): Promise<MarkdownFragment> {
// #502: the fragment path is an MCP agent WRITE (patch_node/insert_node
// markdown), so it uses the SAME extensions-OFF importer options as the
// full-page write (markdownToProseMirrorCanonical): a `$…$` span stays literal
// and a schemeless domain/email is not autolinked. This keeps a block written
// via markdown canonically identical to the same content in a full-page write
// (no "second canon"). Explicit `https://…` links and block structure are
// unaffected.
const doc = await markdownToProseMirror(markdown, {
parseMath: false,
fuzzyLinkify: false,
});
const doc = await markdownToProseMirror(markdown);
const content: any[] = Array.isArray(doc?.content) ? doc.content : [];
const blocks: any[] = [];
+18 -52
View File
@@ -469,11 +469,7 @@ export const SHARED_TOOL_SPECS = {
'(markdown). The fragment may be SEVERAL blocks (a "1 → N" splice: rewrite a ' +
'whole section in one call) — the first block inherits this block id, the ' +
'rest get fresh ids. `^[...]` footnotes are supported (their definitions ' +
"merge into the page's footnote list). Markdown is taken LITERALLY — " +
'`$...$`/`$$...$$` are NOT parsed as math and schemeless `www.host` / bare ' +
'emails are NOT auto-linked (an explicit `https://` URL still links); for a ' +
'real formula pass a `mathInline`/`mathBlock` ProseMirror node via `node` ' +
'(or updatePageJson). REJECTED when the target is a table ' +
"merge into the page's footnote list). REJECTED when the target is a table " +
'cell with attributes markdown cannot represent (merged/colored/fixed-width) ' +
'— use the table tools or `node`. ' +
'`node` (for precise attr/mark work): a raw ProseMirror node, e.g. a ' +
@@ -539,10 +535,6 @@ export const SHARED_TOOL_SPECS = {
'Provide EXACTLY ONE of `markdown` or `node`. ' +
'`markdown` (RECOMMENDED): a canonical markdown fragment — may be SEVERAL ' +
'blocks, inserted in order at the anchor; `^[...]` footnotes supported. ' +
'Markdown is taken LITERALLY — `$...$`/`$$...$$` are NOT parsed as math and ' +
'schemeless `www.host` / bare emails are NOT auto-linked (an explicit ' +
'`https://` URL still links); for a real formula pass a ' +
'`mathInline`/`mathBlock` ProseMirror node via `node` (or updatePageJson). ' +
'`node` (for precise attr/mark work OR table structure): a raw ProseMirror ' +
'node. Table structure is JSON-only (not expressible in markdown): to add a ' +
'tableRow, pass a tableRow node with position before/after and anchor INSIDE ' +
@@ -921,46 +913,28 @@ export const SHARED_TOOL_SPECS = {
inAppKey: 'getPage',
writeClass: 'readOnly',
description:
'Fetch a single page by its id. Returns the page title and its content. ' +
'format:"markdown" (DEFAULT) returns canonical Markdown (round-trips text ' +
'and block structure), sufficient for text edits; use the page-JSON read ' +
'tool only when you need what Markdown cannot carry. The Markdown drops ' +
'exactly: (1) block ids (not visible in Markdown); (2) resolved-comment ' +
'anchors (hidden here; only active <span data-comment-id> anchors remain); ' +
'(3) a fixed set of attributes with no Markdown representation — table-cell ' +
'colspan/rowspan/colwidth/backgroundColor/backgroundColorName, ' +
'heading/paragraph indent, callout.icon, orderedList.type, and link ' +
'internal/target/rel/class. Inline <span data-comment-id> tags in the ' +
'markdown are comment highlight anchors — treat them as markup, not page ' +
'text. format:"text" returns a FLAT, DETERMINISTIC plain-text rendering ' +
'for machine diffing: one line per block, ALL inline marks/formatting and ' +
'comment anchors dropped, a hardBreak is a newline, and non-text nodes ' +
'become STABLE placeholders — an image is "[image]" and a table is ' +
'"[table RxC]" (R rows x C columns). This output is stable across versions ' +
'(pinned by a snapshot test); use it to diff a config/prose you wrote ' +
'against what was stored (an empty diff means it was stored verbatim).',
'Fetch a single page as Markdown by its id. Returns the page title and ' +
'its Markdown content. The converter is canonical (round-trips text and ' +
'block structure), so this is sufficient for text edits; use the ' +
'page-JSON read tool only when you need what Markdown cannot carry. The ' +
'Markdown drops exactly: (1) block ids (not visible in Markdown); ' +
'(2) resolved-comment anchors (hidden here; only active <span ' +
'data-comment-id> anchors remain); (3) a fixed set of attributes with no ' +
'Markdown representation — table-cell colspan/rowspan/colwidth/' +
'backgroundColor/backgroundColorName, heading/paragraph indent, ' +
'callout.icon, orderedList.type, and link internal/target/rel/class. ' +
'Inline <span data-comment-id> tags in the markdown are comment highlight ' +
'anchors — treat them as markup, not page text.',
tier: 'core',
catalogLine:
'getPage — fetch a page by its id (format:"markdown" default, or "text" for a flat machine-diffable read).',
catalogLine: 'getPage — fetch a page as Markdown by its id.',
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
// "(or slugId)" describe kept.
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
format: z
.enum(['markdown', 'text'])
.optional()
.describe(
'Output format: "markdown" (default, canonical round-trippable) or ' +
'"text" (flat deterministic plain text for machine diffing).',
),
}),
// MCP wraps the raw `{ data, success }` as JSON. The in-app host instead
// projects a token-efficient `{ title, markdown }` (its long-standing shape).
execute: (client, { pageId, format }) =>
client.getPage(
pageId as string,
(format as 'markdown' | 'text' | undefined) ?? 'markdown',
),
execute: (client, { pageId }) => client.getPage(pageId as string),
inAppExecute: async (client, { pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = (await client.getPage(pageId as string)) as {
@@ -1103,12 +1077,8 @@ export const SHARED_TOOL_SPECS = {
description:
'Create a new page with a Markdown body in a space, optionally under a ' +
'parent page (omit parentPageId to create at the space root). Returns ' +
'the new page id and title. Body text is taken LITERALLY — ' +
'`$...$`/`$$...$$` are NOT parsed into a math formula and schemeless ' +
'`www.host` / bare emails are NOT auto-linked (an explicit `https://` URL ' +
'still links); for a real formula use updatePageJson with ' +
'`mathInline`/`mathBlock` nodes instead. Reversible: a page can be moved ' +
'to trash later.',
'the new page id and title. Reversible: a page can be moved to trash ' +
'later.',
tier: 'deferred',
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
@@ -1367,11 +1337,7 @@ export const SHARED_TOOL_SPECS = {
'title). The whole body is re-imported from the markdown (block ids ' +
'regenerate — for surgical or id-preserving edits use the find/replace, ' +
'node-patch or page-JSON tools instead). Docmost-flavoured markdown is ' +
'parsed, including `^[...]` inline footnotes. Text is taken LITERALLY — ' +
'`$...$`/`$$...$$` are NOT parsed into a math formula and schemeless ' +
'`www.host` / bare emails are NOT auto-linked (an explicit `https://` URL ' +
'still links); for a real formula use updatePageJson with ' +
'`mathInline`/`mathBlock` nodes instead. Reversible: the previous ' +
'parsed, including `^[...]` inline footnotes. Reversible: the previous ' +
'version is kept in page history.',
tier: 'deferred',
catalogLine:
@@ -1,83 +0,0 @@
// #502 BLOCKER 1 (client half): the MCP `createPage` tool builds its body as an
// AGENT-authored markdown file and POSTs it to the server `/pages/import`
// endpoint. It must send the `disableMarkdownExtensions=true` multipart field so
// the SERVER importer runs with math + fuzzy-autolink OFF (a human file upload
// omits the field and keeps them ON). This mock http server captures the raw
// multipart body and asserts the field is present.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
function readRaw(req) {
return new Promise((resolve) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
});
}
const openServers = [];
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
const NEW_ID = "00000000-0000-4000-8000-000000000042";
const SPACE = "00000000-0000-4000-8000-0000000000aa";
function spawn(state) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
const raw = await readRaw(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, { "Set-Cookie": "authToken=t; Path=/; HttpOnly" });
}
if (req.url === "/api/pages/import") {
state.importBody = raw; // the raw multipart payload
return sendJson(res, 200, { data: { id: NEW_ID } });
}
if (req.url === "/api/pages/update") {
return sendJson(res, 200, { data: { id: NEW_ID } });
}
if (req.url === "/api/pages/info") {
return sendJson(res, 200, {
data: {
id: NEW_ID,
slugId: "slugnew1234",
title: "T",
spaceId: SPACE,
updatedAt: "2026-01-01T00:00:00Z",
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] },
},
});
}
if (req.url === "/api/pages/sidebar-pages") {
return sendJson(res, 200, { data: { items: [], meta: { hasNextPage: false, nextCursor: null } } });
}
return sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
test("createPage sends disableMarkdownExtensions=true in the /pages/import multipart", async () => {
const state = {};
const baseURL = await spawn(state);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.createPage("My Config", "ticket $x=1$ at www.host.com", SPACE);
assert.ok(state.importBody, "the import endpoint received a body");
// The multipart payload carries the field name and its "true" value.
assert.match(state.importBody, /name="disableMarkdownExtensions"/);
assert.match(state.importBody, /name="disableMarkdownExtensions"[\s\S]*?\r?\n\r?\ntrue\r?\n/);
// The agent body itself is still sent as the file part.
assert.match(state.importBody, /ticket \$x=1\$ at www\.host\.com/);
});
@@ -1,116 +0,0 @@
// #502 READ: the getPage tool gains a `format:"text"` mode. It requests the
// server's flat, deterministic text rendering (the server's jsonToText path — the
// SAME serializer that feeds search), passing it through unchanged. This mock
// stands up a local http server (same harness style as getpage-conversion-cache)
// and asserts end-to-end that:
// - format:"text" sends `format:"text"` in the /pages/info body and returns the
// server's text string verbatim (no client-side markdown conversion);
// - the DEFAULT (no format) still returns markdown-converted content;
// - the text read resolves page + subpages like the markdown read.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
const PAGE_UUID = "00000000-0000-4000-8000-000000000010";
const SPACE_UUID = "00000000-0000-4000-8000-0000000000aa";
const CHILD_UUID = "00000000-0000-4000-8000-0000000000bb";
// The deterministic text the SERVER's jsonToText would produce for this page.
const SERVER_TEXT = "Title line\nsecond block\n[image]\n[table 2x3]";
function makeDoc(text) {
return { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] };
}
function spawn(state) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
const body = await readBody(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, { "Set-Cookie": "authToken=t; Path=/; HttpOnly" });
}
if (req.url === "/api/pages/info") {
const parsed = body ? JSON.parse(body) : {};
state.lastInfoBody = parsed;
// Emulate the server: when format:"text" is requested, `content` is the
// flat text string; otherwise it is the raw ProseMirror JSON.
const content = parsed.format === "text" ? SERVER_TEXT : makeDoc("Title line");
return sendJson(res, 200, {
success: true,
data: {
id: PAGE_UUID,
slugId: "slug123456",
title: "Text Page",
parentPageId: null,
spaceId: SPACE_UUID,
updatedAt: "2026-01-01T00:00:00Z",
content,
},
});
}
if (req.url === "/api/pages/sidebar-pages") {
return sendJson(res, 200, {
success: true,
data: {
items: [{ id: CHILD_UUID, title: "Child", hasChildren: false }],
meta: { hasNextPage: false, nextCursor: null },
},
});
}
return sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
function makeClient(baseURL) {
return new DocmostClient({ apiUrl: baseURL, getToken: async () => "access" });
}
test('getPage(format:"text") requests text and returns the server text verbatim', async () => {
const state = {};
const client = makeClient(await spawn(state));
const result = await client.getPage(PAGE_UUID, "text");
assert.equal(state.lastInfoBody.format, "text", "the info request carried format:text");
assert.equal(result.success, true);
assert.equal(result.data.content, SERVER_TEXT, "returns the server's flat text unchanged");
// Deterministic placeholders are surfaced to the agent.
assert.ok(result.data.content.includes("[image]"));
assert.ok(result.data.content.includes("[table 2x3]"));
// No client-side markdown artifacts leaked in.
assert.ok(!result.data.content.includes("{{SUBPAGES}}"));
// Subpages still resolve for context.
assert.deepEqual(result.data.subpages, [{ id: CHILD_UUID, title: "Child" }]);
});
test('getPage default (no format) still returns markdown, not text', async () => {
const state = {};
const client = makeClient(await spawn(state));
const result = await client.getPage(PAGE_UUID);
assert.equal(state.lastInfoBody.format, undefined, "default read sends no format");
assert.ok(result.data.content.includes("Title line"), "markdown content converted client-side");
assert.notEqual(result.data.content, SERVER_TEXT);
});
@@ -1,181 +0,0 @@
// #502 caller-WIRING: end-to-end through a live Hocuspocus collab stack (same
// harness style as markdown-patch-insert), driving the REAL client methods and
// reading the persisted document back, so the test proves each write tool passes
// the RIGHT importer options — not just that the shared wrapper can:
// - updatePageMarkdown (client.updatePage) -> extensions OFF (`$…$` literal, www not linked)
// - import_page_markdown (client.importPageMarkdown) -> DEFAULTS (`$x^2$` -> math node, #328)
// Mutating either caller's option flips the matching assertion (see the coder's
// mutation note), so this file guards the wiring, not only the wrapper.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { WebSocketServer } from "ws";
import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js";
import { serializeDocmostMarkdown } from "@docmost/prosemirror-markdown";
const PAGE = "11111111-1111-4111-8111-111111111111";
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
return acc;
}
function allText(node, acc = []) {
if (!node || typeof node !== "object") return acc.join("");
if (node.type === "text" && typeof node.text === "string") acc.push(node.text);
if (Array.isArray(node.content)) for (const c of node.content) allText(c, acc);
return acc.join("");
}
function hasLink(node) {
return findAll(node, "text").some((t) => t.marks?.some((m) => m.type === "link"));
}
function fragmentToJson(frag) {
const decodeNode = (el) => {
if (el.constructor.name === "YXmlText") {
const delta = el.toDelta();
return delta.map((d) => {
const node = { type: "text", text: d.insert };
if (d.attributes && Object.keys(d.attributes).length) {
node.marks = Object.entries(d.attributes).map(([type, attrs]) =>
attrs && typeof attrs === "object" && Object.keys(attrs).length
? { type, attrs }
: { type },
);
}
return node;
});
}
const node = { type: el.nodeName };
const attrs = el.getAttributes();
if (attrs && Object.keys(attrs).length) node.attrs = attrs;
const children = [];
for (const child of el.toArray()) {
const decoded = decodeNode(child);
if (Array.isArray(decoded)) children.push(...decoded);
else children.push(decoded);
}
if (children.length) node.content = children;
return node;
};
const content = [];
for (const child of frag.toArray()) content.push(decodeNode(child));
return { type: "doc", content };
}
const openStacks = [];
after(async () => {
await Promise.all(
openStacks.map(
({ server, hocuspocus }) =>
new Promise((resolve) => {
server.close(() => {
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
});
}),
),
);
});
async function spawnCollabStack(seedDoc) {
const state = { lastDoc: null };
const hocuspocus = new Hocuspocus({
quiet: true,
async onLoadDocument() {
return buildYDoc(seedDoc);
},
async onChange(data) {
try {
state.lastDoc = fragmentToJson(data.document.getXmlFragment("default"));
} catch {
/* ignore teardown-race decode errors */
}
},
});
const wss = new WebSocketServer({ noServer: true });
const server = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (req.url === "/api/auth/login") {
res.writeHead(200, {
"Content-Type": "application/json",
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
res.end(JSON.stringify({ success: true }));
return;
}
if (req.url === "/api/auth/collab-token") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "not found" }));
});
});
server.on("upgrade", (request, socket, head) => {
if (!request.url || !request.url.startsWith("/collab")) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
hocuspocus.handleConnection(ws, request);
});
});
const baseURL = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
openStacks.push({ server, hocuspocus });
return { state, baseURL };
}
function seed() {
return {
type: "doc",
content: [
{ type: "paragraph", attrs: { id: "p-id" }, content: [{ type: "text", text: "seed" }] },
],
};
}
test("updatePageMarkdown wiring: extensions OFF — `$…$` literal, www not linked, https links", async () => {
const { state, baseURL } = await spawnCollabStack(seed());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.updatePage(PAGE, "cfg $x=1$ and www.host.com and https://ex.com");
assert.ok(state.lastDoc, "a document was persisted");
assert.equal(findAll(state.lastDoc, "mathInline").length, 0, "no phantom math from an agent write");
assert.ok(allText(state.lastDoc).includes("$x=1$"), "literal dollars preserved");
assert.ok(allText(state.lastDoc).includes("www.host.com"), "bare domain preserved as text");
// The explicit https URL still links (only the schemeless autolink is off).
const links = findAll(state.lastDoc, "text").filter((t) =>
t.marks?.some((m) => m.type === "link"),
);
assert.ok(links.some((t) => t.text?.includes("ex.com")), "explicit https still links");
});
test("import_page_markdown wiring: DEFAULTS — exported `$x^2$` re-imports AS a math node (#328)", async () => {
const { state, baseURL } = await spawnCollabStack(seed());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
// A self-contained docmost markdown file whose body carries a math span (as the
// exporter emits it). import_page_markdown must import it with math ON.
const meta = { version: 1, pageId: PAGE, slugId: "s", title: "T", spaceId: "sp", parentPageId: null };
const fullMd = serializeDocmostMarkdown(meta, "energy is $x^2$ here", []);
await client.importPageMarkdown(PAGE, fullMd);
assert.ok(state.lastDoc, "a document was persisted");
assert.equal(
findAll(state.lastDoc, "mathInline").length,
1,
"the lossless round-trip is intact: math survives import_page_markdown",
);
});
+2 -123
View File
@@ -51,14 +51,6 @@ const hasRule = (issues, rule, cellId) =>
(i) => i.rule === rule && (cellId === undefined || i.cellId === cellId),
);
// Reverse the attribute-value XML escaping used in the `content=` payload.
const unescapeAttr = (s) =>
s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
// --- style parsing ---------------------------------------------------------
test("parseStyle: base stylename + key=value pairs", () => {
@@ -343,20 +335,16 @@ test("encode/build: a title with < > \" & round-trips without corrupting the SVG
// The outer content="..." attribute must not be broken by the title: the raw
// title metacharacters never appear literally in the SVG markup (they are
// entity-escaped inside content=, doubly so where the file XML already escaped
// them inside name="...").
// base64-encoded inside content=, and escaped inside the file XML).
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
// The diagram model still decodes losslessly despite the exotic title.
assert.equal(decodeDrawioSvg(svg), model);
// content= is now entity-encoded XML (draw.io's native form), never base64.
assert.match(contentMatch[1], /^&lt;mxfile/);
// The file XML is well-formed: the title lives in name="..." as escaped
// entities, so unescaping recovers the original title byte-for-byte.
const fileXml = unescapeAttr(contentMatch[1]);
const fileXml = Buffer.from(contentMatch[1], "base64").toString("utf-8");
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "the diagram name attribute is intact and quote-safe");
const decodedTitle = nameMatch[1]
@@ -370,112 +358,3 @@ test("encode/build: a title with < > \" & round-trips without corrupting the SVG
const file = encodeDrawioFile(model, title);
assert.match(file, /name="A &lt; B &gt; C &quot; D &amp; E">/);
});
// --- #507: content= is entity-encoded XML, never base64 --------------------
// A model whose cell values carry Cyrillic, ё and an em dash — exactly the
// characters draw.io's Latin-1 atob mangles when content= is base64.
const CYRILLIC_MODEL =
"<mxGraphModel><root>" +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Старт-бит — ёж" style="rounded=1;html=1;" vertex="1" parent="1">' +
'<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
test("#507: buildDrawioSvg writes content= as entity-encoded XML (not base64)", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, "Диаграмма");
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
const content = contentMatch[1];
// The content= value is the entity-encoded mxfile XML — starts with `&lt;mxfile`.
assert.match(content, /^&lt;mxfile/, "content= is entity-encoded mxfile XML");
// It must NOT be a base64 blob: base64 has no XML entities and no literal `<`.
assert.ok(content.includes("&lt;"), "content= carries XML entities, not base64");
// Cyrillic / ё / — survive verbatim in the attribute (raw UTF-8, not atob-mangled).
assert.ok(content.includes("Старт-бит — ёж"), "non-ASCII value is raw UTF-8 in content=");
assert.ok(content.includes("Диаграмма"), "non-ASCII title is raw UTF-8 in content=");
// The mojibake that base64+atob would have produced must be absent.
assert.ok(!content.includes("Ð"), "no Latin-1 mojibake in content=");
});
test("#507: Cyrillic model round-trips byte-stable through buildDrawioSvg -> decodeDrawioSvg", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, "Заголовок — ё");
assert.equal(decodeDrawioSvg(svg), model);
});
test("#507 back-compat: an OLD base64-form .drawio.svg still decodes losslessly", () => {
const model = normalizeXml(CYRILLIC_MODEL);
// Reproduce the pre-fix write path: encodeDrawioFile -> base64 in content=.
const file = encodeDrawioFile(model, "Старая диаграмма");
const base64 = Buffer.from(file, "utf-8").toString("base64");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" content="${base64}"><g/></svg>`;
// No XML entities, purely base64 alphabet — this is the legacy form.
assert.ok(!base64.includes("<") && !base64.includes("&"));
assert.equal(decodeDrawioSvg(svg), model);
});
test("#507 negative: non-ASCII in a cell value AND in the title round-trip clean", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const title = "Тест — ёмкость № 5";
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, title);
// Model recovered byte-for-byte.
assert.equal(decodeDrawioSvg(svg), model);
// Title lands in the <diagram name="..."> of the decoded file XML, intact.
const content = /content="([^"]*)"/.exec(svg)[1];
const fileXml = unescapeAttr(content);
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "diagram name attribute present");
assert.equal(unescapeAttr(nameMatch[1]), title);
});
// --- #507 F1: literal tab/newline/CR in an attribute value survive the
// content= round-trip. The whole mxfile XML lives in one content="..." attr;
// XML attribute-value normalization collapses a LITERAL tab/newline/CR to a
// single space on DOM read, so the escape must emit numeric char-refs instead.
const CTRL_MODEL =
"<mxGraphModel><root>" +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="col1\tcol2" style="html=1;\nshadow=0" vertex="1" parent="1">' +
'<mxGeometry x="10" y="10" width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="3" value="Line1\nLine2\rLine3" vertex="1" parent="1">' +
'<mxGeometry x="10" y="100" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
test("#507 F1: literal tab/newline/CR in a value round-trip byte-stable (DOM decode)", () => {
const svg = buildDrawioSvg(CTRL_MODEL, "<g/>", { width: 200, height: 200 });
const content = /content="([^"]*)"/.exec(svg)[1];
// The tab/newline/CR must be emitted as numeric char-refs, never as literal
// control chars (which DOM attribute-value normalization would eat).
assert.ok(
!/[\t\n\r]/.test(content),
"no literal tab/newline/CR survive in the content= attribute",
);
assert.ok(
content.includes("&#x9;") &&
content.includes("&#xa;") &&
content.includes("&#xd;"),
"tab/newline/CR are emitted as numeric char-refs",
);
// Full DOM decode recovers the model byte-for-byte, control chars intact.
assert.equal(decodeDrawioSvg(svg), CTRL_MODEL);
});
test("#507 F1: regex fallback decodes tab/newline/CR char-refs (agrees with DOM path)", () => {
const svg = buildDrawioSvg(CTRL_MODEL, "<g/>", { width: 200, height: 200 });
const content = /content="([^"]*)"/.exec(svg)[1];
// A bare `&` makes the SVG wrapper malformed, forcing extractContentAttr onto
// its regex fallback branch. That branch must decode the tab/newline/CR
// char-refs exactly like the DOM path, or the two decoders diverge.
const malformedSvg = `<svg content="${content}">&</svg>`;
assert.equal(decodeDrawioSvg(malformedSvg), CTRL_MODEL);
// The well-formed (DOM) path yields the identical result.
assert.equal(decodeDrawioSvg(svg), CTRL_MODEL);
});
@@ -1,108 +0,0 @@
// #502: the two layered markdown extensions (`$…$` math, schemeless fuzzy
// autolink) are decided PER CALLER of the shared write importer, not hardcoded in
// the wrapper — because the callers need OPPOSITE behavior:
// - AGENT-authored plain markdown (updatePageMarkdown, patch_node/insert_node)
// -> extensions OFF: a `$…$` config span stays literal, a bare `www.host` is
// not autolinked, an explicit `https://…` still links.
// - FULL-FILE round-trip import (import_page_markdown, #328 lossless) ->
// DEFAULTS (extensions ON): an exported math node's `$x^2$` re-imports AS a
// math node, so the export→import pair is NOT broken.
// This unit file pins the importer contract at each of those semantics. The
// caller-WIRING (that each client method passes the right options) is pinned by
// the collab-backed test in mock/write-path-extensions-wiring.test.mjs.
import { test } from "node:test";
import assert from "node:assert/strict";
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
import { importMarkdownFragment } from "../../build/lib/markdown-fragment.js";
import {
markdownToProseMirror,
convertProseMirrorToMarkdown,
} from "@docmost/prosemirror-markdown";
const OFF = { parseMath: false, fuzzyLinkify: false };
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
return acc;
}
function allText(node, acc = []) {
if (!node || typeof node !== "object") return acc.join("");
if (node.type === "text" && typeof node.text === "string") acc.push(node.text);
if (Array.isArray(node.content)) for (const c of node.content) allText(c, acc);
return acc.join("");
}
function hasLink(node) {
return findAll(node, "text").some((t) => t.marks?.some((m) => m.type === "link"));
}
// --- AGENT-write semantics (updatePageMarkdown): extensions OFF -----------------
test("agent-write importer (OFF): `$…$` config stays literal, no math node", async () => {
const doc = await markdownToProseMirrorCanonical("export A=$FOO and B=$BAR done", OFF);
assert.equal(findAll(doc, "mathInline").length, 0);
assert.equal(allText(doc), "export A=$FOO and B=$BAR done");
});
test("agent-write importer (OFF): schemeless www NOT linked; explicit https STILL linked", async () => {
const bare = await markdownToProseMirrorCanonical("see www.example.com here", OFF);
assert.equal(hasLink(bare), false);
assert.equal(allText(bare), "see www.example.com here");
const explicit = await markdownToProseMirrorCanonical("see https://example.com here", OFF);
assert.equal(hasLink(explicit), true);
});
test("agent-write importer (OFF): heading + list structure preserved", async () => {
const doc = await markdownToProseMirrorCanonical("## Heading\n\n- one\n- two", OFF);
assert.equal(findAll(doc, "heading").length, 1);
assert.equal(findAll(doc, "bulletList").length, 1);
});
test("fragment importer (patch_node/insert_node) is OFF: `$x=1$` literal, https links", async () => {
const { blocks } = await importMarkdownFragment("cfg $x=1$ and https://ex.com");
const doc = { type: "doc", content: blocks };
assert.equal(findAll(doc, "mathInline").length, 0);
assert.equal(hasLink(doc), true);
assert.ok(allText(doc).includes("$x=1$"));
});
// --- FULL-FILE import semantics (import_page_markdown): DEFAULTS (math ON) ------
test("import_page_markdown importer (DEFAULTS): `$x^2$` DOES create a math node", async () => {
// markdownToProseMirrorCanonical WITHOUT options == what import_page_markdown
// passes. Math must survive (this is the REAL importer, not the package default).
const doc = await markdownToProseMirrorCanonical("$x^2$");
assert.equal(findAll(doc, "mathInline").length, 1);
assert.equal(findAll(doc, "mathInline")[0].attrs.text, "x^2");
});
test("import_page_markdown (DEFAULTS): #328 lossless export->import keeps math (round-trip)", async () => {
// The exporter serializes a math node as readable `$x^2$`; re-importing through
// the REAL import_page_markdown importer (markdownToProseMirrorCanonical, no
// options) must yield a math node again and be byte-stable. Under the BUGGY code
// (canonical hardcoded parseMath:false) doc2 would be literal text -> this test
// would REDDEN.
const source = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "mathInline", attrs: { text: "x^2" } }] }],
};
const md1 = convertProseMirrorToMarkdown(source);
const doc2 = await markdownToProseMirrorCanonical(md1); // real import path, defaults
assert.equal(findAll(doc2, "mathInline").length, 1, "math survives the round-trip import");
const md2 = convertProseMirrorToMarkdown(doc2);
assert.equal(md2, md1, "export is byte-stable across the round-trip");
});
test("import_page_markdown (DEFAULTS): a schemeless www IS autolinked", async () => {
const doc = await markdownToProseMirrorCanonical("see www.example.com here");
assert.equal(hasLink(doc), true);
});
// --- Package default importer (editor/file-import/git-sync) is UNCHANGED --------
test("PACKAGE default importer keeps math ON (editor/file/git-sync path)", async () => {
const doc = await markdownToProseMirror("$x^2$");
assert.equal(findAll(doc, "mathInline").length, 1);
});
@@ -25,7 +25,6 @@ export {
markdownToProseMirror,
markdownToProseMirrorSync,
} from "./markdown-to-prosemirror.js";
export type { MarkdownImportOptions } from "./markdown-to-prosemirror.js";
// Foreign-markdown normalizer (#493): the input-liberal pre-pass that rewrites
// GFM `[^id]` reference footnotes to canonical inline `^[body]`. Two variants:
@@ -7,7 +7,7 @@
* natively through the collab gateway, so no websocket/Yjs write-path lives
* here.
*/
import { Marked, Tokenizer } from "marked";
import { Marked } from "marked";
import { parseHtmlDocument, generateJsonWith } from "./dom-parser.js";
import type { TokenizerExtension, RendererExtension } from "marked";
import { docmostExtensions } from "./docmost-schema.js";
@@ -230,88 +230,19 @@ function escapeFootnoteAttr(value: string): string {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
/**
* Options controlling which of the two *layered* markdown extensions the
* canonical importer applies. Both default to `true`, so the human editor,
* file-import and git-sync paths keep their existing behavior byte-for-byte;
* ONLY the MCP markdown-write path opts OUT (see #502).
*
* The extensions are optional because they are the SOURCE of two silent
* corruptions when an AGENT writes plain prose/config as markdown:
* - `parseMath`: a `$$` span becomes a `mathInline` node. An agent writing a
* config like `export A=$FOO and B=$BAR` gets `$FOO and B=$` silently turned
* into a formula. With `parseMath:false` the `$` stays literal text (real
* formulas go through `update_page_json` with `mathInline`/`mathBlock`).
* - `fuzzyLinkify`: marked's GFM autolinker turns a SCHEMELESS `www.foo.com`
* (and email) into a link. With `fuzzyLinkify:false` a schemeless domain
* stays literal text; an EXPLICIT `https://…` STILL becomes a link (only the
* fuzzy, schemeless autolink is suppressed).
*/
export interface MarkdownImportOptions {
/** Apply the `$…$` / `$$…$$` math extensions (default true). */
parseMath?: boolean;
/** Apply marked's GFM schemeless (fuzzy) autolinker (default true). */
fuzzyLinkify?: boolean;
}
/**
* `fuzzyLinkify:false` override of marked's built-in GFM `url` inline tokenizer.
*
* The stock tokenizer autolinks THREE shapes: a schemeless `www.host` domain, a
* bare email, and an EXPLICIT `scheme://…` URL. #502 wants only the last kept
* a schemeless domain/email an agent typed as prose must stay literal text, but
* a deliberate `https://…` still links. We delegate to the original tokenizer
* and, when it matched, DROP the token (returning `undefined`, so the run stays
* literal text) unless the matched RAW text carries an explicit `scheme:` prefix.
* `www.`/email matches have no scheme in their raw text, so they are dropped;
* `https://…`/`ftp://…` keep their link.
*/
const SCHEME_PREFIX_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
const noFuzzyUrlTokenizer = {
url(this: any, src: string) {
const token = Tokenizer.prototype.url.call(this, src) as any;
if (!token) return token;
// Keep only explicit-scheme URLs; schemeless `www.`/email matches -> literal.
return SCHEME_PREFIX_RE.test(token.raw) ? token : undefined;
},
};
/**
* Build a dedicated `marked` instance for a given extension combination: default
* (GFM) options plus the `==` highlight and `^[…]` footnote inline extensions
* ALWAYS, the `$$`/`$$$$` math extensions only when `parseMath`, and the
* schemeless-autolink suppressor only when `!fuzzyLinkify`. Built on a private
* `Marked` instance so nothing leaks into the global `marked` singleton.
*/
function buildMarkedInstance(parseMath: boolean, fuzzyLinkify: boolean): Marked {
const extensions: (TokenizerExtension & RendererExtension)[] = [
// Dedicated marked instance: default (GFM) options plus the `==` highlight
// inline extension, the `$…$` / `$$…$$` math extensions (#293 canon #6), and the
// `^[…]` inline-footnote extension (#293 canon #2). Constructed once at module
// load so the extensions are registered exactly once and never mutate the global
// `marked` singleton.
const markedInstance = new Marked().use({
extensions: [
highlightMarkExtension,
mathInlineExtension,
mathBlockExtension,
footnoteInlineExtension,
];
if (parseMath) {
extensions.push(mathInlineExtension, mathBlockExtension);
}
const instance = new Marked().use({ extensions });
if (!fuzzyLinkify) {
instance.use({ tokenizer: noFuzzyUrlTokenizer as any });
}
return instance;
}
// Memoize one instance per (parseMath, fuzzyLinkify) combination so the
// extensions are registered exactly once per combo (never on the global
// singleton). The default `(true, true)` instance preserves the pre-#502
// behavior exactly for the editor/file-import/git-sync paths.
const markedInstanceCache = new Map<string, Marked>();
function getMarkedInstance(parseMath: boolean, fuzzyLinkify: boolean): Marked {
const key = `${parseMath}:${fuzzyLinkify}`;
let instance = markedInstanceCache.get(key);
if (!instance) {
instance = buildMarkedInstance(parseMath, fuzzyLinkify);
markedInstanceCache.set(key, instance);
}
return instance;
}
],
});
// NOTE: this module no longer installs a module-level `global.window`/`document`
// jsdom shim. The HTML->DOM passes below (bridgeTaskLists / applyCommentDirectives
@@ -370,7 +301,7 @@ const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
// preprocess is sync. Keeping it sync lets a sync converter entry
// (`markdownToProseMirrorSync`, used by the client's chat renderer which must
// stay synchronous) share this exact logic with the async entry.
function preprocessCallouts(markdown: string, markedInstance: Marked): string {
function preprocessCallouts(markdown: string): string {
// Defensive cap: skip preprocessing for pathologically large inputs.
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return markdown;
@@ -1024,7 +955,7 @@ function applyCommentDirectives(html: string): string {
// sups stay inert) rather than hang.
const MAX_FOOTNOTE_ROUNDS = 10000;
function assembleFootnotes(html: string, markedInstance: Marked): string {
function assembleFootnotes(html: string): string {
// Cheap early-out: nothing carries a footnote body -> nothing to assemble.
if (!html.includes("data-fn-text")) return html;
const document = parseHtmlDocument(html);
@@ -1150,18 +1081,8 @@ function stripEmptyParagraphs(node: any): any {
* for every existing Node consumer). A sync entry is REQUIRED by the client's
* chat renderer, which runs inside a React render/useMemo and cannot await.
*/
export function markdownToProseMirrorSync(
markdownContent: string,
options?: MarkdownImportOptions,
): any {
// Select the marked instance for this call's extension combination. Defaults
// (math + fuzzy autolink ON) preserve the editor/file-import/git-sync paths;
// the MCP markdown-write path passes both false (#502).
const markedInstance = getMarkedInstance(
options?.parseMath ?? true,
options?.fuzzyLinkify ?? true,
);
const withCallouts = preprocessCallouts(markdownContent, markedInstance);
export function markdownToProseMirrorSync(markdownContent: string): any {
const withCallouts = preprocessCallouts(markdownContent);
const html = markedInstance.parse(withCallouts) as string;
// Materialize comment directives (#293 #9 attached textAlign; #5 standalone
// subpages/pageBreak) while the comment nodes still exist, before generateJSON
@@ -1170,7 +1091,7 @@ export function markdownToProseMirrorSync(
// #293 canon #2: assemble the doc-level footnote list from the `<sup
// data-fn-text>` markers (from `^[…]` or the raw-HTML column form) before
// generateJSON, so references + definitions materialize into the schema model.
const withFootnotes = assembleFootnotes(withAttrs, markedInstance);
const withFootnotes = assembleFootnotes(withAttrs);
const bridged = bridgeTaskLists(withFootnotes);
const doc = generateJsonWith(bridged, docmostExtensions);
return stripEmptyParagraphs(doc);
@@ -1183,7 +1104,6 @@ export function markdownToProseMirrorSync(
*/
export async function markdownToProseMirror(
markdownContent: string,
options?: MarkdownImportOptions,
): Promise<any> {
return markdownToProseMirrorSync(markdownContent, options);
return markdownToProseMirrorSync(markdownContent);
}
@@ -1,170 +0,0 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (like math.test.ts) so we exercise the real
// converter and its module-load jsdom setup.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
// ---------------------------------------------------------------------------
// #502: the canonical importer is parameterized with `{ parseMath, fuzzyLinkify }`
// (both DEFAULT true). The MCP markdown-WRITE path passes both false so an agent's
// plain prose/config is imported LITERALLY:
// - parseMath:false -> a `$…$` span stays literal text (no phantom mathInline)
// - fuzzyLinkify:false -> a SCHEMELESS `www.host`/email stays literal text; an
// EXPLICIT `https://…` STILL links (only the fuzzy autolink is suppressed).
// The DEFAULTS (editor/file-import/git-sync) keep math + fuzzy autolink ON, so
// this file also pins that the defaults are UNCHANGED.
// ---------------------------------------------------------------------------
const OFF = { parseMath: false, fuzzyLinkify: false } as const;
// Collect the first paragraph's inline children (the common assertion target).
function firstParaInline(doc: any): any[] {
const p = doc.content?.find((n: any) => n.type === 'paragraph');
return p?.content ?? [];
}
function findAll(node: any, type: string, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content)) {
for (const c of node.content) findAll(c, type, acc);
}
return acc;
}
// Flatten every text node's text (ignoring marks/structure) into one string.
function allText(node: any, acc: string[] = []): string {
if (!node || typeof node !== 'object') return acc.join('');
if (node.type === 'text' && typeof node.text === 'string') acc.push(node.text);
if (Array.isArray(node.content)) {
for (const c of node.content) allText(c, acc);
}
return acc.join('');
}
describe('#502 importer options — extensions OFF (MCP write path)', () => {
it('a `$…$` config span stays literal text (no mathInline node)', async () => {
const md = 'export A=$FOO and B=$BAR done';
const doc = await markdownToProseMirror(md, OFF);
expect(findAll(doc, 'mathInline')).toHaveLength(0);
expect(allText(doc)).toBe('export A=$FOO and B=$BAR done');
});
it('a real-looking `$x=1$` span stays literal text', async () => {
const doc = await markdownToProseMirror('$x=1$', OFF);
expect(findAll(doc, 'mathInline')).toHaveLength(0);
expect(allText(doc)).toBe('$x=1$');
});
it('the reported `($ticket_lifetime=2592000)` config stays literal', async () => {
const doc = await markdownToProseMirror('($ticket_lifetime=2592000)', OFF);
expect(findAll(doc, 'mathInline')).toHaveLength(0);
expect(allText(doc)).toBe('($ticket_lifetime=2592000)');
});
it('a `$$…$$` block stays literal (no mathBlock node)', async () => {
const doc = await markdownToProseMirror('$$\nx^2\n$$', OFF);
expect(findAll(doc, 'mathBlock')).toHaveLength(0);
expect(allText(doc)).toContain('x^2');
});
it('a SCHEMELESS `www.example.com` is NOT autolinked', async () => {
const doc = await markdownToProseMirror('see www.example.com here', OFF);
const inline = firstParaInline(doc);
expect(inline.some((n: any) => n.marks?.some((m: any) => m.type === 'link'))).toBe(false);
expect(allText(doc)).toBe('see www.example.com here');
});
it('a bare dotted domain `gitea.vvzvlad.xyz` stays literal text', async () => {
const doc = await markdownToProseMirror('see gitea.vvzvlad.xyz here', OFF);
expect(findAll(doc, 'text').every((t: any) => !t.marks?.some((m: any) => m.type === 'link'))).toBe(true);
});
it('an EXPLICIT `https://…` STILL becomes a link', async () => {
const doc = await markdownToProseMirror('see https://example.com here', OFF);
const linked = firstParaInline(doc).find((n: any) =>
n.marks?.some((m: any) => m.type === 'link'),
);
expect(linked?.text).toBe('https://example.com');
const link = linked.marks.find((m: any) => m.type === 'link');
expect(link.attrs.href).toBe('https://example.com');
});
it('`foo_bar_baz` is not italicized (CommonMark, unaffected by options)', async () => {
const doc = await markdownToProseMirror('foo_bar_baz', OFF);
expect(findAll(doc, 'text').some((t: any) => t.marks?.some((m: any) => m.type === 'italic' || m.type === 'em'))).toBe(false);
expect(allText(doc)).toBe('foo_bar_baz');
});
it('block STRUCTURE (headings, lists, code fence) is preserved with extensions off', async () => {
const md = '## Heading\n\n- one\n- two\n\n```\ncode $x$ here\n```';
const doc = await markdownToProseMirror(md, OFF);
expect(findAll(doc, 'heading')).toHaveLength(1);
expect(findAll(doc, 'bulletList')).toHaveLength(1);
expect(findAll(doc, 'codeBlock')).toHaveLength(1);
// The `$x$` inside the code fence never becomes math regardless.
expect(findAll(doc, 'mathInline')).toHaveLength(0);
});
});
describe('#502 importer options — DEFAULTS unchanged (editor/file/git-sync)', () => {
it('DEFAULT: `$x^2$` DOES create a mathInline node (file-import unaffected)', async () => {
const doc = await markdownToProseMirror('$x^2$');
expect(findAll(doc, 'mathInline')).toHaveLength(1);
expect(findAll(doc, 'mathInline')[0].attrs.text).toBe('x^2');
});
it('DEFAULT: a schemeless `www.example.com` IS autolinked', async () => {
const doc = await markdownToProseMirror('see www.example.com here');
const linked = firstParaInline(doc).find((n: any) =>
n.marks?.some((m: any) => m.type === 'link'),
);
expect(linked?.text).toBe('www.example.com');
});
it('DEFAULT: explicitly passing {parseMath:true, fuzzyLinkify:true} equals no-options', async () => {
const a = await markdownToProseMirror('$x^2$ and www.foo.com');
const b = await markdownToProseMirror('$x^2$ and www.foo.com', {
parseMath: true,
fuzzyLinkify: true,
});
expect(JSON.stringify(b)).toBe(JSON.stringify(a));
});
it('round-trip export->import at the PACKAGE-DEFAULT layer keeps math (file-import / #328)', async () => {
// The package DEFAULT importer is what the server file-import path uses. A
// page holding real math is exported, then re-imported with DEFAULTS (math
// ON): the mathInline survives. (The tool-level import_page_markdown round-
// trip, which goes through mcp's markdownToProseMirrorCanonical, is pinned
// authoritatively in @docmost/mcp's mcp-write-extensions-off test.)
const source = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'mathInline', attrs: { text: 'x^2' } }] },
],
};
const md1 = convertProseMirrorToMarkdown(source);
const doc2 = await markdownToProseMirror(md1); // DEFAULTS -> math on
expect(findAll(doc2, 'mathInline')).toHaveLength(1);
const md2 = convertProseMirrorToMarkdown(doc2);
expect(md2).toBe(md1); // byte-stable
});
});
describe('#502 mutation guard', () => {
// If a future change silently flipped the MCP write path back to parseMath:true,
// the OFF assertion below would go RED — this pins the discriminating behavior.
it('with parseMath:true the same span DOES become math (proves the flag drives it)', async () => {
const on = await markdownToProseMirror('$x=1$', { parseMath: true, fuzzyLinkify: false });
expect(findAll(on, 'mathInline')).toHaveLength(1);
const off = await markdownToProseMirror('$x=1$', OFF);
expect(findAll(off, 'mathInline')).toHaveLength(0);
});
it('with fuzzyLinkify:true the same www domain DOES link (proves the flag drives it)', async () => {
const on = await markdownToProseMirror('www.example.com', { parseMath: false, fuzzyLinkify: true });
expect(findAll(on, 'text').some((t: any) => t.marks?.some((m: any) => m.type === 'link'))).toBe(true);
const off = await markdownToProseMirror('www.example.com', OFF);
expect(findAll(off, 'text').some((t: any) => t.marks?.some((m: any) => m.type === 'link'))).toBe(false);
});
});