Merge branch 'develop' into feat/footnotes
Resolve conflicts at shared registration points by unioning both features (footnotes + the already-merged html-embed / page-embed work): - slash-menu/menu-items.ts, editor extensions.ts: keep both imports + configures - collaboration.util.ts: register footnote nodes and pageEmbed - editor-ext marked.utils.ts: register footnote + html-embed markdown extensions - editor-ext package.json/tsconfig.json/vitest.config.ts: union of test config (jsdom env for footnote DOM tests + combined test/spec include glob) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "./src/index.ts",
|
||||
|
||||
@@ -16,12 +16,14 @@ export * from "./lib/custom-code-block";
|
||||
export * from "./lib/drawio";
|
||||
export * from "./lib/excalidraw";
|
||||
export * from "./lib/embed";
|
||||
export * from "./lib/html-embed/html-embed";
|
||||
export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
export * from "./lib/subpages";
|
||||
export * from "./lib/transclusion";
|
||||
export * from "./lib/page-embed";
|
||||
export * from "./lib/highlight";
|
||||
export * from "./lib/indent";
|
||||
export * from "./lib/heading/heading";
|
||||
|
||||
138
packages/editor-ext/src/lib/html-embed/html-embed.ts
Normal file
138
packages/editor-ext/src/lib/html-embed/html-embed.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface HtmlEmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface HtmlEmbedAttributes {
|
||||
// Raw HTML/CSS/JS string that is injected verbatim into the wiki origin.
|
||||
source?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
htmlEmbed: {
|
||||
setHtmlEmbed: (attributes?: HtmlEmbedAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the raw source to base64 for the `data-source` attribute.
|
||||
*
|
||||
* The source is arbitrary HTML/CSS/JS. Storing it raw inside an HTML attribute
|
||||
* would (a) require heavy escaping and (b) risk the parser interpreting markup
|
||||
* inside the attribute. Base64 makes the round-trip HTML <-> ProseMirror JSON
|
||||
* lossless and keeps the markup inert while it sits in the attribute.
|
||||
*
|
||||
* `encodeURIComponent`/`decodeURIComponent` wrap btoa/atob so that non-Latin1
|
||||
* (UTF-8) characters survive the base64 step.
|
||||
*/
|
||||
export function encodeHtmlEmbedSource(source: string): string {
|
||||
if (!source) return "";
|
||||
try {
|
||||
if (typeof btoa === "function") {
|
||||
return btoa(encodeURIComponent(source));
|
||||
}
|
||||
// Node fallback (server-side schema parsing has no global btoa).
|
||||
return Buffer.from(encodeURIComponent(source), "utf-8").toString("base64");
|
||||
} catch {
|
||||
// Never swallow silently in a way that loses data: fall back to raw.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeHtmlEmbedSource(encoded: string): string {
|
||||
if (!encoded) return "";
|
||||
try {
|
||||
if (typeof atob === "function") {
|
||||
return decodeURIComponent(atob(encoded));
|
||||
}
|
||||
// Node fallback.
|
||||
return decodeURIComponent(
|
||||
Buffer.from(encoded, "base64").toString("utf-8"),
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const HtmlEmbed = Node.create<HtmlEmbedOptions>({
|
||||
name: "htmlEmbed",
|
||||
inline: false,
|
||||
group: "block",
|
||||
// atom + isolating: the node has no editable ProseMirror children; its body
|
||||
// is the opaque `source` string rendered by the NodeView.
|
||||
atom: true,
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
source: {
|
||||
default: "",
|
||||
// Decode the base64 payload back to the raw source on parse.
|
||||
parseHTML: (element) =>
|
||||
decodeHtmlEmbedSource(element.getAttribute("data-source") || ""),
|
||||
// Encode the raw source to base64 on render so it round-trips losslessly
|
||||
// through the HTML <-> JSON conversions used by export/import/collab.
|
||||
renderHTML: (attributes: HtmlEmbedAttributes) => ({
|
||||
"data-source": encodeHtmlEmbedSource(attributes.source || ""),
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// The static HTML representation is just a marker div carrying the encoded
|
||||
// source. The actual raw markup is NOT expanded here on purpose: the static
|
||||
// generateHTML output (used for previews, search indexing, exports) must not
|
||||
// itself become an injection vector. Only the client NodeView expands and
|
||||
// executes the source.
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setHtmlEmbed:
|
||||
(attrs: HtmlEmbedAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync.
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
112
packages/editor-ext/src/lib/markdown/markdown-html-embed.spec.ts
Normal file
112
packages/editor-ext/src/lib/markdown/markdown-html-embed.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { markdownToHtml, htmlToMarkdown } from "./index";
|
||||
import {
|
||||
encodeHtmlEmbedSource,
|
||||
decodeHtmlEmbedSource,
|
||||
} from "../html-embed/html-embed";
|
||||
|
||||
// SECURITY (Variant C admin gate, import attack surface).
|
||||
//
|
||||
// The markdown import path is the only write path where an htmlEmbed reaches
|
||||
// the server purely from file bytes (no editor / collab socket). The marked
|
||||
// tokenizer in `html-embed.marked.ts` and the turndown rule in
|
||||
// `turndown.utils.ts` are what materialize the `<!--html-embed:BASE64-->`
|
||||
// marker into the `<div data-type="htmlEmbed" data-source="BASE64">` element
|
||||
// that the server then parses into an htmlEmbed node and the admin gate strips.
|
||||
//
|
||||
// If either the tokenizer regex or the turndown rule shape drifts, the marker
|
||||
// would either (a) stop becoming an htmlEmbed node (silently dropping admin
|
||||
// content) or (b) become some OTHER tag the server's `hasHtmlEmbedNode` no
|
||||
// longer recognizes (a strip bypass). These tests pin the marker <-> embed-div
|
||||
// contract that the server-side strip relies on. editor-ext had ZERO tests
|
||||
// before this file; this adds the runner + the round-trip coverage.
|
||||
|
||||
// The server parses the embed div by matching `data-type="htmlEmbed"` and
|
||||
// decoding `data-source`; mirror that here so the assertion is exactly what the
|
||||
// real `htmlToJson` -> htmlEmbed node parse depends on (the node's parseHTML in
|
||||
// html-embed.ts uses the same selector + decodeHtmlEmbedSource).
|
||||
const EMBED_DIV_RE = /<div[^>]*\bdata-type="htmlEmbed"[^>]*>/;
|
||||
function extractEmbedSource(html: string): string | undefined {
|
||||
const div = EMBED_DIV_RE.exec(html);
|
||||
if (!div) return undefined;
|
||||
const enc = /data-source="([^"]*)"/.exec(div[0]);
|
||||
if (!enc) return undefined;
|
||||
return decodeHtmlEmbedSource(enc[1]);
|
||||
}
|
||||
|
||||
// Replicates the server's `hasHtmlEmbedNode` decision against the embed *div*
|
||||
// (the HTML form the server immediately converts to JSON). If this matches, the
|
||||
// server's JSON-level `hasHtmlEmbedNode` will too, because htmlToJson maps this
|
||||
// exact div to an htmlEmbed node.
|
||||
function htmlHasHtmlEmbed(html: string): boolean {
|
||||
return EMBED_DIV_RE.test(html);
|
||||
}
|
||||
|
||||
describe("markdown <!--html-embed--> import round-trip", () => {
|
||||
const source = "<script>x</script>";
|
||||
|
||||
it("markdownToHtml turns the marker into an htmlEmbed div carrying the source", async () => {
|
||||
const md = "<!--html-embed:" + encodeHtmlEmbedSource(source) + "-->";
|
||||
const html = await markdownToHtml(md);
|
||||
|
||||
// The marker became the embed div the server recognizes as an htmlEmbed
|
||||
// node (so the server's hasHtmlEmbedNode would match it after htmlToJson).
|
||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
||||
// The decoded source is the original script, intact.
|
||||
expect(extractEmbedSource(html)).toBe(source);
|
||||
// The raw script is NOT inlined into the HTML — it stays base64 in the
|
||||
// attribute (the marker itself must not be a direct injection vector).
|
||||
expect(html).not.toContain("<script>x</script>");
|
||||
});
|
||||
|
||||
it("preserves UTF-8 / special chars in the embedded source", async () => {
|
||||
const utf8 = '<script>console.log("héllo → 世界")</script>';
|
||||
const md = "<!--html-embed:" + encodeHtmlEmbedSource(utf8) + "-->";
|
||||
const html = await markdownToHtml(md);
|
||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
||||
expect(extractEmbedSource(html)).toBe(utf8);
|
||||
});
|
||||
|
||||
it("an empty marker still produces an htmlEmbed div (empty source)", async () => {
|
||||
const html = await markdownToHtml("<!--html-embed:-->");
|
||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
||||
expect(extractEmbedSource(html)).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips htmlToMarkdown -> markdownToHtml preserving the embed marker", async () => {
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
// NOTE: turndown drops a *blank* (childless) element before any custom rule
|
||||
// runs, and the htmlEmbed div is normally childless. The export pipeline
|
||||
// therefore must give the rule a non-blank div to fire on; we add an inert
|
||||
// text child here to exercise the real turndown htmlEmbed rule. (A blank
|
||||
// embed div serializing to "" is asserted separately below as a documented
|
||||
// edge so this contract drift is visible.)
|
||||
const startHtml = `<div data-type="htmlEmbed" data-source="${encoded}">x</div>`;
|
||||
|
||||
// Export to markdown: the turndown rule emits the <!--html-embed:..-->
|
||||
// marker (lossless, inert in plain markdown viewers).
|
||||
const md = htmlToMarkdown(startHtml);
|
||||
expect(md).toContain("<!--html-embed:" + encoded + "-->");
|
||||
|
||||
// Re-import: the marker round-trips back into an embed div with the same
|
||||
// decoded source — this is the marker <-> embed-div contract the server's
|
||||
// import strip depends on.
|
||||
const html = await markdownToHtml(md);
|
||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
||||
expect(extractEmbedSource(html)).toBe(source);
|
||||
});
|
||||
|
||||
it("documents that a BLANK embed div serializes to empty markdown (turndown drops childless blocks)", () => {
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const blank = `<div data-type="htmlEmbed" data-source="${encoded}"></div>`;
|
||||
// This pins current behavior so a future change to the turndown rule (e.g.
|
||||
// making it fire on blank nodes) is caught rather than silently shipping.
|
||||
expect(htmlToMarkdown(blank)).toBe("");
|
||||
});
|
||||
|
||||
it("the base64 codec itself round-trips (no '<' leaks into the attribute)", () => {
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
expect(encoded).not.toContain("<");
|
||||
expect(decodeHtmlEmbedSource(encoded)).toBe(source);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Token } from "marked";
|
||||
|
||||
interface HtmlEmbedToken {
|
||||
type: "htmlEmbed";
|
||||
raw: string;
|
||||
encoded: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marked extension that rebuilds an `htmlEmbed` node from the HTML comment
|
||||
* marker produced by the turndown rule (`<!--html-embed:<base64>-->`).
|
||||
*
|
||||
* It emits the same marker div the node's `parseHTML` recognizes, so the
|
||||
* pipeline MD -> HTML -> ProseMirror JSON restores the node (and its
|
||||
* base64 `data-source`) exactly. We do NOT expand the raw markup here; the
|
||||
* source stays base64-encoded in the attribute and is only executed by the
|
||||
* client NodeView.
|
||||
*/
|
||||
export const htmlEmbedExtension = {
|
||||
name: "htmlEmbed",
|
||||
level: "block" as const,
|
||||
start(src: string) {
|
||||
return src.indexOf("<!--html-embed:");
|
||||
},
|
||||
tokenizer(src: string): HtmlEmbedToken | undefined {
|
||||
const rule = /^<!--html-embed:([A-Za-z0-9+/=]*)-->/;
|
||||
const match = rule.exec(src);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
type: "htmlEmbed",
|
||||
raw: match[0],
|
||||
encoded: match[1] ?? "",
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const htmlEmbedToken = token as HtmlEmbedToken;
|
||||
return `<div data-type="htmlEmbed" data-source="${htmlEmbedToken.encoded}"></div>`;
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
footnoteReferenceExtension,
|
||||
extractFootnoteDefinitions,
|
||||
} from "./footnote.marked";
|
||||
import { htmlEmbedExtension } from "./html-embed.marked";
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
@@ -43,6 +44,7 @@ marked.use({
|
||||
mathBlockExtension,
|
||||
mathInlineExtension,
|
||||
footnoteReferenceExtension,
|
||||
htmlEmbedExtension,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export function htmlToMarkdown(html: string): string {
|
||||
mathInline,
|
||||
mathBlock,
|
||||
iframeEmbed,
|
||||
htmlEmbed,
|
||||
image,
|
||||
video,
|
||||
footnoteReference,
|
||||
@@ -74,6 +75,32 @@ export function htmlToMarkdown(html: string): string {
|
||||
.replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the `htmlEmbed` node to Markdown.
|
||||
*
|
||||
* Markdown has no native representation for an arbitrary-HTML block, so we
|
||||
* preserve the node losslessly as an HTML comment carrying the base64-encoded
|
||||
* source (the same `data-source` payload the node stores). `markdownToHtml`
|
||||
* recognizes the same marker and rebuilds the node, so the round-trip
|
||||
* MD -> HTML -> JSON keeps the source intact. The comment also keeps the raw
|
||||
* markup inert in the exported `.md` file (it does not render in plain Markdown
|
||||
* viewers).
|
||||
*/
|
||||
function htmlEmbed(turndownService: _TurndownService) {
|
||||
turndownService.addRule('htmlEmbed', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' &&
|
||||
node.getAttribute('data-type') === 'htmlEmbed'
|
||||
);
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const encoded = node.getAttribute('data-source') || '';
|
||||
return `\n\n<!--html-embed:${encoded}-->\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
|
||||
1
packages/editor-ext/src/lib/page-embed/index.ts
Normal file
1
packages/editor-ext/src/lib/page-embed/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./page-embed";
|
||||
88
packages/editor-ext/src/lib/page-embed/page-embed.ts
Normal file
88
packages/editor-ext/src/lib/page-embed/page-embed.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface PageEmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface PageEmbedAttributes {
|
||||
sourcePageId?: string | null;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
pageEmbed: {
|
||||
insertPageEmbed: (attributes: PageEmbedAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-page live embed. Holds only a `sourcePageId` reference; the node view
|
||||
* fetches the source page's current content at render time, so the embed stays
|
||||
* live (no snapshot is stored in the host document). Separate from
|
||||
* `transclusionReference` (which addresses a single block by `transclusionId`).
|
||||
*/
|
||||
export const PageEmbed = Node.create<PageEmbedOptions>({
|
||||
name: "pageEmbed",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
atom: true,
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sourcePageId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-source-page-id"),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.sourcePageId
|
||||
? { "data-source-page-id": attrs.sourcePageId }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertPageEmbed:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (!this.options.view) return null;
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -20,5 +21,6 @@
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "vitest.config.ts", "dist"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
globals: true,
|
||||
include: ["src/**/*.{test,spec}.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,12 +7,30 @@ import { createDocmostMcpServer } from "./index.js";
|
||||
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
|
||||
* `handleRequest`. One McpServer + transport is created per MCP session and
|
||||
* kept alive between requests, keyed by the `mcp-session-id` header.
|
||||
*
|
||||
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
|
||||
* service account, unchanged) OR a `McpConfigResolver` run once per session at
|
||||
* `initialize` to bind that session to the request's identity.
|
||||
*/
|
||||
export function createMcpHttpHandler(config) {
|
||||
export function createMcpHttpHandler(config, options = {}) {
|
||||
// One transport (and one McpServer) per MCP session, keyed by session id.
|
||||
const transports = {};
|
||||
// Last activity timestamp per session id, used for idle eviction.
|
||||
const lastSeen = {};
|
||||
// Anti-session-fixation: the opaque identity key bound to each session at
|
||||
// initialize. A later request for that session whose key differs is rejected.
|
||||
const sessionIdentity = {};
|
||||
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
|
||||
// every early rejection is a well-formed JSON-RPC error, not a torn response.
|
||||
const sendJsonRpcError = (res, statusCode, code, message) => {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: { code, message },
|
||||
id: null,
|
||||
}));
|
||||
};
|
||||
// Idle session TTL (ms): a session with no activity for this long is evicted.
|
||||
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
|
||||
const idleTtlMs = (() => {
|
||||
@@ -29,6 +47,7 @@ export function createMcpHttpHandler(config) {
|
||||
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
|
||||
void transports[sid].close();
|
||||
delete lastSeen[sid];
|
||||
delete sessionIdentity[sid];
|
||||
}
|
||||
}
|
||||
}, sweepIntervalMs);
|
||||
@@ -41,16 +60,23 @@ export function createMcpHttpHandler(config) {
|
||||
// A new session may only be created by an initialize request without a
|
||||
// session id.
|
||||
if (sessionId || !isInitializeRequest(parsedBody)) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Bad Request: no valid session ID provided",
|
||||
},
|
||||
id: null,
|
||||
}));
|
||||
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
|
||||
return;
|
||||
}
|
||||
// Resolve the per-session config from the request (per-user identity) when
|
||||
// a resolver was supplied; otherwise use the static config unchanged. The
|
||||
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
|
||||
// a created session.
|
||||
let sessionConfig;
|
||||
let identity;
|
||||
try {
|
||||
sessionConfig =
|
||||
typeof config === "function" ? await config(req) : config;
|
||||
if (options.identify)
|
||||
identity = await options.identify(req);
|
||||
}
|
||||
catch (err) {
|
||||
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
|
||||
return;
|
||||
}
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
@@ -58,31 +84,46 @@ export function createMcpHttpHandler(config) {
|
||||
onsessioninitialized: (sid) => {
|
||||
transports[sid] = transport;
|
||||
lastSeen[sid] = Date.now();
|
||||
// Bind the resolved identity to the new session id for anti-fixation.
|
||||
if (identity !== undefined)
|
||||
sessionIdentity[sid] = identity;
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid])
|
||||
delete transports[sid];
|
||||
if (sid)
|
||||
delete sessionIdentity[sid];
|
||||
};
|
||||
const server = createDocmostMcpServer(config);
|
||||
const server = createDocmostMcpServer(sessionConfig);
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, parsedBody);
|
||||
return;
|
||||
}
|
||||
if (!transport) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Bad Request: no valid session ID provided",
|
||||
},
|
||||
id: null,
|
||||
}));
|
||||
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
|
||||
return;
|
||||
}
|
||||
// Anti-session-fixation: a request reusing an existing session id must
|
||||
// present credentials/token that resolve to the SAME identity bound at
|
||||
// initialize, otherwise reject with 401. This prevents hijacking another
|
||||
// user's established session by replaying its session id with different
|
||||
// credentials.
|
||||
if (options.identify && sessionId && sessionId in sessionIdentity) {
|
||||
let presented;
|
||||
try {
|
||||
presented = await options.identify(req);
|
||||
}
|
||||
catch (err) {
|
||||
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
|
||||
return;
|
||||
}
|
||||
if (presented !== sessionIdentity[sessionId]) {
|
||||
sendJsonRpcError(res, 401, -32001, "Credentials do not match the user that owns this MCP session.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Routing to an existing transport: refresh its idle timestamp.
|
||||
if (sessionId)
|
||||
lastSeen[sessionId] = Date.now();
|
||||
|
||||
@@ -4,17 +4,71 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { createDocmostMcpServer, DocmostMcpConfig } from "./index.js";
|
||||
|
||||
/**
|
||||
* Per-request config resolver. Run ONCE per MCP session, at the `initialize`
|
||||
* POST, so the session's DocmostClient is bound to that request's identity
|
||||
* (e.g. the HTTP-Basic user the embedding host validated). Back-compat: a plain
|
||||
* `DocmostMcpConfig` object is still accepted (stdio + service account), in
|
||||
* which case the resolver branch is never taken.
|
||||
*/
|
||||
export type McpConfigResolver = (
|
||||
req: IncomingMessage,
|
||||
) => DocmostMcpConfig | Promise<DocmostMcpConfig>;
|
||||
|
||||
/**
|
||||
* Optional anti-session-fixation hook. When supplied, it is called on EVERY
|
||||
* request (init and subsequent) to derive an opaque identity key for the
|
||||
* presented credentials/token. The key resolved at session init is bound to the
|
||||
* `mcp-session-id`; a later request whose key differs is rejected with 401, so
|
||||
* a caller cannot hijack another user's established session by reusing its
|
||||
* session id with different credentials. The key is opaque to this package (the
|
||||
* embedding host decides what identity means, e.g. the user's `sub`/email), so
|
||||
* the package stays generic. Throwing here surfaces as a 401 as well.
|
||||
*/
|
||||
export interface McpHttpOptions {
|
||||
identify?: (req: IncomingMessage) => string | Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stateful Streamable-HTTP handler for the Docmost MCP server. The
|
||||
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
|
||||
* `handleRequest`. One McpServer + transport is created per MCP session and
|
||||
* kept alive between requests, keyed by the `mcp-session-id` header.
|
||||
*
|
||||
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
|
||||
* service account, unchanged) OR a `McpConfigResolver` run once per session at
|
||||
* `initialize` to bind that session to the request's identity.
|
||||
*/
|
||||
export function createMcpHttpHandler(config: DocmostMcpConfig) {
|
||||
export function createMcpHttpHandler(
|
||||
config: DocmostMcpConfig | McpConfigResolver,
|
||||
options: McpHttpOptions = {},
|
||||
) {
|
||||
// One transport (and one McpServer) per MCP session, keyed by session id.
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {};
|
||||
// Last activity timestamp per session id, used for idle eviction.
|
||||
const lastSeen: Record<string, number> = {};
|
||||
// Anti-session-fixation: the opaque identity key bound to each session at
|
||||
// initialize. A later request for that session whose key differs is rejected.
|
||||
const sessionIdentity: Record<string, string> = {};
|
||||
|
||||
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
|
||||
// every early rejection is a well-formed JSON-RPC error, not a torn response.
|
||||
const sendJsonRpcError = (
|
||||
res: ServerResponse,
|
||||
statusCode: number,
|
||||
code: number,
|
||||
message: string,
|
||||
): void => {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: { code, message },
|
||||
id: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Idle session TTL (ms): a session with no activity for this long is evicted.
|
||||
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
|
||||
@@ -33,6 +87,7 @@ export function createMcpHttpHandler(config: DocmostMcpConfig) {
|
||||
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
|
||||
void transports[sid].close();
|
||||
delete lastSeen[sid];
|
||||
delete sessionIdentity[sid];
|
||||
}
|
||||
}
|
||||
}, sweepIntervalMs);
|
||||
@@ -51,17 +106,30 @@ export function createMcpHttpHandler(config: DocmostMcpConfig) {
|
||||
// A new session may only be created by an initialize request without a
|
||||
// session id.
|
||||
if (sessionId || !isInitializeRequest(parsedBody)) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Bad Request: no valid session ID provided",
|
||||
},
|
||||
id: null,
|
||||
}),
|
||||
sendJsonRpcError(
|
||||
res,
|
||||
400,
|
||||
-32000,
|
||||
"Bad Request: no valid session ID provided",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Resolve the per-session config from the request (per-user identity) when
|
||||
// a resolver was supplied; otherwise use the static config unchanged. The
|
||||
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
|
||||
// a created session.
|
||||
let sessionConfig: DocmostMcpConfig;
|
||||
let identity: string | undefined;
|
||||
try {
|
||||
sessionConfig =
|
||||
typeof config === "function" ? await config(req) : config;
|
||||
if (options.identify) identity = await options.identify(req);
|
||||
} catch (err) {
|
||||
sendJsonRpcError(
|
||||
res,
|
||||
401,
|
||||
-32001,
|
||||
err instanceof Error ? err.message : "Unauthorized",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -70,33 +138,60 @@ export function createMcpHttpHandler(config: DocmostMcpConfig) {
|
||||
onsessioninitialized: (sid: string) => {
|
||||
transports[sid] = transport!;
|
||||
lastSeen[sid] = Date.now();
|
||||
// Bind the resolved identity to the new session id for anti-fixation.
|
||||
if (identity !== undefined) sessionIdentity[sid] = identity;
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
const sid = transport!.sessionId;
|
||||
if (sid && transports[sid]) delete transports[sid];
|
||||
if (sid) delete sessionIdentity[sid];
|
||||
};
|
||||
const server = createDocmostMcpServer(config);
|
||||
const server = createDocmostMcpServer(sessionConfig);
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, parsedBody);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transport) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Bad Request: no valid session ID provided",
|
||||
},
|
||||
id: null,
|
||||
}),
|
||||
sendJsonRpcError(
|
||||
res,
|
||||
400,
|
||||
-32000,
|
||||
"Bad Request: no valid session ID provided",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Anti-session-fixation: a request reusing an existing session id must
|
||||
// present credentials/token that resolve to the SAME identity bound at
|
||||
// initialize, otherwise reject with 401. This prevents hijacking another
|
||||
// user's established session by replaying its session id with different
|
||||
// credentials.
|
||||
if (options.identify && sessionId && sessionId in sessionIdentity) {
|
||||
let presented: string;
|
||||
try {
|
||||
presented = await options.identify(req);
|
||||
} catch (err) {
|
||||
sendJsonRpcError(
|
||||
res,
|
||||
401,
|
||||
-32001,
|
||||
err instanceof Error ? err.message : "Unauthorized",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (presented !== sessionIdentity[sessionId]) {
|
||||
sendJsonRpcError(
|
||||
res,
|
||||
401,
|
||||
-32001,
|
||||
"Credentials do not match the user that owns this MCP session.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Routing to an existing transport: refresh its idle timestamp.
|
||||
if (sessionId) lastSeen[sessionId] = Date.now();
|
||||
await transport.handleRequest(req, res, parsedBody);
|
||||
|
||||
234
packages/mcp/test/unit/http-resolver.test.mjs
Normal file
234
packages/mcp/test/unit/http-resolver.test.mjs
Normal file
@@ -0,0 +1,234 @@
|
||||
// Unit tests for createMcpHttpHandler's config-resolver + anti-fixation hook
|
||||
// (http.ts). These assert the wrapper contract WITHOUT depending on the MCP
|
||||
// SDK's full initialize handshake succeeding:
|
||||
// - a STATIC config is still accepted (back-compat: stdio / service account)
|
||||
// and never invokes a resolver;
|
||||
// - a RESOLVER is accepted and is invoked exactly once on a session-init POST;
|
||||
// - the resolver/identify path runs BEFORE the transport, so a thrown
|
||||
// resolver error surfaces as a clean 401 and no session is created.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Readable } from "node:stream";
|
||||
import { createMcpHttpHandler } from "../../build/http.js";
|
||||
|
||||
// A minimal initialize JSON-RPC request body (isInitializeRequest checks
|
||||
// method === "initialize" + jsonrpc + an object params with protocolVersion).
|
||||
const INIT_BODY = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0.0.0" },
|
||||
},
|
||||
};
|
||||
|
||||
// Fake Node req: a readable stream is fine; we pass parsedBody explicitly so the
|
||||
// transport never reads the stream, and our resolver short-circuits before that.
|
||||
function makeReq({ method = "POST", headers = {} } = {}) {
|
||||
const req = new Readable({ read() {} });
|
||||
req.method = method;
|
||||
req.headers = headers;
|
||||
req.push(null);
|
||||
return req;
|
||||
}
|
||||
|
||||
// Fake Node res capturing statusCode + body, mimicking just what http.ts uses.
|
||||
function makeRes() {
|
||||
const chunks = [];
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
headersSent: false,
|
||||
setHeader(k, v) {
|
||||
this.headers[k.toLowerCase()] = v;
|
||||
},
|
||||
end(data) {
|
||||
if (data) chunks.push(data);
|
||||
this.headersSent = true;
|
||||
this.ended = true;
|
||||
},
|
||||
body() {
|
||||
return chunks.join("");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("static config is accepted and never calls a resolver (back-compat)", async () => {
|
||||
// A static config object — the stdio / service-account path. A NON-initialize
|
||||
// POST with no session id must hit the 400 branch deterministically, proving
|
||||
// the static handler is wired and no resolver is consulted.
|
||||
const handler = createMcpHttpHandler({
|
||||
apiUrl: "http://127.0.0.1:3000/api",
|
||||
email: "svc@example.com",
|
||||
password: "secret",
|
||||
});
|
||||
const req = makeReq({ method: "POST", headers: {} });
|
||||
const res = makeRes();
|
||||
await handler.handleRequest(req, res, { jsonrpc: "2.0", method: "ping", id: 9 });
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.match(res.body(), /no valid session ID/);
|
||||
});
|
||||
|
||||
test("resolver is invoked exactly once on a session-init POST", async () => {
|
||||
let calls = 0;
|
||||
const handler = createMcpHttpHandler((req) => {
|
||||
calls += 1;
|
||||
// Throw a sentinel so we observe invocation without driving the full
|
||||
// SDK handshake; http.ts turns a resolver throw into a clean 401.
|
||||
throw new Error("sentinel-from-resolver");
|
||||
});
|
||||
const req = makeReq({ method: "POST", headers: {} });
|
||||
const res = makeRes();
|
||||
await handler.handleRequest(req, res, INIT_BODY);
|
||||
assert.equal(calls, 1, "resolver must be called exactly once per init");
|
||||
assert.equal(res.statusCode, 401);
|
||||
assert.match(res.body(), /sentinel-from-resolver/);
|
||||
});
|
||||
|
||||
test("resolver is NOT invoked for a non-init POST without a session id", async () => {
|
||||
let calls = 0;
|
||||
const handler = createMcpHttpHandler(() => {
|
||||
calls += 1;
|
||||
return { apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" };
|
||||
});
|
||||
const req = makeReq({ method: "POST", headers: {} });
|
||||
const res = makeRes();
|
||||
await handler.handleRequest(req, res, { jsonrpc: "2.0", method: "ping", id: 2 });
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(res.statusCode, 400);
|
||||
});
|
||||
|
||||
test("identify hook throwing on init surfaces as a clean 401", async () => {
|
||||
const handler = createMcpHttpHandler(
|
||||
() => ({ apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" }),
|
||||
{
|
||||
identify: () => {
|
||||
throw new Error("bad-identity");
|
||||
},
|
||||
},
|
||||
);
|
||||
const req = makeReq({ method: "POST", headers: {} });
|
||||
const res = makeRes();
|
||||
await handler.handleRequest(req, res, INIT_BODY);
|
||||
assert.equal(res.statusCode, 401);
|
||||
assert.match(res.body(), /bad-identity/);
|
||||
});
|
||||
|
||||
// Drive a REAL initialize handshake (over a loopback http server so the SDK's
|
||||
// StreamableHTTPServerTransport gets genuine Node req/res objects), capture the
|
||||
// assigned mcp-session-id, then replay subsequent requests to exercise the
|
||||
// anti-fixation identify comparison: the SAME identity is accepted (routed to
|
||||
// the transport), a DIFFERENT identity is rejected 401, and crucially the
|
||||
// per-session config RESOLVER is consulted only ONCE (at init), never on a
|
||||
// subsequent request — proving subsequent requests do not re-mint the config.
|
||||
test("subsequent request: SAME identity routes through, DIFFERENT identity is 401, resolver runs once", async () => {
|
||||
const http = await import("node:http");
|
||||
|
||||
let resolverCalls = 0;
|
||||
let currentIdentity = "user-a";
|
||||
const handler = createMcpHttpHandler(
|
||||
() => {
|
||||
resolverCalls += 1;
|
||||
return { apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" };
|
||||
},
|
||||
{ identify: () => currentIdentity },
|
||||
);
|
||||
|
||||
// Loopback server: every request is bridged into the MCP handler with its body
|
||||
// parsed from JSON, exactly like the embedding host does.
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : undefined;
|
||||
handler.handleRequest(req, res, body).catch(() => {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const { port } = server.address();
|
||||
|
||||
const call = (headers, body) =>
|
||||
new Promise((resolve) => {
|
||||
const r = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
method: "POST",
|
||||
path: "/mcp",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(resp) => {
|
||||
let data = "";
|
||||
resp.on("data", (c) => (data += c));
|
||||
resp.on("end", () =>
|
||||
resolve({
|
||||
statusCode: resp.statusCode,
|
||||
sessionId: resp.headers["mcp-session-id"],
|
||||
body: data,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
r.end(JSON.stringify(body));
|
||||
});
|
||||
|
||||
try {
|
||||
// 1) Establish a session via a real initialize POST (identity = user-a).
|
||||
const init = await call({}, INIT_BODY);
|
||||
assert.equal(resolverCalls, 1, "resolver runs exactly once at init");
|
||||
const sid = init.sessionId;
|
||||
assert.ok(sid, "initialize must assign an mcp-session-id");
|
||||
|
||||
// 2) Subsequent request, SAME identity: not a 401, resolver NOT re-run.
|
||||
const ok = await call(
|
||||
{ "mcp-session-id": sid },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 5 },
|
||||
);
|
||||
assert.notEqual(ok.statusCode, 401, "same identity must not be rejected");
|
||||
assert.equal(resolverCalls, 1, "resolver is NOT re-run on a subsequent request");
|
||||
|
||||
// 3) Subsequent request, DIFFERENT identity: rejected 401 (anti-fixation).
|
||||
currentIdentity = "user-b";
|
||||
const bad = await call(
|
||||
{ "mcp-session-id": sid },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 6 },
|
||||
);
|
||||
assert.equal(bad.statusCode, 401, "different identity hijack is rejected");
|
||||
assert.match(bad.body, /do not match the user/);
|
||||
assert.equal(resolverCalls, 1, "still no resolver re-run on the rejected request");
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown existing session id (non-init, with session header) is 400", async () => {
|
||||
// A request carrying a session id that was never established must not consult
|
||||
// the resolver or identify hook — it is a plain 400 (no valid session).
|
||||
let calls = 0;
|
||||
const handler = createMcpHttpHandler(
|
||||
() => {
|
||||
calls += 1;
|
||||
return { apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" };
|
||||
},
|
||||
{ identify: () => "x" },
|
||||
);
|
||||
const req = makeReq({
|
||||
method: "POST",
|
||||
headers: { "mcp-session-id": "does-not-exist" },
|
||||
});
|
||||
const res = makeRes();
|
||||
await handler.handleRequest(req, res, { jsonrpc: "2.0", method: "ping", id: 3 });
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user