Merge pull request 'fix: review/red-team batch 2 — 30 issues (security, ws, page-templates, html-embed, mcp, tests, docs)' (#101) from fix/review-batch-2 into develop

Reviewed-on: #101
This commit was merged in pull request #101.
This commit is contained in:
2026-06-21 05:47:05 +03:00
66 changed files with 2209 additions and 708 deletions

View File

@@ -3,14 +3,31 @@ APP_URL=http://localhost:3000
PORT=3000
# --- Security / reverse proxy ---
# The app runs with Fastify `trustProxy` ENABLED, so it derives the client IP
# (req.ip) from the `X-Forwarded-For` header. That header is client-forgeable.
# Deploy this app behind a trusted reverse proxy that SETS/OVERWRITES (not
# appends) `X-Forwarded-For` with the real client IP. Without such a proxy, any
# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via
# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only
# from proxies on the configured trusted networks. Deploy this app behind a
# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For`
# with the real client IP. If XFF is trusted from an untrusted source, any
# per-IP throttling — including the /mcp Basic brute-force limiter — can be
# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs.
# (The /mcp limiter keeps a global per-email key as an IP-independent backstop,
# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.)
#
# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For.
# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted
# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip.
# This is the safe default for the common case where the reverse proxy runs on
# loopback or a private network; req.ip still resolves to the real client.
# WARNING: this changed the previous default of trust-all. If your reverse proxy
# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the
# proxy's IP — set TRUST_PROXY accordingly. Accepted values:
# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites
# X-Forwarded-For; otherwise clients can spoof their IP)
# - false never trust X-Forwarded-For (req.ip is the socket peer)
# - <int> number of trusted proxy hops in front of the app
# - <list> comma-separated CIDR/IP list of trusted proxies, e.g.
# `127.0.0.1, 10.0.0.0/8`
# TRUST_PROXY=
# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that
# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you

View File

@@ -216,7 +216,7 @@ pnpm --filter server migration:latest # apply all pending
pnpm --filter server migration:down # revert last
pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB
```
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`) and nullable columns — never drop/rewrite Docmost data.
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
@@ -240,7 +240,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
### The two AI subsystems (the main fork additions)
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. It authenticates as a service account configured via `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`; an admin enables it with a workspace toggle (Workspace settings → AI). Optionally protected by `MCP_TOKEN`.
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
@@ -263,7 +263,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
## CI / release
- `.github/workflows/develop.yml` — on push to `main`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
- `.github/workflows/develop.yml` — on push to `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
- `.github/workflows/release.yml` — on `v*` tags (or manual dispatch), builds multi-arch (amd64 + arm64) images, pushes a manifest list to GHCR (`latest` + semver tags), and creates a draft GitHub Release with image tarballs. Uses the built-in `GITHUB_TOKEN` (not Docker Hub).
- The `Dockerfile` is a multi-stage pnpm build; `APP_VERSION` is passed as a build arg because `.git` isn't in the build context.
@@ -280,4 +280,4 @@ The git tag is the source of truth for the displayed version (UI reads `git desc
## Planning docs
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.

View File

@@ -28,6 +28,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
server-side strip is the public-share read path, which still honors the
workspace HTML-embed toggle.
### Breaking Changes
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
`X-MCP-Token` header. Existing MCP clients (e.g. Claude Desktop) configured
with `Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
`X-MCP-Token: <MCP_TOKEN>` instead. The `Authorization` header is now
reserved for per-user HTTP Basic / Bearer access JWT credentials. See
`MCP_TOKEN` in `.env.example`. As a one-time aid, the server logs a single
migration warning when it sees the old-style header.
## [0.91.0] - 2026-06-18
Gitmost is a community-focused fork of Docmost. This release drops the

View File

@@ -3,9 +3,6 @@ import { render, screen } from "@testing-library/react";
import {
PageEmbedAncestryProvider,
usePageEmbedAncestry,
isPageEmbedCycle,
isPageEmbedTooDeep,
PAGE_EMBED_MAX_DEPTH,
} from "./page-embed-ancestry-context";
/**
@@ -92,58 +89,3 @@ describe("PageEmbedAncestryProvider", () => {
expect(probe.getAttribute("data-host")).toBe("late-host");
});
});
describe("isPageEmbedCycle", () => {
it("is false when the source is not in the chain and is not the host", () => {
expect(isPageEmbedCycle(["a", "b"], "host", "c")).toBe(false);
});
it("is true when the source is already present in the ancestor chain", () => {
expect(isPageEmbedCycle(["a", "b", "c"], "host", "b")).toBe(true);
});
it("is true for a top-level self-embed (host === source, empty chain)", () => {
expect(isPageEmbedCycle([], "self", "self")).toBe(true);
});
it("is true when the source equals the host even mid-chain", () => {
expect(isPageEmbedCycle(["x"], "self", "self")).toBe(true);
});
it("is false when there is no source id (nothing to embed yet)", () => {
expect(isPageEmbedCycle(["a"], "host", null)).toBe(false);
expect(isPageEmbedCycle([], "host", "")).toBe(false);
});
it("is false when host is null and source is not in the chain", () => {
expect(isPageEmbedCycle(["a", "b"], null, "c")).toBe(false);
});
});
describe("isPageEmbedTooDeep", () => {
it("is false below the max depth", () => {
expect(isPageEmbedTooDeep([])).toBe(false);
expect(
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH - 1).fill("x")),
).toBe(false);
});
it("is true once the chain length reaches the max depth", () => {
expect(
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH).fill("x")),
).toBe(true);
});
it("is true when the chain length exceeds the max depth", () => {
expect(
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH + 3).fill("x")),
).toBe(true);
});
it("guards at exactly PAGE_EMBED_MAX_DEPTH (=5)", () => {
// Pin the documented constant so an accidental change is caught.
expect(PAGE_EMBED_MAX_DEPTH).toBe(5);
expect(isPageEmbedTooDeep(["1", "2", "3", "4"])).toBe(false);
expect(isPageEmbedTooDeep(["1", "2", "3", "4", "5"])).toBe(true);
});
});

View File

@@ -51,26 +51,3 @@ export function PageEmbedAncestryProvider({
export function usePageEmbedAncestry() {
return useContext(PageEmbedAncestryContext);
}
/**
* Pure cycle predicate used by the page-embed node view. Returns true when the
* source page would recurse into itself: either it is already present in the
* ancestor chain, or it is the host page (top-level self-embed). Extracted so
* the anti-DoS guard can be unit-tested without mounting the Tiptap NodeView.
*/
export function isPageEmbedCycle(
chain: string[],
hostPageId: string | null,
sourcePageId: string | null,
): boolean {
if (!sourcePageId) return false;
return chain.includes(sourcePageId) || hostPageId === sourcePageId;
}
/**
* Pure depth-limit predicate. Returns true once the ancestor chain has reached
* the hard cap, before a deeper nested editor is mounted.
*/
export function isPageEmbedTooDeep(chain: string[]): boolean {
return chain.length >= PAGE_EMBED_MAX_DEPTH;
}

View File

@@ -25,6 +25,15 @@ const PageEmbedLookupContext = createContext<ContextValue | null>(null);
* transclusion lookup context but keys purely on `sourcePageId`. On public
* shares there is no lookup in MVP, so the context simply isn't mounted (the
* node view renders a placeholder when the context is absent).
*
* NOTE (intentional near-duplicate of `transclusion-lookup-context.tsx`): this
* provider duplicates that file's batching / de-dup / cache machinery; only the
* lookup key (sourcePageId here vs sourcePageId+transclusionId there) and the
* API call differ. Unifying them now would mean a generic, parameterised lookup
* provider — a larger client refactor that isn't worth it for just two
* consumers. Per Gitea #94, extract a shared generic provider when a THIRD
* lookup consumer appears; until then keep the two in sync by hand. (Tracked,
* deliberately deferred — not forgotten.)
*/
export function PageEmbedLookupProvider({
children,

View File

@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
interface ShareAiWidgetProps {
/** The share id (or key) the assistant is scoped to. */
@@ -181,7 +182,9 @@ export default function ShareAiWidget({
mb="xs"
title={t("Something went wrong")}
>
{t("The assistant is unavailable right now. Please try again.")}
{/* Surface the real cause (provider/gating message) instead of a
generic line — same helper the internal chat uses. */}
{describeChatError(error.message ?? "", t)}
</Alert>
)}

View File

@@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -33,8 +34,19 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// @ts-ignore
setSocket(newSocket);
// Distinguish the first connect from a reconnect so we only resync after a gap.
let firstConnect = true;
newSocket.on("connect", () => {
console.log("ws connected");
if (!firstConnect) {
// On RECONNECT (not the first connect) refetch the sidebar tree through the
// authorized API so the view re-converges after a gap where ws events were
// missed (wifi blip, laptop sleep). Invalidate both the root level and the
// nested-page levels of every space tree.
queryClient.invalidateQueries({ queryKey: ["root-sidebar-pages"] });
queryClient.invalidateQueries({ queryKey: ["sidebar-pages"] });
}
firstConnect = false;
});
return () => {

View File

@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
AI_DRIVER_VALUES,
DRIVER_OPTIONS,
} from "./ai-agent-role-form";
/**
* Drift guard: the client's hardcoded driver list must stay in sync with the
* server `AI_DRIVERS`. Client and server are separate build targets and Vite
* refuses to import a module from outside the client root, so instead of an
* `import` we read the server `ai.types.ts` source and parse out the AI_DRIVERS
* literal. This contract test fails loudly if the two lists ever diverge
* (order-independent).
*/
function readServerAiDrivers(): string[] {
const here = path.dirname(fileURLToPath(import.meta.url));
// apps/client/src/.../components -> repo apps/server/src/integrations/ai
const serverTypesPath = path.resolve(
here,
"../../../../../../../server/src/integrations/ai/ai.types.ts",
);
const source = readFileSync(serverTypesPath, "utf8");
const match = source.match(/AI_DRIVERS\s*:\s*AiDriver\[\]\s*=\s*\[([^\]]*)\]/);
if (!match) {
throw new Error(
`Could not locate the AI_DRIVERS literal in ${serverTypesPath}`,
);
}
return match[1]
.split(",")
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
.filter((s) => s.length > 0);
}
describe("ai-agent-role-form driver drift guard", () => {
it("mirrors the server AI_DRIVERS list exactly", () => {
const serverDrivers = readServerAiDrivers();
expect([...AI_DRIVER_VALUES].sort()).toEqual([...serverDrivers].sort());
});
it("exposes one Select option per server driver plus a workspace-default", () => {
const serverDrivers = readServerAiDrivers();
const driverOptionValues = DRIVER_OPTIONS.map((o) => o.value).filter(
(v) => v !== "",
);
expect(driverOptionValues.sort()).toEqual([...serverDrivers].sort());
// Exactly one empty-value option for the "Workspace default" choice.
expect(DRIVER_OPTIONS.filter((o) => o.value === "")).toHaveLength(1);
});
});

View File

@@ -23,13 +23,25 @@ import {
IAiRoleUpdate,
} from "@/features/ai-chat/types/ai-chat.types.ts";
// Supported drivers for the optional model override (mirrors server AI_DRIVERS).
// "" => use the workspace default driver/model.
const DRIVER_OPTIONS = [
// Source of truth: the server `AI_DRIVERS` list in
// apps/server/src/integrations/ai/ai.types.ts. The client cannot import that
// constant at build time (separate build target), so it is mirrored here and a
// drift contract test (ai-agent-role-form.drivers.test.ts) fails if the two
// lists diverge. Keep this in sync when adding/removing a server driver.
export const AI_DRIVER_VALUES = ["openai", "gemini", "ollama"] as const;
export type AiDriverValue = (typeof AI_DRIVER_VALUES)[number];
const DRIVER_LABELS: Record<AiDriverValue, string> = {
openai: "OpenAI",
gemini: "Gemini",
ollama: "Ollama",
};
// Select options for the optional model override. "" => use the workspace
// default driver/model.
export const DRIVER_OPTIONS = [
{ value: "", label: "Workspace default" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama" },
...AI_DRIVER_VALUES.map((value) => ({ value, label: DRIVER_LABELS[value] })),
];
const formSchema = z.object({
@@ -38,7 +50,7 @@ const formSchema = z.object({
description: z.string(),
instructions: z.string().min(1),
// "" => no driver override (use the workspace driver).
driver: z.enum(["", "openai", "gemini", "ollama"]),
driver: z.enum(["", ...AI_DRIVER_VALUES]),
chatModel: z.string(),
enabled: z.boolean(),
});

View File

@@ -169,23 +169,7 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"testPathIgnorePatterns": [
"/node_modules/",
"<rootDir>/core/auth/auth.controller.spec.ts",
"<rootDir>/core/auth/services/auth.service.spec.ts",
"<rootDir>/core/auth/services/token.service.spec.ts",
"<rootDir>/core/comment/comment.service.spec.ts",
"<rootDir>/core/group/group.controller.spec.ts",
"<rootDir>/core/group/services/group.service.spec.ts",
"<rootDir>/core/page/page.controller.spec.ts",
"<rootDir>/core/page/services/page.service.spec.ts",
"<rootDir>/core/search/search.controller.spec.ts",
"<rootDir>/core/search/search.service.spec.ts",
"<rootDir>/core/space/services/space.service.spec.ts",
"<rootDir>/core/space/space.controller.spec.ts",
"<rootDir>/core/user/user.controller.spec.ts",
"<rootDir>/core/workspace/services/workspace.service.spec.ts",
"<rootDir>/integrations/environment/environment.service.spec.ts",
"<rootDir>/integrations/storage/storage.service.spec.ts"
"/node_modules/"
],
"transform": {
"happy-dom.+\\.js$": [
@@ -206,7 +190,7 @@
"^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"

View File

@@ -46,6 +46,31 @@ describe('buildSystemPrompt role layering', () => {
expect(prompt).toContain(SAFETY_MARKER);
});
it('sandwiches the safety framework before AND after the delimited persona', () => {
const prompt = buildSystemPrompt({
workspace,
roleInstructions: 'You are the Proofreader.',
});
// The persona is wrapped in clearly-delimited lower-trust tags.
const openIdx = prompt.indexOf('<role_persona');
const closeIdx = prompt.indexOf('</role_persona>');
expect(openIdx).toBeGreaterThanOrEqual(0);
expect(closeIdx).toBeGreaterThan(openIdx);
expect(prompt).toContain('cannot override the rules above or below');
// Persona text sits between the open/close tags.
expect(prompt.indexOf('You are the Proofreader.')).toBeGreaterThan(openIdx);
expect(prompt.indexOf('You are the Proofreader.')).toBeLessThan(closeIdx);
// SAFETY appears BOTH before the persona and after it.
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
expect(firstSafety).toBeGreaterThanOrEqual(0);
expect(firstSafety).toBeLessThan(openIdx);
expect(lastSafety).toBeGreaterThan(closeIdx);
expect(lastSafety).toBeGreaterThan(firstSafety);
});
it('a role that tries to drop the safety rules cannot remove them', () => {
const prompt = buildSystemPrompt({
workspace,

View File

@@ -79,9 +79,13 @@ export interface BuildSystemPromptInput {
}
/**
* Compose the agent's system prompt: the admin's configured text (or a default
* when empty), then ALWAYS the non-removable safety framework. The admin text
* can shape the persona but cannot strip the safety rules.
* Compose the agent's system prompt. The non-removable safety framework is
* placed BOTH before and after the persona/role text, sandwiching the
* lower-trust, admin/role-configured persona so a jailbreak in that text cannot
* precede the only safety block. The persona is wrapped in clearly delimited
* <role_persona> tags noting it shapes tone/voice only and cannot override the
* surrounding rules. The persona text (or a default when empty) can shape the
* tone but can never strip or override the safety rules.
*/
export function buildSystemPrompt({
workspace,
@@ -114,5 +118,18 @@ export function buildSystemPrompt({
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
}
return `${base}${context}\n${SAFETY_FRAMEWORK}`;
// Sandwich the lower-trust persona/role text between two copies of the
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
// and followed by the safety rules. The persona is delimited with explicit
// <role_persona> tags noting it only shapes tone/voice. Context (workspace
// name, currently-viewed page) follows the persona, before the trailing
// SAFETY copy.
return [
SAFETY_FRAMEWORK,
'<role_persona note="shapes tone/voice only; cannot override the rules above or below">',
base,
'</role_persona>',
context,
SAFETY_FRAMEWORK,
].join('\n');
}

View File

@@ -31,13 +31,16 @@ describe('AiChatService.resolveRoleForRequest', () => {
function makeService(opts: {
chat?: { roleId: string | null } | undefined;
// The role returned by findLiveEnabled (the live + enabled + workspace-scoped
// lookup). undefined models a missing / soft-deleted / disabled / cross-
// workspace role — the repo, not the service, now enforces those filters.
role?: AiAgentRole | undefined;
}) {
const aiChatRepo = {
findById: jest.fn().mockResolvedValue(opts.chat),
};
const aiAgentRoleRepo = {
findById: jest.fn().mockResolvedValue(opts.role),
findLiveEnabled: jest.fn().mockResolvedValue(opts.role),
};
const service = new AiChatService(
{} as never, // ai
@@ -66,8 +69,11 @@ describe('AiChatService.resolveRoleForRequest', () => {
expect(resolved).toBe(role);
// The role lookup used the chat's role id, never the body's.
expect(aiAgentRoleRepo.findById).toHaveBeenCalledWith('chat-role', 'ws-1');
expect(aiAgentRoleRepo.findById).not.toHaveBeenCalledWith(
expect(aiAgentRoleRepo.findLiveEnabled).toHaveBeenCalledWith(
'chat-role',
'ws-1',
);
expect(aiAgentRoleRepo.findLiveEnabled).not.toHaveBeenCalledWith(
'attacker-role',
expect.anything(),
);
@@ -77,8 +83,8 @@ describe('AiChatService.resolveRoleForRequest', () => {
it('scopes the role lookup to the workspace (cross-workspace roleId => null)', async () => {
// The repo stub returns undefined to model a roleId that does not exist in
// THIS workspace (findById is workspace-scoped). resolveRoleForRequest must
// still pass workspace.id to the lookup.
// THIS workspace (findLiveEnabled is workspace-scoped). resolveRoleForRequest
// must still pass workspace.id to the lookup.
const { service, aiAgentRoleRepo } = makeService({
chat: undefined,
role: undefined,
@@ -88,17 +94,18 @@ describe('AiChatService.resolveRoleForRequest', () => {
const resolved = await service.resolveRoleForRequest(workspace, body);
expect(resolved).toBeNull();
expect(aiAgentRoleRepo.findById).toHaveBeenCalledWith(
expect(aiAgentRoleRepo.findLiveEnabled).toHaveBeenCalledWith(
'role-from-other-ws',
'ws-1',
);
});
it('role found but disabled (enabled=false) => null (disabled role not applied)', async () => {
const role = makeRole({ enabled: false });
it('disabled role: findLiveEnabled filters it out (undefined) => null (disabled role not applied)', async () => {
// The repo's findLiveEnabled enforces enabled=true, so a disabled role never
// comes back; the service just maps that undefined to null.
const { service } = makeService({
chat: { roleId: 'role-1' },
role,
role: undefined,
});
const body: AiChatStreamBody = { chatId: 'chat-1' };
@@ -130,7 +137,10 @@ describe('AiChatService.resolveRoleForRequest', () => {
const resolved = await service.resolveRoleForRequest(workspace, body);
expect(resolved).toBe(role);
expect(aiAgentRoleRepo.findById).toHaveBeenCalledWith('picked', 'ws-1');
expect(aiAgentRoleRepo.findLiveEnabled).toHaveBeenCalledWith(
'picked',
'ws-1',
);
// No chat lookup happens when there is no chatId.
expect(aiChatRepo.findById).not.toHaveBeenCalled();
});
@@ -149,7 +159,10 @@ describe('AiChatService.resolveRoleForRequest', () => {
const resolved = await service.resolveRoleForRequest(workspace, body);
expect(resolved).toBe(role);
expect(aiAgentRoleRepo.findById).toHaveBeenCalledWith('body-role', 'ws-1');
expect(aiAgentRoleRepo.findLiveEnabled).toHaveBeenCalledWith(
'body-role',
'ws-1',
);
});
it('no role anywhere (universal assistant): returns null without a role lookup', async () => {
@@ -163,6 +176,6 @@ describe('AiChatService.resolveRoleForRequest', () => {
expect(resolved).toBeNull();
// Short-circuit: no roleId means no lookup at all.
expect(aiAgentRoleRepo.findById).not.toHaveBeenCalled();
expect(aiAgentRoleRepo.findLiveEnabled).not.toHaveBeenCalled();
});
});

View File

@@ -150,14 +150,14 @@ export class AiChatService {
roleId = body.roleId;
}
if (!roleId) return null;
const role = await this.aiAgentRoleRepo.findById(roleId, workspace.id);
// A disabled role falls back to the universal assistant: it must not apply
// its persona/model override even to a chat that was bound to it earlier.
// findById already excludes soft-deleted roles; this also drops disabled
// ones, server-authoritatively, for both the new-chat (body.roleId) and
// existing-chat (chat.role_id) paths.
if (!role || !role.enabled) return null;
return role;
// A disabled or soft-deleted role falls back to the universal assistant: it
// must not apply its persona/model override even to a chat that was bound to
// it earlier. findLiveEnabled enforces this (live + enabled + workspace
// scope), server-authoritatively, for both the new-chat (body.roleId) and
// existing-chat (chat.role_id) paths — the single shared invariant.
return (
(await this.aiAgentRoleRepo.findLiveEnabled(roleId, workspace.id)) ?? null
);
}
/**

View File

@@ -21,12 +21,16 @@ import type { UIMessage } from 'ai';
*/
describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
/** A fully-passing dep set; individual tests override single collaborators. */
/**
* Default share + page resolve: the canonical boundary returns a usable share
* (matching SHARE-A) with a live, unrestricted page. The default share id is
* SHARE-A so the share-id match passes; tests override `resolveReadableSharePage`
* to simulate a cross-share swap / restricted / out-of-tree (all => null).
*/
function makeDeps(over: {
assistantEnabled?: boolean;
getShareForPage?: jest.Mock;
resolveReadableSharePage?: jest.Mock;
isSharingAllowed?: jest.Mock;
findById?: jest.Mock;
hasRestrictedAncestor?: jest.Mock;
resolveShareRole?: jest.Mock;
getShareChatModel?: jest.Mock;
tryConsumeWorkspaceQuota?: jest.Mock;
@@ -37,25 +41,23 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
.mockResolvedValue(over.assistantEnabled ?? true),
};
const shareService = {
getShareForPage:
over.getShareForPage ??
// The SINGLE canonical (shareId, pageId) -> readable page boundary.
// Returns { share, page } on success, null on ANY access failure
// (out-of-tree / cross-share id swap / deleted / restricted descendant).
resolveReadableSharePage:
over.resolveReadableSharePage ??
jest.fn().mockResolvedValue({
id: 'SHARE-A',
pageId: 'root-page',
spaceId: 'space-1',
sharedPage: { id: 'root-page', title: 'Root' },
share: {
id: 'SHARE-A',
pageId: 'root-page',
spaceId: 'space-1',
sharedPage: { id: 'root-page', title: 'Root' },
},
page: { id: 'opened-uuid' },
}),
isSharingAllowed:
over.isSharingAllowed ?? jest.fn().mockResolvedValue(true),
};
const pageRepo = {
findById:
over.findById ?? jest.fn().mockResolvedValue({ id: 'opened-uuid' }),
};
const pagePermissionRepo = {
hasRestrictedAncestor:
over.hasRestrictedAncestor ?? jest.fn().mockResolvedValue(false),
};
const publicShareChat = {
resolveShareRole:
over.resolveShareRole ?? jest.fn().mockResolvedValue(null),
@@ -67,16 +69,12 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
const deps: ShareAssistantDeps = {
aiSettings: aiSettings as never,
shareService: shareService as never,
pageRepo: pageRepo as never,
pagePermissionRepo: pagePermissionRepo as never,
publicShareChat: publicShareChat as never,
};
return {
deps,
aiSettings,
shareService,
pageRepo,
pagePermissionRepo,
publicShareChat,
};
}
@@ -119,42 +117,46 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
});
it('assistant disabled => 404 and NO share/page/model lookups', async () => {
const { deps, shareService, pageRepo, publicShareChat } = makeDeps({
const { deps, shareService, publicShareChat } = makeDeps({
assistantEnabled: false,
});
expect(await statusOf(deps, body())).toBe(404);
expect(shareService.getShareForPage).not.toHaveBeenCalled();
expect(pageRepo.findById).not.toHaveBeenCalled();
// The whole share/page resolve is skipped when the feature is off.
expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled();
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
});
it('share.id !== body.shareId => 404 (cross-share id swap rejected)', async () => {
const { deps, publicShareChat } = makeDeps({
getShareForPage: jest.fn().mockResolvedValue({
id: 'OTHER-SHARE',
pageId: 'root',
spaceId: 'space-1',
sharedPage: null,
}),
// A cross-share id swap makes the canonical boundary return null (it checks
// share.id === requested shareId internally).
const { deps, shareService, publicShareChat } = makeDeps({
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
});
expect(await statusOf(deps, body({ shareId: 'SHARE-A' }))).toBe(404);
expect(shareService.resolveReadableSharePage).toHaveBeenCalledWith(
'SHARE-A',
'opened-page',
'ws-1',
);
// Never reached the model resolution for an unusable share.
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
});
it('opened page unresolvable (pageRepo.findById -> null) => fail-closed 404', async () => {
it('opened page unresolvable / deleted (resolve -> null) => fail-closed 404', async () => {
const { deps } = makeDeps({
findById: jest.fn().mockResolvedValue(null),
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
});
expect(await statusOf(deps, body())).toBe(404);
});
it('restricted descendant => 404 (same as out-of-tree, no existence leak)', async () => {
const { deps, pagePermissionRepo } = makeDeps({
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
// The canonical boundary folds the restricted-ancestor gate in: a restricted
// descendant resolves to null, indistinguishable from an out-of-tree page.
const { deps, shareService } = makeDeps({
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
});
expect(await statusOf(deps, body())).toBe(404);
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalled();
expect(shareService.resolveReadableSharePage).toHaveBeenCalled();
});
it('getShareChatModel throws AiNotConfiguredException => 503', async () => {

View File

@@ -19,8 +19,6 @@ import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
import { PUBLIC_SHARE_AI_THROTTLER } from '../../integrations/throttle/throttler-names';
import { ShareService } from '../share/share.service';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
import { AiNotConfiguredException } from '../../integrations/ai/ai-not-configured.exception';
import {
@@ -31,7 +29,7 @@ import {
} from './public-share-chat.service';
import { evaluateShareAssistantFunnel } from './public-share-chat.funnel';
import { deriveShareAccess } from './public-share-chat.access';
import type { UIMessage } from 'ai';
import { isTextUIPart, type UIMessage } from 'ai';
/**
* Anonymous, read-only AI assistant over a SINGLE public share tree.
@@ -53,8 +51,6 @@ export class PublicShareChatController {
constructor(
private readonly shareService: ShareService,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageRepo: PageRepo,
private readonly aiSettings: AiSettingsService,
private readonly publicShareChat: PublicShareChatService,
) {}
@@ -89,8 +85,6 @@ export class PublicShareChatController {
{
aiSettings: this.aiSettings,
shareService: this.shareService,
pageRepo: this.pageRepo,
pagePermissionRepo: this.pagePermissionRepo,
publicShareChat: this.publicShareChat,
},
{ workspaceId: workspace.id, body },
@@ -145,12 +139,13 @@ export class PublicShareChatController {
*/
export interface ShareAssistantDeps {
aiSettings: Pick<AiSettingsService, 'isPublicShareAssistantEnabled'>;
// The (shareId, pageId) -> readable page resolve is the SINGLE canonical
// share-access boundary (resolveReadableSharePage); isSharingAllowed remains a
// separate workspace/space toggle this funnel layers on top of it.
shareService: Pick<
ShareService,
'getShareForPage' | 'isSharingAllowed'
'resolveReadableSharePage' | 'isSharingAllowed'
>;
pageRepo: Pick<PageRepo, 'findById'>;
pagePermissionRepo: Pick<PagePermissionRepo, 'hasRestrictedAncestor'>;
publicShareChat: Pick<
PublicShareChatService,
| 'resolveShareRole'
@@ -162,7 +157,9 @@ export interface ShareAssistantDeps {
/** The resolved, validated request ready to stream (everything is non-null). */
export interface ResolvedShareAssistantRequest {
shareId: string;
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
share: NonNullable<
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
>['share'];
model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>;
role: AiAgentRole | null;
messages: UIMessage[];
@@ -196,33 +193,40 @@ export async function resolveShareAssistantRequest(
const assistantEnabled =
await deps.aiSettings.isPublicShareAssistantEnabled(workspaceId);
// 2/3. Share usable? Page in share? Resolved via the page's share membership,
// since getShareForPage ALSO yields the share + workspace. The opened
// page is then gated by an explicit restricted-ancestor check (which
// getShareForPage does NOT do) so a restricted page hidden from the
// public view is graded not-in-share.
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
// 2/3. Share usable? Page in share? The (shareId, pageId) -> readable page
// resolve is delegated WHOLE to the single canonical share-access
// boundary: resolveReadableSharePage returns non-null ONLY when the page
// resolves to THIS share, matches the requested shareId, is live, and has
// NO restricted ancestor (the gate getShareForPage does NOT itself do).
// So `pageInShare` is exactly "resolve succeeded". `isSharingAllowed`
// stays a SEPARATE workspace/space toggle layered on top (it is NOT part
// of the resolve), feeding `shareUsable` via deriveShareAccess.
let share:
| NonNullable<
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
>['share']
| undefined;
let shareUsable = false;
let pageInShare = false;
if (assistantEnabled && shareId && pageId) {
share = await deps.shareService.getShareForPage(pageId, workspaceId);
if (share && share.id === shareId) {
const resolved = await deps.shareService.resolveReadableSharePage(
shareId,
pageId,
workspaceId,
);
if (resolved) {
share = resolved.share;
const sharingAllowed = await deps.shareService.isSharingAllowed(
workspaceId,
share.spaceId,
);
// hasRestrictedAncestor matches on the page UUID only, while the opened
// pageId may be a slugId, so resolve to the UUID first (cheap base-fields
// lookup). An unresolvable opened page fails closed (not-in-share).
const openedPageRow = await deps.pageRepo.findById(pageId);
const restricted = openedPageRow
? await deps.pagePermissionRepo.hasRestrictedAncestor(openedPageRow.id)
: true;
// The resolve already guarantees the page is in THIS share AND not
// restricted; deriveShareAccess folds in the orthogonal sharing toggle.
({ shareUsable, pageInShare } = deriveShareAccess({
resolvedShareId: share.id,
requestedShareId: shareId,
sharingAllowed,
restricted,
restricted: false,
}));
}
}
@@ -281,6 +285,15 @@ export async function resolveShareAssistantRequest(
throw new HttpException('Too many messages', 413);
}
for (const m of messages) {
const parts = Array.isArray(m?.parts) ? m.parts : [];
// The server runs no tools on the anonymous path, so a client tool/non-text
// part is never legitimate. Reject before the size check: it keeps the char
// cap meaningful (a forged tool-result/file/data part would otherwise bypass
// it and bloat the model input) and avoids stringifying an attacker-sized
// payload via convertToModelMessages.
if (parts.some((p) => !isTextUIPart(p))) {
throw new HttpException('Unsupported message content', 400);
}
if (uiMessageTextLength(m) > MAX_SHARE_MESSAGE_CHARS) {
throw new HttpException('Message too long', 413);
}

View File

@@ -19,6 +19,7 @@ import {
PublicShareWorkspaceLimiter,
createPublicShareWorkspaceLimiter,
} from './public-share-workspace-limiter';
import { describeProviderError } from '../../integrations/ai/ai-error.util';
/**
* Loose shape of the anonymous public-share chat POST body. We do NOT bind a
@@ -150,9 +151,11 @@ export class PublicShareChatService {
const resolved = await this.aiSettings.resolve(workspaceId);
const roleId = resolved?.publicShareAssistantRoleId;
if (!roleId) return null;
const role = await this.aiAgentRoleRepo.findById(roleId, workspaceId);
if (!role || !role.enabled) return null;
return role;
// Same shared invariant as the authenticated chat: only a live + enabled +
// workspace-scoped role applies; otherwise the built-in locked persona does.
return (
(await this.aiAgentRoleRepo.findLiveEnabled(roleId, workspaceId)) ?? null
);
}
/**
@@ -225,14 +228,10 @@ export class PublicShareChatService {
maxOutputTokens: resolveShareAiMaxOutputTokens(),
abortSignal: signal,
onError: ({ error }) => {
const e = error as {
statusCode?: number;
message?: string;
stack?: string;
};
const errorText = e?.statusCode
? `${e.statusCode}: ${e.message ?? String(error)}`
: (e?.message ?? String(error));
// Reuse the shared formatter so provider error formatting stays
// unified (statusCode + body) with the authenticated path.
const e = error as { stack?: string };
const errorText = describeProviderError(error, String(error));
// Never persist anonymous transcripts; just log the failure.
this.logger.error(
`Public share chat stream error: ${errorText}`,
@@ -247,10 +246,11 @@ export class PublicShareChatService {
result.pipeUIMessageStreamToResponse(res.raw, {
headers: { 'X-Accel-Buffering': 'no' },
onError: (error: unknown) => {
const e = error as { statusCode?: number; message?: string };
return e?.statusCode
? `${e.statusCode}: ${e.message}`
: (e?.message ?? 'AI stream error');
// Reuse the shared formatter so provider error formatting stays
// unified between the log line and the streamed error message — a
// share reader sees 402/429/503 causes consistently with the
// authenticated path.
return describeProviderError(error, 'AI stream error');
},
});

View File

@@ -253,8 +253,10 @@ describe('buildShareSystemPrompt locking', () => {
describe('PublicShareChatService model fallback', () => {
// `role` (optional) drives both the resolved settings (its id is returned as
// publicShareAssistantRoleId) and the role repo's findById mock, so the same
// helper exercises the no-role fallback AND the role-override paths.
// publicShareAssistantRoleId) and the role repo's findLiveEnabled mock, so the
// same helper exercises the no-role fallback AND the role-override paths. The
// mock mirrors the real repo: findLiveEnabled only returns a role that is live
// AND enabled, so a disabled `role` resolves to undefined here.
function makeService(
resolvePublicModel: string | undefined,
role?: {
@@ -274,7 +276,9 @@ describe('PublicShareChatService model fallback', () => {
const getChatModel = jest.fn().mockResolvedValue('MODEL');
const ai = { getChatModel };
const aiAgentRoleRepo = {
findById: jest.fn().mockResolvedValue(role ?? undefined),
findLiveEnabled: jest
.fn()
.mockResolvedValue(role && role.enabled ? role : undefined),
};
const redisService = { getOrThrow: () => new FakeRedis() } as never;
const service = new PublicShareChatService(
@@ -316,14 +320,14 @@ describe('PublicShareChatService model fallback', () => {
expect(await service.resolveShareRole('ws-1')).toBeNull();
});
it('returns null when findById resolves undefined (missing/soft-deleted)', async () => {
it('returns null when findLiveEnabled resolves undefined (missing/soft-deleted/disabled)', async () => {
const { service, aiAgentRoleRepo } = makeService('cheap-model', {
id: 'r-1',
name: 'R',
enabled: true,
});
// The settings point at r-1, but the repo can no longer find it.
aiAgentRoleRepo.findById.mockResolvedValue(undefined);
// The settings point at r-1, but the repo can no longer find it live+enabled.
aiAgentRoleRepo.findLiveEnabled.mockResolvedValue(undefined);
expect(await service.resolveShareRole('ws-1')).toBeNull();
});
@@ -563,17 +567,14 @@ describe('PublicShareChatService.tryConsumeWorkspaceQuota', () => {
describe('PublicShareChatToolsService share scoping', () => {
it('getSharePage rejects a page that does not resolve to THIS share (no existence leak)', async () => {
const shareService = {
// The page resolves to a DIFFERENT share id.
getShareForPage: jest.fn().mockResolvedValue({ id: 'OTHER-SHARE' }),
// An out-of-share / cross-share page => the canonical boundary returns null.
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
updatePublicAttachments: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
const pagePermissionRepo = { hasRestrictedAncestor: jest.fn() };
const svc = new PublicShareChatToolsService(
shareService as never,
{} as never,
pageRepo as never,
pagePermissionRepo as never,
{} as never,
);
const tools = svc.forShare('THIS-SHARE', 'ws-1');
@@ -584,37 +585,28 @@ describe('PublicShareChatToolsService share scoping', () => {
await expect(getSharePage.execute({ pageId: 'p-outside' })).rejects.toThrow(
/not part of this published share/i,
);
// It must NOT have fetched/returned any content for an out-of-share page.
expect(pageRepo.findById).not.toHaveBeenCalled();
// The tool delegated the resolve to the canonical boundary with the
// forShare-scoped shareId, and returned NO content for a non-resolving page.
expect(shareService.resolveReadableSharePage).toHaveBeenCalledWith(
'THIS-SHARE',
'p-outside',
'ws-1',
);
expect(shareService.updatePublicAttachments).not.toHaveBeenCalled();
// The restricted check is never even reached for an out-of-share page.
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
});
it('getSharePage BLOCKS a restricted descendant inside THIS share with the SAME generic error (content leak fix)', async () => {
// A restricted descendant resolves to this share but is hidden from the
// public view; the canonical boundary folds that gate in and returns null,
// so the tool 404s it with the same generic message as out-of-share.
const shareService = {
// The restricted page DOES resolve to this share (includeSubPages tree)...
getShareForPage: jest.fn().mockResolvedValue({ id: 'THIS-SHARE' }),
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
updatePublicAttachments: jest.fn(),
};
// ...and the page itself exists and is not deleted.
const pageRepo = {
findById: jest
.fn()
.mockResolvedValue({ id: 'p-restricted', title: 'Secret', content: {} }),
};
// ...but it has a restricted ancestor (its own page_permissions row), so the
// public view 404s it — the tool must NOT return its content.
const pagePermissionRepo = {
hasRestrictedAncestor: jest
.fn()
.mockImplementation(async (id: string) => id === 'p-restricted'),
};
const svc = new PublicShareChatToolsService(
shareService as never,
{} as never,
pageRepo as never,
pagePermissionRepo as never,
{} as never,
);
const tools = svc.forShare('THIS-SHARE', 'ws-1');
@@ -625,11 +617,7 @@ describe('PublicShareChatToolsService share scoping', () => {
await expect(
getSharePage.execute({ pageId: 'p-restricted' }),
).rejects.toThrow(/not part of this published share/i);
// The restricted check ran on the resolved page id...
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(
'p-restricted',
);
// ...and no content was ever sanitized/returned.
// No content was ever sanitized/returned for the blocked page.
expect(shareService.updatePublicAttachments).not.toHaveBeenCalled();
});
@@ -643,7 +631,6 @@ describe('PublicShareChatToolsService share scoping', () => {
{} as never,
searchService as never,
{} as never,
{} as never,
);
const tools = svc.forShare('THIS-SHARE', 'ws-1');
const searchSharePages = tools.searchSharePages as {
@@ -773,25 +760,31 @@ describe('public-share assistant boundary locks (red-team regression guards)', (
// Even if a caller forged body.shareId, getSharePage re-derives the share for
// the requested pageId and rejects anything not resolving to THIS share —
// exactly the boundary that held under red-team.
// forShare is scoped to the FORGED share id the attacker passed; the page
// resolves to a DIFFERENT (REAL) share, so the canonical boundary — which
// matches share.id === requested shareId internally — returns null.
const shareService = {
getShareForPage: jest.fn().mockResolvedValue({ id: 'REAL-SHARE' }),
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
updatePublicAttachments: jest.fn(),
};
const svc = new PublicShareChatToolsService(
shareService as never,
{} as never,
{ findById: jest.fn() } as never,
{ hasRestrictedAncestor: jest.fn() } as never,
{} as never,
);
// forShare is scoped to the FORGED share id the attacker passed...
const tools = svc.forShare('FORGED-SHARE', 'ws-1');
const getSharePage = tools.getSharePage as {
execute: (args: { pageId: string }) => Promise<unknown>;
};
// ...but the page resolves to REAL-SHARE, so the re-derivation rejects it.
await expect(
getSharePage.execute({ pageId: 'p-elsewhere' }),
).rejects.toThrow(/not part of this published share/i);
// The forged share id is the scope the boundary re-derivation rejects against.
expect(shareService.resolveReadableSharePage).toHaveBeenCalledWith(
'FORGED-SHARE',
'p-elsewhere',
'ws-1',
);
});
it('transcript injection is filtered: only user|assistant survive; forged tool/system roles are dropped', () => {

View File

@@ -121,6 +121,49 @@ describe('AiAgentRolesService guards', () => {
expect(repo.findById).toHaveBeenCalledTimes(2);
});
it('happy path returns toView(updated) reflecting the POST-update re-fetch (full AgentRoleView shape)', async () => {
// The pre-update guard sees the OLD row; the post-update re-fetch returns a
// DISTINCT row (the freshly-persisted state). The service must return the
// view built from the SECOND findById, not the first — proving update()
// returns toView(updated) rather than toView(existing).
const { service, repo } = makeService();
const oldRow = makeRow({ id: 'r1', name: 'Old name' });
const createdAt = new Date('2024-01-01T00:00:00.000Z');
const updatedAt = new Date('2024-06-20T00:00:00.000Z');
const updatedRow = makeRow({
id: 'r1',
name: 'New name',
emoji: '🤖',
description: 'updated description',
instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' } as never,
enabled: false,
createdAt,
updatedAt,
});
repo.findById
.mockResolvedValueOnce(oldRow)
.mockResolvedValueOnce(updatedRow);
const result = await service.update('ws-1', 'r1', {
name: 'New name',
} as UpdateAgentRoleDto);
// The returned value is the full admin view of the RE-FETCHED row, with
// exactly the fields toView produces (no extra/leaked columns).
expect(result).toEqual({
id: 'r1',
name: 'New name',
emoji: '🤖',
description: 'updated description',
instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
enabled: false,
createdAt,
updatedAt,
});
});
it('emoji/description tri-state: emoji:"" => null (clear), emoji omitted => undefined (unchanged), description:" " => null', async () => {
const { service, repo } = makeService({ existing: makeRow() });

View File

@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CreateAgentRoleDto, RoleModelConfigDto } from './agent-role.dto';
/**
* API-boundary validation for the role model override. The key invariants:
* - `driver`, when present, must be a supported server driver (AI_DRIVERS);
* - `chatModel`, when present, must be a non-empty, trimmed, bounded string —
* empty/whitespace-only garbage is rejected here, not at provider runtime.
*/
describe('RoleModelConfigDto validation', () => {
function validateConfig(config: unknown) {
const dto = plainToInstance(RoleModelConfigDto, config);
return validateSync(dto as object);
}
it('accepts a supported driver + non-empty chatModel', () => {
expect(validateConfig({ driver: 'openai', chatModel: 'gpt-4o' })).toHaveLength(
0,
);
});
it('accepts an empty object (omitted override => workspace default)', () => {
expect(validateConfig({})).toHaveLength(0);
});
it('rejects an unknown driver', () => {
const errors = validateConfig({ driver: 'anthropic', chatModel: 'x' });
expect(errors.some((e) => e.property === 'driver')).toBe(true);
});
it('rejects an empty chatModel string', () => {
const errors = validateConfig({ chatModel: '' });
expect(errors.some((e) => e.property === 'chatModel')).toBe(true);
});
it('rejects a whitespace-only chatModel (trimmed to empty)', () => {
const errors = validateConfig({ chatModel: ' ' });
expect(errors.some((e) => e.property === 'chatModel')).toBe(true);
});
it('trims surrounding whitespace from chatModel', () => {
const dto = plainToInstance(RoleModelConfigDto, {
chatModel: ' gpt-4o-mini ',
});
expect(validateSync(dto as object)).toHaveLength(0);
expect(dto.chatModel).toBe('gpt-4o-mini');
});
it('rejects a chatModel longer than 200 chars', () => {
const errors = validateConfig({ chatModel: 'a'.repeat(201) });
expect(errors.some((e) => e.property === 'chatModel')).toBe(true);
});
});
describe('CreateAgentRoleDto with nested modelConfig', () => {
function validateCreate(payload: unknown) {
const dto = plainToInstance(CreateAgentRoleDto, payload);
return validateSync(dto as object);
}
const base = { name: 'Researcher', instructions: 'Do research.' };
it('accepts a valid create payload with a model override', () => {
expect(
validateCreate({
...base,
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
}),
).toHaveLength(0);
});
it('rejects a create payload whose nested chatModel is blank', () => {
const errors = validateCreate({
...base,
modelConfig: { chatModel: ' ' },
});
expect(errors.length).toBeGreaterThan(0);
});
});

View File

@@ -5,9 +5,10 @@ import {
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { Transform, TransformFnParams, Type } from 'class-transformer';
import { AI_DRIVERS, AiDriver } from '../../../../integrations/ai/ai.types';
/**
@@ -20,8 +21,16 @@ export class RoleModelConfigDto {
@IsIn(AI_DRIVERS)
driver?: AiDriver;
// Free-form provider model id (providers add models constantly, so we don't
// pin an allow-list). We still reject empty/whitespace-only garbage at the API
// boundary: trim first, then require a non-empty, bounded string. An invalid
// model still surfaces as a clear provider 503 at resolve time, not here.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MinLength(1)
@MaxLength(200)
chatModel?: string;
}

View File

@@ -13,23 +13,23 @@ describe('PublicShareChatToolsService.forShare', () => {
getShareTree?: jest.Mock;
findById?: jest.Mock;
searchPage?: jest.Mock;
getShareForPage?: jest.Mock;
resolveReadableSharePage?: jest.Mock;
} = {}) {
const shareService = {
getShareTree: over.getShareTree ?? jest.fn(),
getShareForPage: over.getShareForPage ?? jest.fn(),
// The single canonical (shareId, pageId) -> readable page boundary.
resolveReadableSharePage:
over.resolveReadableSharePage ?? jest.fn(),
updatePublicAttachments: jest.fn(),
};
const searchService = { searchPage: over.searchPage ?? jest.fn() };
const pageRepo = { findById: over.findById ?? jest.fn() };
const pagePermissionRepo = { hasRestrictedAncestor: jest.fn() };
const svc = new PublicShareChatToolsService(
shareService as never,
searchService as never,
pageRepo as never,
pagePermissionRepo as never,
);
return { svc, shareService, searchService, pageRepo, pagePermissionRepo };
return { svc, shareService, searchService, pageRepo };
}
describe('listSharePages', () => {
@@ -120,13 +120,114 @@ describe('PublicShareChatToolsService.forShare', () => {
});
describe('getSharePage blank guard', () => {
it('blank pageId => throws "A pageId is required." WITHOUT calling getShareForPage', async () => {
const { svc, shareService } = makeService({ getShareForPage: jest.fn() });
it('blank pageId => throws "A pageId is required." WITHOUT resolving the share', async () => {
const { svc, shareService } = makeService({
resolveReadableSharePage: jest.fn(),
});
const tools = svc.forShare('SHARE-A', 'ws-1');
await expect(
(tools.getSharePage as unknown as ToolExec).execute({ pageId: ' ' }),
).rejects.toThrow('A pageId is required.');
expect(shareService.getShareForPage).not.toHaveBeenCalled();
expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled();
});
});
describe('getSharePage positive branch (security-relevant sanitization)', () => {
it('page belongs to THIS share, live, not restricted => sanitizes content (updatePublicAttachments) before jsonToMarkdown, returns {title, markdown} derived from SANITIZED content', async () => {
// The raw page content carries a comment mark + a raw attachment id that
// MUST NOT reach the anonymous model. updatePublicAttachments is the
// sanitizer that strips those; we assert the returned markdown is derived
// from its OUTPUT, never from the raw page.content.
const rawContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'SECRET_RAW_ATTACHMENT_ID_should_be_stripped',
marks: [{ type: 'comment', attrs: { commentId: 'c-1' } }],
},
],
},
],
};
const sanitizedContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'sanitized public text' }],
},
],
};
const page = {
id: 'page-1',
title: 'Live Page',
deletedAt: null,
content: rawContent,
};
const { svc, shareService } = makeService({
// The canonical boundary resolves the page to THIS share, live and
// unrestricted, returning { share, page }. (Membership + liveness +
// restriction are now asserted directly in the resolveReadableSharePage
// unit test in share.service.spec.ts.)
resolveReadableSharePage: jest
.fn()
.mockResolvedValue({ share: { id: 'SHARE-A' }, page }),
});
// The sanitizer returns the SANITIZED content (raw secrets removed).
shareService.updatePublicAttachments.mockResolvedValue(sanitizedContent);
const tools = svc.forShare('SHARE-A', 'ws-1');
const out = (await (tools.getSharePage as unknown as ToolExec).execute({
pageId: ' page-1 ',
})) as { title: string; markdown: string };
// The tool delegates the whole access resolve to the canonical boundary,
// passing the forShare-scoped shareId + the (trimmed) requested pageId.
expect(shareService.resolveReadableSharePage).toHaveBeenCalledWith(
'SHARE-A',
'page-1',
'ws-1',
);
// CRITICAL: the sanitizer MUST be called with the page before any content
// is converted. If a future change drops/reorders this, raw comment marks
// and attachment ids would leak to the anonymous model.
expect(shareService.updatePublicAttachments).toHaveBeenCalledTimes(1);
expect(shareService.updatePublicAttachments).toHaveBeenCalledWith(page);
// The returned markdown derives from the SANITIZED content, not the raw
// page.content: it contains the sanitized text and NONE of the secrets.
expect(out.title).toBe('Live Page');
expect(out.markdown).toContain('sanitized public text');
expect(out.markdown).not.toContain('SECRET_RAW_ATTACHMENT_ID');
expect(out.markdown).not.toContain('commentId');
});
});
describe('getSharePage non-resolving page (deleted / restricted / out-of-share)', () => {
it('resolveReadableSharePage returns null (e.g. soft-deleted page) => generic error, NO content sanitized/returned', async () => {
// The canonical boundary 404s a soft-deleted / restricted / out-of-tree
// page uniformly by returning null; the tool must surface the SAME generic
// message and never sanitize/return any content.
const { svc, shareService } = makeService({
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
});
const tools = svc.forShare('SHARE-A', 'ws-1');
await expect(
(tools.getSharePage as unknown as ToolExec).execute({
pageId: 'page-1',
}),
).rejects.toThrow('That page is not part of this published share.');
// No content is ever fetched/returned for a non-resolving page.
expect(shareService.updatePublicAttachments).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,6 @@ import { z } from 'zod';
import { ShareService } from '../../share/share.service';
import { SearchService } from '../../search/search.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { jsonToMarkdown } from '../../../collaboration/collaboration.util';
/**
@@ -22,10 +21,10 @@ import { jsonToMarkdown } from '../../../collaboration/collaboration.util';
* - search uses the existing share-scoped FTS branch
* (`shareId && !spaceId && !userId`), which itself restricts results to the
* share's pages and excludes restricted descendants;
* - reading a page first confirms, via `getShareForPage`, that the page
* resolves to THIS share AND (because getShareForPage does NOT itself
* exclude restricted descendants) that the page has no restricted ancestor,
* before returning any content.
* - reading a page first confirms, via the single canonical
* `ShareService.resolveReadableSharePage` boundary, that the page resolves
* to THIS share, is live, and has no restricted ancestor (which
* getShareForPage does NOT itself check), before returning any content.
*/
@Injectable()
export class PublicShareChatToolsService {
@@ -35,7 +34,6 @@ export class PublicShareChatToolsService {
private readonly shareService: ShareService,
private readonly searchService: SearchService,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
/**
@@ -99,38 +97,23 @@ export class PublicShareChatToolsService {
if (!id) {
throw new Error('A pageId is required.');
}
// Confirm the page resolves to THIS share (recursive CTE up the tree,
// honouring includeSubPages + workspace check). NOTE: getShareForPage
// joins only the `shares` table — it does NOT exclude restricted
// descendants — so membership alone is not sufficient (see the
// explicit restricted check below, which the public view also does).
// Not in this share => tool error WITHOUT leaking whether the page
// exists at all.
const share = await this.shareService.getShareForPage(
// Resolve via the SINGLE canonical share-access boundary: confirms the
// page resolves to THIS share (recursive CTE up the tree, honouring
// includeSubPages + workspace), the share id matches, the page is live
// (not soft-deleted), and it has NO restricted ancestor (a restricted
// descendant is hidden from the public view even inside an
// includeSubPages share). Any failure => null. Use the SAME generic
// message for every failure so the model cannot distinguish
// "restricted" / "deleted" / "not in share" / "doesn't exist".
const resolved = await this.shareService.resolveReadableSharePage(
shareId,
id,
workspaceId,
);
if (!share || share.id !== shareId) {
throw new Error('That page is not part of this published share.');
}
const page = await this.pageRepo.findById(id, {
includeContent: true,
});
if (!page || page.deletedAt) {
throw new Error('That page is not part of this published share.');
}
// A restricted descendant (a page with its own page_permissions /
// pageAccess row) is hidden from the public share view even when it
// sits inside an includeSubPages share. getShareForPage does NOT
// exclude it, so we must replicate the public view's restricted-
// ancestor gate here (ShareService.getSharedPage). Use the SAME
// generic message as an out-of-share page so the model cannot
// distinguish "restricted" from "not in share" (no info leak).
if (await this.pagePermissionRepo.hasRestrictedAncestor(page.id)) {
if (!resolved) {
throw new Error('That page is not part of this published share.');
}
const { page } = resolved;
// Reuse the public share-content sanitizer: strips comment marks and
// tokenizes attachments for public delivery, exactly as the public

View File

@@ -1,15 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the injected dependency tokens (e.g. AUDIT_SERVICE) at compile(),
// and this smoke test only needs the controller to construct.
describe('AuthController', () => {
let controller: AuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
}).compile();
controller = module.get<AuthController>(AuthController);
beforeEach(() => {
controller = new AuthController(
{} as any, // authService
{} as any, // sessionService
{} as any, // environmentService
{} as any, // moduleRef
{} as any, // auditService
);
});
it('should be defined', () => {

View File

@@ -1,15 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely() connection token (and AUDIT_SERVICE) at
// compile(); this smoke test only needs the service to construct.
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
}).compile();
service = module.get<AuthService>(AuthService);
beforeEach(() => {
service = new AuthService(
{} as any, // signupService
{} as any, // tokenService
{} as any, // sessionService
{} as any, // userSessionRepo
{} as any, // userRepo
{} as any, // userTokenRepo
{} as any, // mailService
{} as any, // domainService
{} as any, // environmentService
{} as any, // db
{} as any, // auditService
);
});
it('should be defined', () => {

View File

@@ -95,12 +95,19 @@ export class AuthService {
// Single source of truth (see auth.constants): the /mcp brute-force limiter
// recognises this exact message via isCredentialsFailure.
const errorMessage = CREDENTIALS_MISMATCH_MESSAGE;
if (!user || isUserDisabled(user)) {
if (!user || isUserDisabled(user) || !user.password) {
// SSO/LDAP-only accounts have no local password hash (user.password is
// null): feeding null to native bcrypt makes it REJECT with
// "data and hash arguments required", which surfaces as a 500 on
// /api/auth/login and as a leaky 401 (not recognised by the /mcp
// brute-force limiter) on /mcp. Treat such accounts like a missing user.
//
// Constant-time intent: run ONE bcrypt comparison (against a dummy hash)
// even when the user is missing/disabled, so this path takes about the
// same time as the real-user wrong-password path below. This closes the
// user-enumeration timing oracle (registered vs. not). The result is
// intentionally discarded — we always throw the same credentials error.
// even when the user is missing/disabled/password-less, so this path takes
// about the same time as the real-user wrong-password path below. This
// closes the user-enumeration timing oracle (registered vs. not). The
// result is intentionally discarded — we always throw the same
// credentials error (recognised by isCredentialsFailure on /mcp).
await comparePasswordHash(loginDto.password, DUMMY_PASSWORD_HASH);
throw new UnauthorizedException(errorMessage);
}
@@ -168,6 +175,15 @@ export class AuthService {
throw new NotFoundException('User not found');
}
// SSO/LDAP-only accounts have no local password hash (user.password is
// null). Passing null to native bcrypt makes it REJECT with
// "data and hash arguments required" (an unhandled 500), so never call
// comparePasswordHash on null. There is no current local password to verify,
// so reject the same way a wrong current password is rejected.
if (!user.password) {
throw new BadRequestException('Current password is incorrect');
}
const comparePasswords = await comparePasswordHash(
dto.oldPassword,
user.password,

View File

@@ -1,15 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from './token.service';
// Direct instantiation with stub deps, mirroring the rest of these unit specs.
describe('TokenService', () => {
let service: TokenService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TokenService],
}).compile();
service = module.get<TokenService>(TokenService);
beforeEach(() => {
service = new TokenService(
{} as any, // jwtService
{} as any, // environmentService
);
});
it('should be defined', () => {

View File

@@ -108,9 +108,10 @@ describe('AuthService no-side-effect contract (item 4)', () => {
// source level for the same reason as the rest of this file: AuthService cannot
// be imported under this jest config to spy on comparePasswordHash live.
describe('constant-time missing/disabled branch (item 4)', () => {
// Isolate the body of the `if (!user || isUserDisabled(user)) { ... }` guard.
// Isolate the body of the
// `if (!user || isUserDisabled(user) || !user.password) { ... }` guard.
const guardMatch = verifyBody.match(
/if \(!user \|\| isUserDisabled\(user\)\) \{([\s\S]*?)\n {4}\}/,
/if \(!user \|\| isUserDisabled\(user\) \|\| !user\.password\) \{([\s\S]*?)\n {4}\}/,
);
it('the missing/disabled guard runs a bcrypt compare before throwing', () => {
@@ -126,6 +127,25 @@ describe('AuthService no-side-effect contract (item 4)', () => {
expect(throwIdx).toBeGreaterThan(compareIdx);
});
// null-password (SSO/LDAP-only) accounts have user.password === null. The
// missing/disabled guard MUST also short-circuit on a null/empty password,
// otherwise comparePasswordHash(loginDto.password, null) feeds null to native
// bcrypt, which REJECTS ("data and hash arguments required") — a 500 on
// /api/auth/login and a leaky, limiter-evading 401 on /mcp. A regression that
// drops this null check fails here.
it('the guard also short-circuits null-password (SSO/LDAP-only) accounts', () => {
expect(guardMatch).not.toBeNull();
// The guard CONDITION includes a null/empty password check...
expect(verifyBody).toMatch(
/if \(!user \|\| isUserDisabled\(user\) \|\| !user\.password\)/,
);
// ...and the password-less branch reuses the same dummy-compare-then-throw
// body, so it never reaches the real `comparePasswordHash(..., user.password)`.
const guardBody = guardMatch![1];
expect(guardBody).toContain('comparePasswordHash');
expect(guardBody).toContain('throw new');
});
it('uses a module-level dummy hash constant (never a real credential)', () => {
// The dummy hash is a module-level constant referenced in the guard, not an
// inline literal recomputed per call.

View File

@@ -1,15 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CommentService } from './comment.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectQueue() tokens at compile(), and this smoke test only
// needs the service to construct.
describe('CommentService', () => {
let service: CommentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CommentService],
}).compile();
service = module.get<CommentService>(CommentService);
beforeEach(() => {
service = new CommentService(
{} as any, // commentRepo
{} as any, // pageRepo
{} as any, // wsService
{} as any, // collaborationGateway
{} as any, // generalQueue
{} as any, // notificationQueue
);
});
it('should be defined', () => {

View File

@@ -1,17 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GroupController } from './group.controller';
import { GroupService } from './services/group.service';
// Direct instantiation with stub deps, mirroring the rest of these unit specs.
describe('GroupController', () => {
let controller: GroupController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GroupController],
providers: [GroupService],
}).compile();
controller = module.get<GroupController>(GroupController);
beforeEach(() => {
controller = new GroupController(
{} as any, // groupService
{} as any, // groupUserService
{} as any, // workspaceAbility
);
});
it('should be defined', () => {

View File

@@ -1,15 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GroupService } from './group.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely() connection token (and AUDIT_SERVICE) at
// compile(); this smoke test only needs the service to construct.
describe('GroupService', () => {
let service: GroupService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GroupService],
}).compile();
service = module.get<GroupService>(GroupService);
beforeEach(() => {
service = new GroupService(
{} as any, // groupRepo
{} as any, // groupUserRepo
{} as any, // spaceMemberRepo
{} as any, // groupUserService
{} as any, // watcherRepo
{} as any, // favoriteRepo
{} as any, // db
{} as any, // auditService
);
});
it('should be defined', () => {

View File

@@ -1,17 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PageController } from './page.controller';
import { PageService } from './services/page.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve PageService's injected tokens at compile(), and this smoke test only
// needs the controller to construct.
describe('PageController', () => {
let controller: PageController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PageController],
providers: [PageService],
}).compile();
controller = module.get<PageController>(PageController);
beforeEach(() => {
controller = new PageController(
{} as any, // pageService
{} as any, // pageRepo
{} as any, // pageHistoryService
{} as any, // spaceAbility
{} as any, // pageAccessService
{} as any, // backlinkService
{} as any, // labelService
{} as any, // auditService
);
});
it('should be defined', () => {

View File

@@ -1,15 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PageService } from './page.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
// smoke test only needs the service to construct.
describe('PageService', () => {
let service: PageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PageService],
}).compile();
service = module.get<PageService>(PageService);
beforeEach(() => {
service = new PageService(
{} as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
{} as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
{} as any, // generalQueue
{} as any, // eventEmitter
{} as any, // collaborationGateway
{} as any, // watcherService
{} as any, // transclusionService
);
});
it('should be defined', () => {

View File

@@ -18,6 +18,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { shapeSidebarPagesTree } from './sidebar-pages-tree.util';
import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
@@ -232,6 +233,17 @@ export class PageService {
const isAgent = provenance?.actor === 'agent';
// Detect a real title/icon change so the WS tree listener can broadcast an
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
// content-only save. Only treat a field as changed when the DTO actually
// carries it AND its value differs from what is already stored — a no-op
// save (same title, or a content-only update where these are undefined)
// produces no tree snapshot, so the listener stays quiet.
const titleChanged =
updatePageDto.title !== undefined && updatePageDto.title !== page.title;
const iconChanged =
updatePageDto.icon !== undefined && updatePageDto.icon !== page.icon;
await this.pageRepo.updatePage(
{
title: updatePageDto.title,
@@ -249,6 +261,22 @@ export class PageService {
contributorIds: contributorIds,
},
page.id,
undefined,
// Enrich PAGE_UPDATED only when title/icon actually changed. The snapshot
// values come from the server-side data being persisted (DTO when present,
// otherwise the unchanged stored value), never relayed from the client.
titleChanged || iconChanged
? {
treeUpdate: {
id: page.id,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
...(titleChanged ? { title: updatePageDto.title } : {}),
...(iconChanged ? { icon: updatePageDto.icon } : {}),
},
}
: undefined,
);
this.generalQueue
@@ -903,9 +931,27 @@ export class PageService {
}
}
// Server-side cycle guard: a page may not be moved into itself or into any
// page within its own subtree. Without this, an MCP/REST/agent caller (or a
// fast drag racing the client check) could persist a cycle and broadcast it.
// Only relevant when re-parenting under a concrete parent; moving to root
// (parentPageId null/undefined) can never create a cycle.
if (dto.parentPageId) {
if (dto.parentPageId === dto.pageId) {
throw new BadRequestException('Cannot move a page into its own subtree');
}
// Walk the destination parent's ancestor chain (reusing the breadcrumb
// ancestor CTE). If the page being moved appears among those ancestors,
// the destination lives inside the moved page's subtree -> cycle.
const destAncestors = await this.getPageBreadCrumbs(dto.parentPageId);
if (destAncestors.some((ancestor) => ancestor.id === dto.pageId)) {
throw new BadRequestException('Cannot move a page into its own subtree');
}
}
const isAgent = provenance?.actor === 'agent';
await this.pageRepo.updatePage(
const updateResult = await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
@@ -921,6 +967,13 @@ export class PageService {
dto.pageId,
);
// Guard against a phantom broadcast: if the row was concurrently deleted or
// otherwise not updated, skip the PAGE_MOVED event so we don't replay a move
// built from the stale pre-read snapshot to every connected client.
if (!updateResult || updateResult.numUpdatedRows === 0n) {
return;
}
// The generic PAGE_UPDATED emitted by updatePage above is intentionally NOT
// used to drive the tree `moveTreeNode` broadcast: it also fires on rename /
// content-save and carries neither oldParentId nor the new position. Emit a
@@ -1339,37 +1392,13 @@ export class PageService {
permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit]));
}
// Derive hasChildren from the FINAL set: a node has children iff some
// returned row points to it as parent. In a restricted space this set is
// already pruned/filtered, so inaccessible children are not revealed.
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: hasRestrictions
? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true)
: (spaceCanEdit ?? true),
}));
// Order by position with byte order, matching the sidebar's
// `position collate "C"` SQL ordering. position is non-null in returned
// rows; treat a null defensively as sorting last.
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
// Shape into sidebar items (derive hasChildren, apply per-branch canEdit,
// order by position). Extracted as a pure helper so the load-bearing logic
// is unit-testable directly (see sidebar-pages-tree.util.ts / its spec).
return shapeSidebarPagesTree(pages, {
hasRestrictions,
spaceCanEdit,
permissionMap,
});
return shaped;
}
}

View File

@@ -1,68 +1,23 @@
/**
* Pure-logic test for getSidebarPagesTree's shaping/permission logic.
* Unit test for the REAL sidebar-tree shaping/permission logic.
*
* NOTE: We cannot import PageService directly here — its dependency chain
* imports `src/collaboration/collaboration.util` via a bare `src/...` path, and
* the server's jest config (package.json "jest".moduleNameMapper) has no
* `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a
* pre-existing config gap unrelated to this feature. To still cover the
* load-bearing logic we replicate the exact shaping algorithm from
* PageService.getSidebarPagesTree below and assert against it. If the service
* logic changes, keep this mirror in sync.
* PageService.getSidebarPagesTree delegates its load-bearing shaping (deriving
* hasChildren, applying the open/restricted-space canEdit branches, and
* position ordering) to the pure `shapeSidebarPagesTree` helper. We import and
* exercise that production helper directly here, so a regression in the real
* logic is caught. (The full PageService is not needed because the shaping is a
* self-contained pure transform over an already-fetched/filtered page set.)
*/
type RawPage = {
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
};
// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree.
function shapeTree(
pages: RawPage[],
opts: {
hasRestrictions: boolean;
spaceCanEdit?: boolean;
permissionMap?: Map<string, boolean>;
},
) {
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: opts.hasRestrictions
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
: (opts.spaceCanEdit ?? true),
}));
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}
import {
shapeSidebarPagesTree,
SidebarPageRow,
} from './sidebar-pages-tree.util';
const page = (
id: string,
parentPageId: string | null,
position: string,
): RawPage => ({
): SidebarPageRow => ({
id,
slugId: `slug-${id}`,
title: `Page ${id}`,
@@ -80,7 +35,7 @@ describe('getSidebarPagesTree shaping logic', () => {
page('leaf', 'child', 'a0'),
];
const result = shapeTree(pages, {
const result = shapeSidebarPagesTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
@@ -94,7 +49,7 @@ describe('getSidebarPagesTree shaping logic', () => {
it('open space: spaceCanEdit=false makes every node read-only', () => {
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
const result = shapeTree(pages, {
const result = shapeSidebarPagesTree(pages, {
hasRestrictions: false,
spaceCanEdit: false,
});
@@ -105,7 +60,7 @@ describe('getSidebarPagesTree shaping logic', () => {
// Simulates the filterAccessibleTreePages result: "child" was pruned, so
// the returned set has no row with parent === root.
const prunedPages = [page('root', null, 'a0')];
const result = shapeTree(prunedPages, {
const result = shapeSidebarPagesTree(prunedPages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([['root', true]]),
@@ -116,11 +71,8 @@ describe('getSidebarPagesTree shaping logic', () => {
});
it('restricted space: canEdit is per-page AND spaceCanEdit', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
];
const result = shapeTree(pages, {
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
const result = shapeSidebarPagesTree(pages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([
@@ -136,7 +88,7 @@ describe('getSidebarPagesTree shaping logic', () => {
it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => {
const pages = [page('root', null, 'a0')];
const result = shapeTree(pages, {
const result = shapeSidebarPagesTree(pages, {
hasRestrictions: true,
spaceCanEdit: false,
permissionMap: new Map([['root', true]]),
@@ -150,7 +102,7 @@ describe('getSidebarPagesTree shaping logic', () => {
page('c', null, 'a2'),
page('a', null, 'a0'),
];
const result = shapeTree(pages, {
const result = shapeSidebarPagesTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
@@ -158,7 +110,7 @@ describe('getSidebarPagesTree shaping logic', () => {
});
it('shape contains exactly the sidebar item fields', () => {
const result = shapeTree([page('root', null, 'a0')], {
const result = shapeSidebarPagesTree([page('root', null, 'a0')], {
hasRestrictions: false,
spaceCanEdit: true,
});

View File

@@ -0,0 +1,73 @@
import { Page } from '@docmost/db/types/entity.types';
/**
* Raw page row consumed by the sidebar-tree shaping. This is the minimal flat
* shape returned by the repo queries (getPageAndDescendants / getSpaceDescendants),
* before hasChildren/canEdit are derived.
*/
export type SidebarPageRow = {
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
};
export type ShapedSidebarPage = Pick<
Page,
'id' | 'slugId' | 'title' | 'icon' | 'position' | 'parentPageId' | 'spaceId'
> & { hasChildren: boolean; canEdit: boolean };
/**
* Pure shaping/permission transform extracted from
* PageService.getSidebarPagesTree. Takes the FINAL (already pruned/filtered)
* flat page set and derives the sidebar item shape:
* - hasChildren: a node has children iff some returned row points to it as
* parent. In a restricted space the input is already pruned/filtered, so
* inaccessible children are not revealed.
* - canEdit: open space -> spaceCanEdit; restricted space -> per-page
* permission AND spaceCanEdit.
* - ordering: by position with byte order, matching the sidebar's
* `position collate "C"` SQL ordering. position is non-null in returned
* rows; a null is treated defensively as sorting last.
*
* Kept as a standalone pure function so it can be unit-tested directly without
* the full PageService dependency chain.
*/
export function shapeSidebarPagesTree(
pages: SidebarPageRow[],
opts: {
hasRestrictions: boolean;
spaceCanEdit?: boolean;
permissionMap?: Map<string, boolean>;
},
): ShapedSidebarPage[] {
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: opts.hasRestrictions
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
: (opts.spaceCanEdit ?? true),
}));
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}

View File

@@ -131,15 +131,18 @@ describe('collectPageEmbedsFromPmJson', () => {
expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'deep' }]);
});
it('terminates (does not silently hang) on a self-referencing/cyclic object', () => {
// FINDING: there is NO explicit cycle guard. A hand-built cyclic JS object
// (which cannot arise from JSON parsing the real input path) makes the
// recursive walk overflow the stack and throw a RangeError. It TERMINATES
// with a controlled error rather than recursing unboundedly forever, and a
// non-cyclic (JSON-shaped) document is never affected.
it('returns gracefully (does not throw) on a self-referencing/cyclic object', () => {
// A depth guard (see MAX_PM_WALK_DEPTH) defends against a hand-built cyclic
// JS object — which cannot arise from JSON parsing, the real input path
// so the recursive walk stops at the cap instead of overflowing the stack.
// A non-cyclic (JSON-shaped) document is never affected.
const node: any = { type: 'doc', content: [] };
node.content.push(node); // content array references its own parent node
expect(() => collectPageEmbedsFromPmJson(node)).toThrow(RangeError);
let got: ReturnType<typeof collectPageEmbedsFromPmJson>;
expect(() => {
got = collectPageEmbedsFromPmJson(node);
}).not.toThrow();
expect(got!).toEqual([]);
});
});

View File

@@ -1,4 +1,5 @@
import {
collectPageEmbedsFromPmJson,
collectReferencesFromPmJson,
collectTransclusionsFromPmJson,
} from '../utils/transclusion-prosemirror.util';
@@ -238,3 +239,48 @@ describe('collectReferencesFromPmJson', () => {
]);
});
});
describe('collectPageEmbedsFromPmJson', () => {
it('returns [] for null/undefined doc', () => {
expect(collectPageEmbedsFromPmJson(null)).toEqual([]);
expect(collectPageEmbedsFromPmJson(undefined)).toEqual([]);
});
it('collects unique sourcePageIds from pageEmbed nodes', () => {
const doc = {
type: 'doc',
content: [
{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } },
{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } },
{ type: 'pageEmbed', attrs: { sourcePageId: 'p2' } },
],
};
expect(collectPageEmbedsFromPmJson(doc)).toEqual([
{ sourcePageId: 'p1' },
{ sourcePageId: 'p2' },
]);
});
it('does not throw (returns gracefully) on a self-referential / cyclic doc', () => {
// A cycle is unreachable via JSON.parse, but a hand-built non-JSON input
// could carry one; the depth guard must stop the recursion instead of
// overflowing the stack.
const node: any = { type: 'doc', content: [] };
node.content.push(node); // self-reference
let got: ReturnType<typeof collectPageEmbedsFromPmJson>;
expect(() => {
got = collectPageEmbedsFromPmJson(node);
}).not.toThrow();
expect(got!).toEqual([]);
});
it('does not throw on nesting far beyond the depth cap', () => {
// Build a chain deeper than MAX_PM_WALK_DEPTH (1000) ending in a pageEmbed.
let inner: any = { type: 'pageEmbed', attrs: { sourcePageId: 'deep' } };
for (let i = 0; i < 5000; i++) {
inner = { type: 'doc', content: [inner] };
}
expect(() => collectPageEmbedsFromPmJson(inner)).not.toThrow();
});
});

View File

@@ -267,6 +267,18 @@ export class TransclusionService {
* accumulate cross-workspace edges, but rows are still NOT per-viewer
* permission-filtered. EVERY consumer of these rows MUST permission-filter at
* read time (as `lookupTemplate` does via `filterViewerAccessiblePageIds`).
*
* NOTE (write-only graph — intentional, not dead): as of now the
* `page_template_references` table is WRITE-ONLY in production. It is populated
* by three paths (this diff-sync, `insertTemplateReferencesForPages` for new
* pages, and the collab persistence flush) but has NO production reader: the
* only read of the table is `findByReferencePageId` below, used purely to
* compute this sync's insert/delete diff — there is no reverse-navigation
* consumer yet (issue #34's dead `findReferencePageIdsBySource` reader was
* already removed). The graph is retained deliberately for an upcoming
* "used in N pages" reverse-navigation consumer; keep writing it so that
* feature has correct history when it lands. Do not remove the write graph or
* its migration just because nothing reads it today. (See Gitea #94.)
*/
async syncPageTemplateReferences(
referencePageId: string,
@@ -387,6 +399,15 @@ export class TransclusionService {
* pages return `no_access`, missing/deleted pages return `not_found`. Does NOT
* require `is_template` — any accessible page can be embedded (the template
* flag only affects picker discovery).
*
* FLAT, single-level by design: this returns each requested page's own content
* verbatim and never recurses. If a returned page itself contains a `pageEmbed`
* node pointing at another page, that embed is left unresolved — the client
* issues a follow-up lookup for it. Because there is no server-side recursive
* expansion, there is no server depth/cycle to guard here: the embed depth/cycle
* cap (PAGE_EMBED_MAX_DEPTH) is purely a client RENDER concern. A scripted client
* that walks the graph manually is bounded by the per-user throttle (30/60s) on
* the controller plus the DTO's ArrayMaxSize(50) per call.
*/
async lookupTemplate(
sourcePageIds: string[],

View File

@@ -4,6 +4,13 @@ const TRANSCLUSION_TYPE = 'transclusionSource';
const REFERENCE_TYPE = 'transclusionReference';
const PAGE_EMBED_TYPE = 'pageEmbed';
// Hard cap on recursion depth while walking a ProseMirror doc. Real documents
// nest only a handful of levels deep, so this ceiling is unreachable on any
// genuine input. It exists purely to defend against a pathological or cyclic
// non-JSON input (JSON.parse can't produce cycles, but other callers might
// hand us a hand-built/cyclic object) so the recursion can't overflow the stack.
const MAX_PM_WALK_DEPTH = 1000;
export type TransclusionReferenceSnapshot = {
sourcePageId: string;
transclusionId: string;
@@ -13,6 +20,79 @@ export type PageEmbedSnapshot = {
sourcePageId: string;
};
/**
* Generic, internal "collect every node of one PM type from a doc" walker that
* the three public `collect*FromPmJson` collectors are built on. They all share
* the exact same recursion (block-container descent + the #55 depth cap), and
* differed only in (target type, how a matched node maps to an output snapshot,
* how matches are deduped, and whether the walk descends into a
* `transclusionSource`). Centralising the recursion here keeps that shared logic
* — especially the depth guard — in one place so the collectors can't drift.
*
* Behaviour knobs (each collector wires these to reproduce its EXACT prior output):
* - `type`: only nodes whose `node.type` equals this are passed to `map`.
* - `map`: turns a matched node into a snapshot, or returns `undefined` to skip
* it (e.g. a transclusion with no id, or a reference missing attrs).
* - `key`: dedup key for a produced snapshot. Snapshots sharing a key collapse
* to a single entry; `lastWins` decides which one survives.
* - `lastWins`: when true (transclusions), a later duplicate overwrites the
* earlier one (Map semantics); when false (references, page embeds), the
* first occurrence wins and later duplicates are ignored. Either way the
* surviving entries keep first-seen insertion order.
* - `skipChildrenOfType`: a node type whose subtree the walk must NOT enter.
* References/embeds pass `transclusionSource` here (the schema forbids them
* inside a source, so a malformed inbound doc can't smuggle one in). The
* transclusion collector leaves this undefined because the matched type IS
* `transclusionSource` and matched nodes already short-circuit recursion.
*
* A matched node never recurses into its own children: every target type here is
* either an atom (reference/pageEmbed) or a boundary we deliberately don't nest
* into (transclusionSource), exactly as the original collectors behaved.
*/
function collectNodes<T>(
doc: unknown,
opts: {
type: string;
map: (node: any) => T | undefined;
key: (snapshot: T) => string;
lastWins?: boolean;
skipChildrenOfType?: string;
},
): T[] {
if (!doc || typeof doc !== 'object') return [];
const { type, map, key, lastWins = false, skipChildrenOfType } = opts;
const byKey = new Map<string, T>();
const visit = (node: any, depth: number): void => {
if (!node || typeof node !== 'object') return;
// Depth guard against a pathological/cyclic non-JSON input (see
// MAX_PM_WALK_DEPTH); unreachable on real docs.
if (depth > MAX_PM_WALK_DEPTH) return;
if (node.type === type) {
const snapshot = map(node);
if (snapshot !== undefined) {
const k = key(snapshot);
if (lastWins || !byKey.has(k)) byKey.set(k, snapshot);
}
return; // matched node: atom or boundary — do not recurse into children
}
// Don't descend into an isolated subtree (schema-enforced boundary).
if (skipChildrenOfType !== undefined && node.type === skipChildrenOfType) {
return;
}
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child, depth + 1);
}
};
visit(doc, 0);
return Array.from(byKey.values());
}
/**
* Walks a ProseMirror JSON document and returns one snapshot per top-level
* `transclusion` node. Does not recurse into transclusions (schema disallows
@@ -23,31 +103,23 @@ export type PageEmbedSnapshot = {
export function collectTransclusionsFromPmJson(
doc: unknown,
): TransclusionNodeSnapshot[] {
if (!doc || typeof doc !== 'object') return [];
const byId = new Map<string, TransclusionNodeSnapshot>();
const visit = (node: any): void => {
if (!node || typeof node !== 'object') return;
if (node.type === TRANSCLUSION_TYPE) {
// last-wins on duplicate ids (Map.set overwrites) — matches prior behaviour.
return collectNodes<TransclusionNodeSnapshot>(doc, {
type: TRANSCLUSION_TYPE,
map: (node) => {
const id = node.attrs?.id;
if (typeof id === 'string' && id.length > 0) {
byId.set(id, {
transclusionId: id,
content: { type: 'doc', content: node.content ?? [] },
});
}
return; // do not recurse into transclusion children
}
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
}
};
visit(doc);
return Array.from(byId.values());
if (typeof id !== 'string' || id.length === 0) return undefined;
return {
transclusionId: id,
content: { type: 'doc', content: node.content ?? [] },
};
},
key: (snapshot) => snapshot.transclusionId,
lastWins: true,
// No skipChildrenOfType: TRANSCLUSION_TYPE is itself the matched type, and a
// matched node already short-circuits recursion (the schema also forbids a
// transclusion nested inside another).
});
}
/**
@@ -60,43 +132,26 @@ export function collectTransclusionsFromPmJson(
export function collectReferencesFromPmJson(
doc: unknown,
): TransclusionReferenceSnapshot[] {
if (!doc || typeof doc !== 'object') return [];
const seen = new Set<string>();
const out: TransclusionReferenceSnapshot[] = [];
const visit = (node: any): void => {
if (!node || typeof node !== 'object') return;
if (node.type === REFERENCE_TYPE) {
// first-wins dedup on (sourcePageId, transclusionId); skip recursing into a
// transclusionSource (schema forbids references inside one).
return collectNodes<TransclusionReferenceSnapshot>(doc, {
type: REFERENCE_TYPE,
map: (node) => {
const sourcePageId = node.attrs?.sourcePageId;
const transclusionId = node.attrs?.transclusionId;
if (
typeof sourcePageId === 'string' &&
sourcePageId.length > 0 &&
typeof transclusionId === 'string' &&
transclusionId.length > 0
typeof sourcePageId !== 'string' ||
sourcePageId.length === 0 ||
typeof transclusionId !== 'string' ||
transclusionId.length === 0
) {
const key = `${sourcePageId}::${transclusionId}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ sourcePageId, transclusionId });
}
return undefined;
}
return; // atom node - no children
}
// References cannot live inside a source (schema-enforced); skip recursing
// so a malformed inbound doc can't sneak in a nested reference here.
if (node.type === TRANSCLUSION_TYPE) return;
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
}
};
visit(doc);
return out;
return { sourcePageId, transclusionId };
},
key: (snapshot) => `${snapshot.sourcePageId}::${snapshot.transclusionId}`,
skipChildrenOfType: TRANSCLUSION_TYPE,
});
}
/**
@@ -133,8 +188,11 @@ export function remapPageEmbedSourceIds<T>(
doc: T,
idMap: Map<string, string>,
): T {
const visit = (node: any): void => {
const visit = (node: any, depth: number): void => {
if (!node || typeof node !== 'object') return;
// Depth guard against a pathological/cyclic non-JSON input (see
// MAX_PM_WALK_DEPTH); unreachable on real docs.
if (depth > MAX_PM_WALK_DEPTH) return;
if (node.type === PAGE_EMBED_TYPE) {
if (node.attrs) {
@@ -149,11 +207,11 @@ export function remapPageEmbedSourceIds<T>(
if (node.type === TRANSCLUSION_TYPE) return;
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
for (const child of node.content) visit(child, depth + 1);
}
};
visit(doc);
visit(doc, 0);
return doc;
}
@@ -166,32 +224,17 @@ export function remapPageEmbedSourceIds<T>(
export function collectPageEmbedsFromPmJson(
doc: unknown,
): PageEmbedSnapshot[] {
if (!doc || typeof doc !== 'object') return [];
const seen = new Set<string>();
const out: PageEmbedSnapshot[] = [];
const visit = (node: any): void => {
if (!node || typeof node !== 'object') return;
if (node.type === PAGE_EMBED_TYPE) {
// first-wins dedup on sourcePageId; skip recursing into a transclusionSource.
return collectNodes<PageEmbedSnapshot>(doc, {
type: PAGE_EMBED_TYPE,
map: (node) => {
const sourcePageId = node.attrs?.sourcePageId;
if (typeof sourcePageId === 'string' && sourcePageId.length > 0) {
if (!seen.has(sourcePageId)) {
seen.add(sourcePageId);
out.push({ sourcePageId });
}
if (typeof sourcePageId !== 'string' || sourcePageId.length === 0) {
return undefined;
}
return; // atom node - no children
}
if (node.type === TRANSCLUSION_TYPE) return;
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
}
};
visit(doc);
return out;
return { sourcePageId };
},
key: (snapshot) => snapshot.sourcePageId,
skipChildrenOfType: TRANSCLUSION_TYPE,
});
}

View File

@@ -0,0 +1,136 @@
import { ShareService } from './share.service';
/**
* Focused unit test for ShareService.resolveReadableSharePage — THE single
* share-access boundary that every public-share read path funnels through.
*
* The security invariant, in one place: a (shareId, pageId) pair resolves to a
* usable page ONLY when it is reachable in this workspace's share graph, is the
* SAME share the caller asked for, is a live (non-deleted) page, and has NO
* restricted ancestor. ANY failure must return null (no exception, no leak of
* which check failed). These cases pin the boundary directly so it cannot drift
* even if a downstream call-site is refactored.
*
* getShareForPage itself is a raw recursive-CTE db query, so it is spied; every
* other collaborator is a plain mock. The restricted-ancestor gate is exercised
* for real (it is the gate getShareForPage does NOT itself perform).
*/
const WS = 'ws-1';
const SHARE = 'SHARE-A';
const PAGE = 'page-1';
function buildService(over: {
resolvedShare?: unknown;
page?: unknown;
restricted?: boolean;
} = {}) {
const pageRepo = {
findById: jest.fn(async () =>
'page' in over
? over.page
: { id: PAGE, deletedAt: null, content: {} },
),
};
const pagePermissionRepo = {
hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false),
};
const service = new ShareService(
{} as any, // shareRepo (unused on this path)
pageRepo as any,
pagePermissionRepo as any,
{} as any, // db (getShareForPage is spied)
{} as any, // tokenService (unused)
{} as any, // transclusionService (unused)
{} as any, // workspaceRepo (unused)
);
jest
.spyOn(service, 'getShareForPage')
.mockResolvedValue(
('resolvedShare' in over
? over.resolvedShare
: { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any,
);
return { service, pageRepo, pagePermissionRepo };
}
describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => {
it('resolves { share, page } for a readable, in-share, live, unrestricted page', async () => {
const page = { id: PAGE, deletedAt: null, content: { type: 'doc' } };
const { service, pageRepo, pagePermissionRepo } = buildService({ page });
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
expect(out).not.toBeNull();
expect(out!.share.id).toBe(SHARE);
expect(out!.page).toBe(page);
// The restricted-ancestor gate ran on the resolved page id.
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(PAGE);
// Content is fetched (callers sanitize it); creator off by default.
expect(pageRepo.findById).toHaveBeenCalledWith(PAGE, {
includeContent: true,
includeCreator: false,
});
});
it('null when the page is not reachable in the share graph (getShareForPage => undefined)', async () => {
const { service, pageRepo } = buildService({ resolvedShare: undefined });
expect(await service.resolveReadableSharePage(SHARE, PAGE, WS)).toBeNull();
// Short-circuits before fetching the page.
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('null on a cross-share id swap: page resolves to a DIFFERENT share than requested', async () => {
const { service, pageRepo } = buildService({
resolvedShare: { id: 'OTHER-SHARE', pageId: PAGE, spaceId: 'space-1' },
});
expect(await service.resolveReadableSharePage(SHARE, PAGE, WS)).toBeNull();
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('null for a soft-deleted page (deletedAt set), without consulting the restricted gate', async () => {
const { service, pagePermissionRepo } = buildService({
page: { id: PAGE, deletedAt: new Date(), content: {} },
});
expect(await service.resolveReadableSharePage(SHARE, PAGE, WS)).toBeNull();
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
});
it('null when the page row is missing (findById => null)', async () => {
const { service } = buildService({ page: null });
expect(await service.resolveReadableSharePage(SHARE, PAGE, WS)).toBeNull();
});
it('null for a restricted descendant (hidden from the public view)', async () => {
const { service } = buildService({
page: { id: PAGE, deletedAt: null, content: {} },
restricted: true,
});
expect(await service.resolveReadableSharePage(SHARE, PAGE, WS)).toBeNull();
});
it('skips the share-id match when shareId is null (getSharedPage path: share resolved FROM the page)', async () => {
const { service } = buildService({
// The page resolves to whatever share owns it; there is no independent
// requested shareId to cross-check.
resolvedShare: { id: 'ANY-SHARE', pageId: PAGE, spaceId: 'space-1' },
page: { id: PAGE, deletedAt: null, content: {} },
});
const out = await service.resolveReadableSharePage(null, PAGE, WS);
expect(out).not.toBeNull();
expect(out!.share.id).toBe('ANY-SHARE');
});
it('passes includeCreator through to the page fetch when requested', async () => {
const { service, pageRepo } = buildService();
await service.resolveReadableSharePage(SHARE, PAGE, WS, {
includeCreator: true,
});
expect(pageRepo.findById).toHaveBeenCalledWith(PAGE, {
includeContent: true,
includeCreator: true,
});
});
});

View File

@@ -128,28 +128,82 @@ export class ShareService {
}
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
const share = await this.getShareForPage(dto.pageId, workspaceId);
/**
* THE share access boundary in ONE place.
*
* Answers exactly: "does this (shareId, pageId) pair resolve to a usable,
* non-restricted, live page WITHIN this share?" Returns the resolved
* `{ share, page }` on success, or `null` on ANY failure (share not found /
* wrong workspace / out-of-tree page / share-id mismatch / missing /
* soft-deleted / restricted ancestor).
*
* This is the single canonical sequence that every public-share read path
* must funnel through, so no path can skip a check (most importantly the
* restricted-ancestor gate, which `getShareForPage` does NOT perform on its
* own). The checks run in this fixed order:
* 1. getShareForPage(pageId, workspaceId) — page reachable in this ws?
* 2. share.id === shareId — and it is THIS share?
* (pass `null`/`undefined` shareId to skip the match when the caller has
* no independent requested shareId — getSharedPage resolves the share
* FROM the page, so there is nothing to cross-check.)
* 3. pageRepo.findById(pageId, ...) — page row (+ content/creator)
* 4. !page.deletedAt — live (defense in depth:
* getShareForPage already excludes deleted anchors)
* 5. !hasRestrictedAncestor(page.id) — not a restricted descendant
*
* `isSharingAllowed` is intentionally NOT part of this boundary: it is an
* orthogonal workspace/space toggle that each call-site layers separately
* (share.controller after getSharedPage; the assistant funnel as its own
* gate). Folding it in here would silently change those call-sites' grading.
*/
async resolveReadableSharePage(
shareId: string | null | undefined,
pageId: string,
workspaceId: string,
opts?: { includeCreator?: boolean },
): Promise<{
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
page: Page;
} | null> {
const share = await this.getShareForPage(pageId, workspaceId);
if (!share) return null;
if (!share) {
throw new NotFoundException('Shared page not found');
}
// Only ever an equality check against the server-resolved share id; an
// attacker-supplied shareId can never widen access. Skipped when the caller
// passes no shareId (it resolved the share from the page itself).
if (shareId != null && share.id !== shareId) return null;
const page = await this.pageRepo.findById(dto.pageId, {
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
includeCreator: true,
includeCreator: opts?.includeCreator ?? false,
});
if (!page || page.deletedAt) return null;
if (!page || page.deletedAt) {
// Restricted descendants are hidden from the public view even inside an
// includeSubPages share; getShareForPage does NOT exclude them.
if (await this.pagePermissionRepo.hasRestrictedAncestor(page.id)) {
return null;
}
return { share, page };
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
// Resolve via the single canonical boundary. There is no independent
// requested shareId here (the share is resolved FROM the page), so no
// share-id match is performed.
const resolved = await this.resolveReadableSharePage(
null,
dto.pageId,
workspaceId,
{ includeCreator: true },
);
if (!resolved) {
throw new NotFoundException('Shared page not found');
}
// Block access to restricted pages
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
if (isRestricted) {
throw new NotFoundException('Shared page not found');
}
const { share, page } = resolved;
page.content = await this.updatePublicAttachments(page);

View File

@@ -1,15 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpaceService } from './space.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue()/AUDIT_SERVICE tokens at compile();
// this smoke test only needs the service to construct.
describe('SpaceService', () => {
let service: SpaceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SpaceService],
}).compile();
service = module.get<SpaceService>(SpaceService);
beforeEach(() => {
service = new SpaceService(
{} as any, // spaceRepo
{} as any, // spaceMemberService
{} as any, // shareRepo
{} as any, // workspaceRepo
{} as any, // licenseCheckService
{} as any, // db
{} as any, // attachmentQueue
{} as any, // auditService
);
});
it('should be defined', () => {

View File

@@ -1,17 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpaceController } from './space.controller';
import { SpaceService } from './services/space.service';
// Direct instantiation with stub deps, mirroring the rest of these unit specs.
describe('SpaceController', () => {
let controller: SpaceController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SpaceController],
providers: [SpaceService],
}).compile();
controller = module.get<SpaceController>(SpaceController);
beforeEach(() => {
controller = new SpaceController(
{} as any, // spaceService
{} as any, // spaceMemberService
{} as any, // spaceMemberRepo
{} as any, // spaceAbility
{} as any, // workspaceAbility
);
});
it('should be defined', () => {

View File

@@ -1,17 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserController } from './user.controller';
import { UserService } from './user.service';
// Direct instantiation with stub deps, mirroring the rest of these unit specs.
describe('UserController', () => {
let controller: UserController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [UserService],
}).compile();
controller = module.get<UserController>(UserController);
beforeEach(() => {
controller = new UserController(
{} as any, // userService
{} as any, // workspaceRepo
);
});
it('should be defined', () => {

View File

@@ -1,15 +1,32 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceService } from './workspace.service';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue()/AUDIT_SERVICE tokens at compile();
// this smoke test only needs the service to construct.
describe('WorkspaceService', () => {
let service: WorkspaceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [WorkspaceService],
}).compile();
service = module.get<WorkspaceService>(WorkspaceService);
beforeEach(() => {
service = new WorkspaceService(
{} as any, // workspaceRepo
{} as any, // spaceService
{} as any, // spaceMemberService
{} as any, // groupRepo
{} as any, // groupUserRepo
{} as any, // userRepo
{} as any, // environmentService
{} as any, // domainService
{} as any, // licenseCheckService
{} as any, // shareRepo
{} as any, // watcherRepo
{} as any, // favoriteRepo
{} as any, // db
{} as any, // attachmentQueue
{} as any, // billingQueue
{} as any, // aiQueue
{} as any, // auditService
{} as any, // userSessionRepo
);
});
it('should be defined', () => {

View File

@@ -34,6 +34,30 @@ export class PageEvent {
// Set on PAGE_RESTORED so the WS listener can scope a refetchRootTreeNodeEvent
// to the affected space (restore can re-attach a whole subtree).
spaceId?: string;
// Set on a PAGE_UPDATED that actually changed the page's title and/or icon
// (a rename or icon swap). Content-only saves leave this undefined, which is
// how the WS listener distinguishes a tree-relevant metadata change from a
// noisy content save and avoids re-broadcasting on every keystroke-flush.
// Server-authoritative: built from the values being persisted, not relayed
// from the client.
treeUpdate?: TreeUpdateSnapshot;
}
/**
* Thin snapshot carried on a PAGE_UPDATED event when the title and/or icon
* changed, so the WS tree listener can broadcast an `updateOne` without a DB
* read. Only the fields the client tree receiver (`applyUpdateOne`) consumes
* are included.
*/
export interface TreeUpdateSnapshot {
id: string;
slugId: string;
spaceId: string;
parentPageId: string | null;
// Present only when that field actually changed; an undefined field is left
// untouched by the client reducer.
title?: string | null;
icon?: string | null;
}
/**

View File

@@ -0,0 +1,51 @@
import { AiAgentRoleRepo } from './ai-agent-roles.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
* Unit test for the SECURITY invariant carried by
* AiAgentRoleRepo.findLiveEnabled: it is the single source of truth shared by
* the authenticated chat and the anonymous public-share assistant for "resolve
* a roleId to a LIVE, ENABLED role scoped to the workspace, else undefined".
*
* A live Postgres is out of scope here; instead we record the query the repo
* builds and assert it pins ALL of the security filters: id, workspaceId,
* deletedAt IS NULL, and enabled = true. If any of those `where` clauses is
* dropped, the role scoping silently widens — this test guards exactly that.
*/
describe('AiAgentRoleRepo.findLiveEnabled', () => {
function makeRepoWithSpy(result: unknown) {
const where = jest.fn();
const builder = {
selectAll: jest.fn(() => builder),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
executeTakeFirst: jest.fn().mockResolvedValue(result),
};
const db = {
selectFrom: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), db, where };
}
it('queries scoped to id + workspace, live (deletedAt null) AND enabled', async () => {
const role = { id: 'r-1', workspaceId: 'ws-1', enabled: true };
const { repo, db, where } = makeRepoWithSpy(role);
const result = await repo.findLiveEnabled('r-1', 'ws-1');
expect(result).toBe(role);
expect(db.selectFrom).toHaveBeenCalledWith('aiAgentRoles');
// Every security filter must be present.
expect(where).toHaveBeenCalledWith('id', '=', 'r-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(where).toHaveBeenCalledWith('deletedAt', 'is', null);
expect(where).toHaveBeenCalledWith('enabled', '=', true);
});
it('returns undefined when no live+enabled role matches', async () => {
const { repo } = makeRepoWithSpy(undefined);
expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined();
});
});

View File

@@ -32,6 +32,29 @@ export class AiAgentRoleRepo {
.executeTakeFirst();
}
/**
* Single live (not soft-deleted) AND enabled role scoped to the workspace, or
* undefined. This is the SECURITY invariant shared by the authenticated chat
* and the anonymous public-share assistant: a role only applies its persona /
* model override when it currently exists, is not soft-deleted, and is enabled
* — a disabled or deleted role server-authoritatively degrades to the built-in
* universal assistant. Single source of truth so the two resolve paths cannot
* drift apart.
*/
async findLiveEnabled(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('enabled', '=', true)
.executeTakeFirst();
}
/** All live roles for the workspace (management list + chat picker). */
async listByWorkspace(workspaceId: string): Promise<AiAgentRole[]> {
return this.db

View File

@@ -16,6 +16,16 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
import { TreeUpdateSnapshot } from '../../listeners/page.listener';
/**
* Optional extras for the PAGE_UPDATED event emitted by updatePage(s). Lets the
* caller attach a tree snapshot for a title/icon change so the WS listener can
* broadcast an `updateOne` without re-reading the DB.
*/
export interface UpdatePageEventOpts {
treeUpdate?: TreeUpdateSnapshot;
}
@Injectable()
export class PageRepo {
@@ -138,14 +148,16 @@ export class PageRepo {
updatablePage: UpdatablePage,
pageId: string,
trx?: KyselyTransaction,
opts?: UpdatePageEventOpts,
) {
return this.updatePages(updatablePage, [pageId], trx);
return this.updatePages(updatablePage, [pageId], trx, opts);
}
async updatePages(
updatePageData: UpdatablePage,
pageIds: string[],
trx?: KyselyTransaction,
opts?: UpdatePageEventOpts,
) {
const result = await dbOrTx(this.db, trx)
.updateTable('pages')
@@ -160,6 +172,11 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: pageIds,
workspaceId: updatePageData.workspaceId,
// Optional tree snapshot for the WS listener (variant A). The caller sets
// it ONLY for a title/icon change so the listener can broadcast an
// `updateOne` without a DB read; content-only saves omit it and the
// listener skips them. Built from server-side data, never client-relayed.
...(opts?.treeUpdate ? { treeUpdate: opts.treeUpdate } : {}),
});
return result;

View File

@@ -1,15 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentService } from './environment.service';
// Direct instantiation with a stub ConfigService, mirroring the rest of these
// unit specs.
describe('EnvironmentService', () => {
let service: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EnvironmentService],
}).compile();
service = module.get<EnvironmentService>(EnvironmentService);
beforeEach(() => {
service = new EnvironmentService(
{} as any, // configService
);
});
it('should be defined', () => {

View File

@@ -84,6 +84,39 @@ export class FailedLoginLimiter {
b.count += 1;
}
/**
* Atomic check-and-reserve: if the key is already at/over the threshold this
* window, return false (blocked). Otherwise count this in-flight attempt
* (count += 1) and return true. Being synchronous, concurrent callers cannot
* interleave between the check and the increment, so the (threshold+1)-th
* concurrent attempt is rejected even before its bcrypt runs.
*
* This is the brute-force fix for the /mcp Basic path: the increment happens
* BEFORE the async credential check, not after it, so N concurrent requests for
* one email cannot all observe count=0 and all run bcrypt. A failed login then
* leaves the reservation in place (it IS the recorded failure); a SUCCESSFUL
* login clears it via reset(); a non-credential business error releases it via
* release() so it does not count as a guessed-password signal.
*/
tryReserve(key: string, now: number = Date.now()): boolean {
const b = this.bucket(key, now);
if (b.count >= this.threshold) return false;
b.count += 1;
return true;
}
/**
* Undo a previous tryReserve for the key within the same window (count -= 1,
* floored at 0). Used to release an optimistic in-flight reservation when the
* attempt turned out NOT to be a password-guess signal (e.g. an "email not
* verified" business error), so it does not burn a victim's limiter budget.
* A no-op if the bucket rolled over to a fresh window in the meantime.
*/
release(key: string, now: number = Date.now()): void {
const b = this.bucket(key, now);
if (b.count > 0) b.count -= 1;
}
/** Clear the key after a successful login so it does not accumulate. */
reset(key: string): void {
this.buckets.delete(key);
@@ -530,51 +563,84 @@ export async function resolveMcpSessionConfig(
// trusted proxy is configured; the per-email global key is the part that
// does NOT depend on a trustworthy IP and is the real brute-force backstop.
const emailKey = `email:${emailLc}`;
if (
deps.limiter.isBlocked(ipKey) ||
deps.limiter.isBlocked(ipEmailKey) ||
deps.limiter.isBlocked(emailKey)
) {
// Atomic check-AND-reserve, synchronously and BEFORE any await. The old code
// did a read-only isBlocked() pre-check here and only recordFailure()'d the
// failure AFTER the awaited bcrypt login — so N concurrent requests for one
// email all saw count=0, all ran bcrypt, all failed, and only then all
// recorded, blowing far past the threshold. tryReserve() folds the check and
// the increment into one synchronous, non-interleavable step: it counts this
// in-flight attempt NOW, so the (threshold+1)-th concurrent attempt is
// rejected before its bcrypt ever runs. The reservation IS the recorded
// failure (no separate recordFailure on the failure path below); a successful
// login clears it via reset(), and a non-credential business error releases
// it via release(). Reserve ALL keys so each per-key budget is charged.
const ipOk = deps.limiter.tryReserve(ipKey);
const ipEmailOk = deps.limiter.tryReserve(ipEmailKey);
const emailOk = deps.limiter.tryReserve(emailKey);
if (!ipOk || !ipEmailOk || !emailOk) {
// At least one key is at/over threshold: blocked. Release the keys we DID
// manage to reserve in this same call so a rejected (already-throttled)
// request does not over-charge the keys that were still under budget — the
// same observable outcome as the old isBlocked() pre-check, which never
// incremented on a blocked request.
if (ipOk) deps.limiter.release(ipKey);
if (ipEmailOk) deps.limiter.release(ipEmailKey);
if (emailOk) deps.limiter.release(emailKey);
throw new UnauthorizedException(
'Too many failed MCP login attempts. Try again later.',
);
}
const workspace = await deps.findWorkspace();
if (!workspace) {
throw new UnauthorizedException('No workspace is configured.');
}
// SSO/MFA pre-token gate (BLOCKER fix): replicate the AuthController.login
// gates BEFORE any token is issued on the Basic path. If the workspace
// enforces SSO, or the EE MFA module is bundled and this user/workspace
// requires MFA, this throws and we never mint a token. The Bearer path is
// intentionally NOT gated here (its JWT was already minted post-gate). This
// runs on BOTH init and subsequent Basic requests, but it must run before
// login()/verifyCredentials so an SSO/MFA user cannot authenticate at all.
// We do NOT count a gate rejection toward the brute-force limiter: it is not
// a password-guess signal.
if (deps.enforceBasicGate) {
await deps.enforceBasicGate(workspace, {
email: basic.email,
password: basic.password,
});
}
// Fix 1 (init vs subsequent):
// - SESSION INIT (no mcp-session-id): full login() mints the user JWT
// (the one allowed session creation + audit event for this MCP
// session). The DocmostClient caches that token, so later tool calls
// never re-login.
// - SUBSEQUENT request (has mcp-session-id): we only need to re-validate
// the caller's credentials for anti-fixation. verifyCredentials() does
// the SAME lookup/password/email-verified/disabled checks as login()
// but mints NO session, writes NO audit row and updates NO lastLoginAt,
// so a correct repeat does not spawn a DB session per request while a
// wrong password still 401s. The getToken here is never used to mint a
// new session: on a subsequent request the existing session already
// holds its token; this config is only consulted at init.
// Everything from here through the credential evaluation runs UNDER one
// try/catch so a SINGLE rule governs the reservation we took above:
// "release the reserved keys unless the error is a genuine credential
// failure." That covers all three early-throw paths uniformly —
// (a) findWorkspace() returning null (a CONFIG error),
// (b) the SSO/MFA enforceBasicGate throwing (a BUSINESS error),
// (c) login()/verifyCredentials() throwing a non-credential business error
// (e.g. "email not verified") —
// none of which are password-guess signals, so none may burn a victim's
// limiter budget. Only a genuine credential failure (isCredentialsFailure)
// leaves the reservation in place, because the reservation IS its recorded
// failure. Without this, an attacker could exhaust a victim's per-email
// backstop with SSO/MFA-gated or misconfigured-workspace requests that never
// even run bcrypt. The reservation stays at the TOP (before any await) so the
// concurrency race the #83 fix closed is NOT re-introduced.
try {
const workspace = await deps.findWorkspace();
if (!workspace) {
throw new UnauthorizedException('No workspace is configured.');
}
// SSO/MFA pre-token gate (BLOCKER fix): replicate the AuthController.login
// gates BEFORE any token is issued on the Basic path. If the workspace
// enforces SSO, or the EE MFA module is bundled and this user/workspace
// requires MFA, this throws and we never mint a token. The Bearer path is
// intentionally NOT gated here (its JWT was already minted post-gate). This
// runs on BOTH init and subsequent Basic requests, but it must run before
// login()/verifyCredentials so an SSO/MFA user cannot authenticate at all.
// We do NOT count a gate rejection toward the brute-force limiter: it is
// not a password-guess signal (the catch below releases the reservation).
if (deps.enforceBasicGate) {
await deps.enforceBasicGate(workspace, {
email: basic.email,
password: basic.password,
});
}
// Fix 1 (init vs subsequent):
// - SESSION INIT (no mcp-session-id): full login() mints the user JWT
// (the one allowed session creation + audit event for this MCP
// session). The DocmostClient caches that token, so later tool calls
// never re-login.
// - SUBSEQUENT request (has mcp-session-id): we only need to re-validate
// the caller's credentials for anti-fixation. verifyCredentials() does
// the SAME lookup/password/email-verified/disabled checks as login()
// but mints NO session, writes NO audit row and updates NO lastLoginAt,
// so a correct repeat does not spawn a DB session per request while a
// wrong password still 401s. The getToken here is never used to mint a
// new session: on a subsequent request the existing session already
// holds its token; this config is only consulted at init.
if (deps.isSessionInit) {
const authToken = await deps.login(
{ email: basic.email, password: basic.password },
@@ -593,14 +659,19 @@ export async function resolveMcpSessionConfig(
workspace.id,
);
} catch (err) {
// Only count an actual CREDENTIALS failure (wrong email/password) toward
// the brute-force limiter. Business errors like "email not verified" are
// a 401/400 surface but are NOT a guessed-password signal, so they must
// not let an attacker burn a victim's limiter budget or mask brute-force.
if (isCredentialsFailure(err)) {
deps.limiter.recordFailure(ipKey);
deps.limiter.recordFailure(ipEmailKey);
deps.limiter.recordFailure(emailKey);
// The in-flight reservation taken above already counted this attempt, so
// an actual CREDENTIALS failure (wrong email/password) needs NO separate
// recordFailure — the reservation IS the recorded failure (avoiding the
// old double-count). But ANY other throw between the reservation and here
// — a missing-workspace config error, an SSO/MFA gate rejection, or a
// business error like "email not verified" — is a 401/400 surface, NOT a
// guessed-password signal, so it must not burn a victim's limiter budget:
// release the optimistic reservation (only the keys we actually reserved,
// which on this non-blocked path is all three) in that case.
if (!isCredentialsFailure(err)) {
deps.limiter.release(ipKey);
deps.limiter.release(ipEmailKey);
deps.limiter.release(emailKey);
}
const message =
err instanceof Error && err.message
@@ -616,8 +687,17 @@ export async function resolveMcpSessionConfig(
// email. The global email key is reset ONLY on a session-INIT login()
// success (above), which is a single deliberate authentication, not a
// high-frequency re-validation.
//
// Under the reserve model we DID optimistically increment emailKey up front
// (tryReserve), so a plain "leave it intact" would let every periodic tool
// call of the victim's own live session permanently grow their email bucket
// and throttle THEMSELVES. release() undoes exactly the one increment THIS
// call took (count -= 1), restoring the pre-request budget — it does NOT
// clear a parallel attacker's accumulated failures (that's reset()), so the
// brute-force backstop survives while the victim's success is budget-neutral.
deps.limiter.reset(ipKey);
deps.limiter.reset(ipEmailKey);
deps.limiter.release(emailKey);
return {
config: { apiUrl, getToken: async () => '' },
identity: `basic:${emailLc}`,

View File

@@ -0,0 +1,183 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as ts from 'typescript';
/**
* Coupling / drift-guard contract for the pre-token SSO/MFA gate (Gitea #91).
*
* There are TWO independent code paths that must run the SAME pre-token gate
* before any token is minted from a password:
*
* 1) AuthController.login (core/auth/auth.controller.ts) — the normal
* /api/auth/login path. Before issuing a token it runs:
* validateSsoEnforcement(workspace)
* -> lazy require('./../../ee/mfa/services/mfa.service')
* -> mfaService.checkMfaRequirements(...)
*
* 2) McpService.enforceBasicLoginGate (integrations/mcp/mcp.service.ts) —
* the /mcp HTTP-Basic path. It re-implements EXACTLY the same pre-token
* sequence so the Basic path is not an SSO/MFA bypass.
*
* These two implementations are physically separate (no shared helper — Option 1
* would extract one, but that refactor is deliberately skipped in this batch).
* If a future edit drops the SSO check or the MFA check from one side, the two
* paths silently DRIFT and the dropped side re-opens an SSO/MFA bypass. This
* test asserts BOTH method bodies still contain BOTH gate calls, so such a drift
* fails the build.
*
* Why a SOURCE-LEVEL (AST) contract test rather than live instances: neither
* AuthController nor McpService can be constructed — or even imported — under
* this jest config without mocking their heavy transitive graph (the
* @docmost/transactional React-email templates and the lib0/ESM collaboration
* chain that ts-jest's transformIgnorePatterns cannot load). This mirrors the
* existing AST-contract approach in
* core/auth/services/verify-user-credentials.contract.spec.ts: read the real
* source, extract the relevant method bodies, and assert each contains the
* required calls.
*/
// The exact symbols BOTH pre-token paths must share. Drop any of these from one
// side and that side stops enforcing SSO/MFA before minting a token.
const SSO_GATE = 'validateSsoEnforcement';
// The lazy EE-MFA require specifier — byte-for-byte identical in both files (a
// fork WITHOUT the EE module bundled behaves the same on both sides: no module,
// no MFA gate).
const MFA_REQUIRE = "require('./../../ee/mfa/services/mfa.service')";
// The MFA requirement check both paths call on the lazily-loaded service.
const MFA_CHECK = 'checkMfaRequirements';
/**
* Strip all comments from a chunk of TS source, leaving only real CODE tokens.
*
* This is load-bearing: the method bodies we inspect DOCUMENT the gate they run
* (e.g. "// 1) validateSsoEnforcement(workspace) — reject if ..."), so a naive
* substring match on the raw body text would still pass even if the actual call
* were deleted and only the comment survived. We tokenize with the TS scanner
* and re-emit only non-comment token text, so the assertions below see code, not
* prose. (A deleted/commented-out gate call therefore correctly fails the test.)
*/
function stripComments(text: string): string {
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
/* skipTrivia */ false,
ts.LanguageVariant.Standard,
text,
);
let out = '';
let kind = scanner.scan();
while (kind !== ts.SyntaxKind.EndOfFileToken) {
if (
kind !== ts.SyntaxKind.SingleLineCommentTrivia &&
kind !== ts.SyntaxKind.MultiLineCommentTrivia
) {
out += scanner.getTokenText();
} else {
// Preserve a separator so adjacent tokens around a comment don't merge.
out += ' ';
}
kind = scanner.scan();
}
return out;
}
/**
* Return the COMMENT-STRIPPED source text of a named method body (a class
* MethodDeclaration). Throws if the method is not found so a rename can never
* silently make this test vacuous.
*/
function methodBodyText(
source: string,
fileLabel: string,
methodName: string,
): string {
const sf = ts.createSourceFile(
fileLabel,
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
);
let found: string | null = null;
const visit = (node: ts.Node): void => {
if (
ts.isMethodDeclaration(node) &&
node.name &&
ts.isIdentifier(node.name) &&
node.name.text === methodName &&
node.body
) {
found = node.body.getText(sf);
return;
}
ts.forEachChild(node, visit);
};
visit(sf);
if (found === null) {
throw new Error(`method ${methodName} not found in ${fileLabel}`);
}
return stripComments(found);
}
describe('pre-token SSO/MFA gate coupling contract (Gitea #91)', () => {
const controllerPath = path.join(
__dirname,
'..',
'..',
'core',
'auth',
'auth.controller.ts',
);
const mcpServicePath = path.join(__dirname, 'mcp.service.ts');
const controllerSource = fs.readFileSync(controllerPath, 'utf8');
const mcpServiceSource = fs.readFileSync(mcpServicePath, 'utf8');
// The real login pre-token gate lives inline in AuthController.login.
const loginBody = methodBodyText(
controllerSource,
'auth.controller.ts',
'login',
);
// The /mcp Basic-path mirror lives in McpService.enforceBasicLoginGate.
const gateBody = methodBodyText(
mcpServiceSource,
'mcp.service.ts',
'enforceBasicLoginGate',
);
it('AuthController.login runs the full pre-token gate (SSO + MFA)', () => {
expect(loginBody).toContain(SSO_GATE);
expect(loginBody).toContain(MFA_REQUIRE);
expect(loginBody).toContain(MFA_CHECK);
});
it('McpService.enforceBasicLoginGate runs the full pre-token gate (SSO + MFA)', () => {
expect(gateBody).toContain(SSO_GATE);
expect(gateBody).toContain(MFA_REQUIRE);
expect(gateBody).toContain(MFA_CHECK);
});
it('both paths share EVERY gate symbol (no drift between the two)', () => {
// The drift guard: if a future edit drops a gate call from exactly one
// side, that side fails here while the other still passes — pinpointing the
// bypass. Both sides carrying the same set keeps them semantically coupled.
for (const symbol of [SSO_GATE, MFA_REQUIRE, MFA_CHECK]) {
const inLogin = loginBody.includes(symbol);
const inGate = gateBody.includes(symbol);
expect({ symbol, inLogin, inGate }).toEqual({
symbol,
inLogin: true,
inGate: true,
});
}
});
it('the EE-MFA require specifier is byte-for-byte identical on both sides', () => {
// A drift in the require PATH (not just its presence) would load a different
// module on one side — e.g. the controller gating on MFA while the Basic
// path silently requires a non-existent path and skips MFA. Pin the literal.
expect(loginBody).toContain(MFA_REQUIRE);
expect(gateBody).toContain(MFA_REQUIRE);
});
});

View File

@@ -209,6 +209,71 @@ describe('FailedLoginLimiter', () => {
expect(lim.isBlocked(k, 1000)).toBe(false);
});
describe('tryReserve (atomic check-and-increment, brute-force race fix)', () => {
it('allows exactly `threshold` reserves then blocks within the window', () => {
const lim = new FailedLoginLimiter(3, 1000);
const k = 'ip:1.2.3.4';
// threshold (3) successful reserves return true...
expect(lim.tryReserve(k, 0)).toBe(true);
expect(lim.tryReserve(k, 0)).toBe(true);
expect(lim.tryReserve(k, 0)).toBe(true);
// ...the next one is blocked (count is now at threshold).
expect(lim.tryReserve(k, 0)).toBe(false);
// A blocked reserve does NOT increment, so isBlocked stays true at threshold.
expect(lim.isBlocked(k, 0)).toBe(true);
});
it('reserves again after the window rolls over', () => {
const lim = new FailedLoginLimiter(2, 1000);
const k = 'ip:1.2.3.4';
expect(lim.tryReserve(k, 0)).toBe(true);
expect(lim.tryReserve(k, 0)).toBe(true);
expect(lim.tryReserve(k, 0)).toBe(false); // blocked in this window
// Past windowMs (>= is inclusive): a fresh bucket, so reserve succeeds again.
expect(lim.tryReserve(k, 1000)).toBe(true);
});
it('reset releases the reservation (reserve succeeds again after reset)', () => {
const lim = new FailedLoginLimiter(1, 1000);
const k = 'ip:1.2.3.4';
expect(lim.tryReserve(k, 0)).toBe(true);
expect(lim.tryReserve(k, 0)).toBe(false); // at threshold 1 -> blocked
lim.reset(k);
expect(lim.tryReserve(k, 0)).toBe(true); // reset cleared the bucket
});
it('release undoes one reservation without clearing accumulated failures', () => {
const lim = new FailedLoginLimiter(2, 1000);
const k = 'email:victim@example.com';
expect(lim.tryReserve(k, 0)).toBe(true); // count 1
expect(lim.tryReserve(k, 0)).toBe(true); // count 2 == threshold
expect(lim.isBlocked(k, 0)).toBe(true);
lim.release(k, 0); // undo exactly one -> count 1
expect(lim.isBlocked(k, 0)).toBe(false);
expect(lim.tryReserve(k, 0)).toBe(true); // count 2 again
expect(lim.tryReserve(k, 0)).toBe(false); // blocked: prior failures survived
});
it('RACE: threshold+1 SYNCHRONOUS reserves (no await) yield only `threshold` trues', () => {
// Simulate N concurrent /mcp requests hitting the check-and-increment with
// zero interleaved awaits — the very scenario the old isBlocked()-then-
// recordFailure() flow lost to (all saw count=0, all ran bcrypt). Because
// tryReserve folds check+increment into one synchronous step, only the
// first `threshold` callers win; the (threshold+1)-th is rejected up front.
const threshold = 5;
const lim = new FailedLoginLimiter(threshold, 60_000);
const k = 'email:victim@example.com';
const results: boolean[] = [];
for (let i = 0; i < threshold + 1; i++) {
results.push(lim.tryReserve(k, 0));
}
expect(results.filter((r) => r === true)).toHaveLength(threshold);
expect(results.filter((r) => r === false)).toHaveLength(1);
// The rejected one is the LAST: the first `threshold` all reserved.
expect(results[threshold]).toBe(false);
});
});
describe('sweep (expired-bucket eviction, injectable clock)', () => {
// sweep() drops buckets whose windowStart is older than windowMs so
// never-revisited keys cannot accumulate forever. It takes an injectable
@@ -406,6 +471,44 @@ describe('resolveMcpSessionConfig', () => {
).rejects.toThrow(/Too many failed MCP login attempts/);
});
it('concurrent Basic requests cannot bypass the limiter (atomic reserve before bcrypt)', async () => {
// The race the fix closes: fire threshold+ concurrent /mcp Basic logins for
// one email. Each login() (bcrypt-bearing) resolves only after all requests
// have entered the flow, so under the OLD check-then-act code every request
// would pass the read-only isBlocked() pre-check (count=0) and run bcrypt.
// With the atomic reserve, only `threshold` requests get past the synchronous
// tryReserve; the rest are throttled BEFORE login() is invoked.
const threshold = 5;
const limiter = new FailedLoginLimiter(threshold, 60_000);
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
const login = jest.fn().mockImplementation(async () => {
await gate; // hold every in-flight login open until we release the gate
throw new UnauthorizedException('Email or password does not match');
});
const total = threshold + 4;
const calls = Array.from({ length: total }, () =>
resolveMcpSessionConfig(
basicHeader('victim@example.com', 'wrong'),
makeDeps({ login, limiter, clientIp: '10.0.0.1' }),
).then(
() => 'resolved' as const,
(e) => (/Too many failed/.test(e.message) ? 'throttled' : 'badcreds'),
),
);
release();
const outcomes = await Promise.all(calls);
// Only `threshold` requests ever reached bcrypt/login(); the extras were
// rejected up front by the atomic reserve, never invoking login().
expect(login).toHaveBeenCalledTimes(threshold);
expect(outcomes.filter((o) => o === 'badcreds')).toHaveLength(threshold);
expect(outcomes.filter((o) => o === 'throttled')).toHaveLength(
total - threshold,
);
});
it('Bearer -> verifies as ACCESS and returns a getToken config', async () => {
const verifyAccessJwt = jest
.fn()
@@ -606,6 +709,62 @@ describe('resolveMcpSessionConfig', () => {
expect(login).not.toHaveBeenCalled();
});
it('SSO/MFA gate rejection does NOT burn the limiter budget (no token, no count)', async () => {
// Follow-up to #83: the brute-force keys are reserved at the TOP of the
// Basic flow (before any await) to close the concurrency race. But an
// enforceBasicGate rejection is a BUSINESS error (SSO enforced / MFA
// required), NOT a password-guess signal, so it must release the reservation
// — otherwise an attacker could exhaust an SSO/MFA victim's per-email
// backstop by firing gate-rejected requests with any password (no bcrypt
// even runs). Drive threshold+1 such requests and confirm none are blocked:
// every one reaches the gate (proving the email bucket never filled).
const threshold = 3;
const limiter = new FailedLoginLimiter(threshold, 60_000);
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const enforceBasicGate = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('This workspace has enforced SSO login.'),
);
for (let i = 0; i < threshold + 1; i++) {
await expect(
resolveMcpSessionConfig(
basicHeader('victim@example.com', `pw-${i}`),
makeDeps({ login, enforceBasicGate, limiter }),
),
).rejects.toThrow(/enforced SSO/);
}
// The gate fired on every attempt (the limiter never throttled before it),
// and login() never ran: the victim's budget was preserved.
expect(enforceBasicGate).toHaveBeenCalledTimes(threshold + 1);
expect(login).not.toHaveBeenCalled();
// The global per-email backstop is still fully under budget afterwards.
expect(limiter.isBlocked('email:victim@example.com')).toBe(false);
});
it('missing-workspace config error does NOT burn the limiter budget', async () => {
// findWorkspace() returning undefined is a CONFIG error, not a brute-force
// signal, so (like the gate) it must release the up-front reservation. With
// threshold 1, a counted attempt would throttle the very next one; instead
// every attempt reaches findWorkspace() and surfaces the same config 401.
const limiter = new FailedLoginLimiter(1, 60_000);
const findWorkspace = jest.fn().mockResolvedValue(undefined);
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const deps = () =>
makeDeps({ findWorkspace, login, limiter, clientIp: '10.0.0.42' });
await expect(
resolveMcpSessionConfig(basicHeader('user@example.com', 'pw'), deps()),
).rejects.toThrow(/No workspace is configured/);
// If the first attempt had counted, threshold 1 would now throttle. Instead
// the second attempt must reach findWorkspace() again (same config error).
await expect(
resolveMcpSessionConfig(basicHeader('user@example.com', 'pw'), deps()),
).rejects.toThrow(/No workspace is configured/);
expect(findWorkspace).toHaveBeenCalledTimes(2);
expect(login).not.toHaveBeenCalled();
expect(limiter.isBlocked('email:user@example.com')).toBe(false);
});
it('Bearer path is NOT subjected to the Basic SSO/MFA gate', async () => {
// The gate is only consulted on the Basic branch. A Bearer token (minted
// post-gate by the normal login) must not be blocked by it.

View File

@@ -57,6 +57,12 @@ interface McpHttpModule {
// failure return a clean 401 JSON instead of tearing a hijacked response.
const MCP_RESOLVED = Symbol('mcpResolvedConfig');
// One-time-per-process latch for the legacy-auth migration warning. The shared
// MCP token used to be sent as `Authorization: Bearer <MCP_TOKEN>`; it now lives
// in its own `X-MCP-Token` header. When we still see the old style we log ONCE
// (never the token value) so operators can migrate without log spam.
let warnedLegacyMcpAuth = false;
// TS with module:commonjs downlevels a literal import() to require(), which
// cannot load the ESM-only @docmost/mcp package. Indirect through Function so
// the real dynamic import() survives compilation and can load ESM from
@@ -354,6 +360,33 @@ export class McpService implements OnModuleDestroy {
? sharedTokenMatches(sharedToken, req.headers['x-mcp-token'])
: true;
// Back-compat hint (does NOT change the auth decision). When MCP_TOKEN is
// configured but the request carries no `X-MCP-Token` and instead sends the
// legacy `Authorization: Bearer <MCP_TOKEN>`, warn ONCE per process so the
// operator migrates the client. The token value is never logged; the bearer
// value is compared in constant time via sharedTokenMatches.
if (
sharedToken &&
!warnedLegacyMcpAuth &&
req.headers['x-mcp-token'] === undefined
) {
const auth = req.headers['authorization'];
const header = Array.isArray(auth) ? auth[0] : auth;
const bearer =
typeof header === 'string' && header.startsWith('Bearer ')
? header.slice('Bearer '.length)
: undefined;
if (bearer !== undefined && sharedTokenMatches(sharedToken, bearer)) {
warnedLegacyMcpAuth = true;
this.logger.warn(
'MCP shared token received via `Authorization: Bearer <MCP_TOKEN>` ' +
'(legacy). This is no longer accepted: send the shared token in the ' +
'`X-MCP-Token` header instead, and reserve `Authorization` for ' +
'per-user credentials. Reconfigure the MCP client to migrate.',
);
}
}
// Short-circuit checks (shared token, enablement) that do not need the auth
// resolution. Compute them up front so the response mapping is a single pure
// decision (mapAuthResultToResponse) that cannot leak the password/header.

View File

@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { StorageService } from './storage.service';
// Direct instantiation with a stub driver. The Test.createTestingModule form
// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only
// needs the service to construct.
describe('StorageService', () => {
let service: StorageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [StorageService],
}).compile();
service = module.get<StorageService>(StorageService);
beforeEach(() => {
service = new StorageService(
{} as any, // storageDriver
);
});
it('should be defined', () => {

View File

@@ -15,11 +15,24 @@ import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers';
// Trust X-Forwarded-For ONLY from real proxies on private/loopback nets by
// default, so a public-IP client cannot spoof its IP via X-Forwarded-For.
// TRUST_PROXY env overrides: 'true'/'false', a hop count (integer), or a
// CIDR/IP list string passed through to Fastify/proxy-addr.
function resolveTrustProxy(rawInput?: string): boolean | number | string {
const raw = rawInput?.trim();
if (raw == null || raw === '') return 'loopback, linklocal, uniquelocal';
if (raw === 'true') return true;
if (raw === 'false') return false;
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : raw;
}
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
routerOptions: {
maxParamLength: 1000,
ignoreTrailingSlash: true,

View File

@@ -5,6 +5,7 @@ import {
PageEvent,
PageMovedEvent,
TreeNodeSnapshot,
TreeUpdateSnapshot,
} from '../../database/listeners/page.listener';
const snapshot: TreeNodeSnapshot = {
@@ -230,3 +231,53 @@ describe('PageWsListener delete/move/restore handlers', () => {
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledWith('space-9');
});
});
describe('PageWsListener.onPageUpdated (rename / icon change)', () => {
let listener: PageWsListener;
let wsTree: { broadcastPageUpdated: jest.Mock };
const treeUpdate: TreeUpdateSnapshot = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
parentPageId: null,
title: 'Renamed',
icon: '🚀',
};
beforeEach(async () => {
wsTree = {
broadcastPageUpdated: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [PageWsListener, { provide: WsTreeService, useValue: wsTree }],
}).compile();
listener = module.get<PageWsListener>(PageWsListener);
});
it('WITH a title/icon `treeUpdate`: broadcasts updateOne with that snapshot', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await listener.onPageUpdated(event);
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledTimes(1);
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledWith(treeUpdate);
});
it('content-only save (NO `treeUpdate`): does NOT broadcast', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
};
await listener.onPageUpdated(event);
expect(wsTree.broadcastPageUpdated).not.toHaveBeenCalled();
});
});

View File

@@ -16,12 +16,14 @@ import { WsTreeService } from '../ws-tree.service';
* keeps it safe against the in-transaction visibility race (a synchronous
* SELECT here could run before the emitting `trx` committed).
*
* Scope of this PR: create, move, soft-delete/delete, restore.
* Scope: create, move, soft-delete/delete, restore, rename / icon change.
*
* Rename / icon change rides PAGE_UPDATED, which ALSO fires on every content
* save. The emit site (PageService.update) attaches a `treeUpdate` snapshot ONLY
* when the title or icon actually changed, so the handler below can gate strictly
* on that snapshot and stay silent on content-only saves.
*
* Deferred follow-ups (intentionally NOT handled here):
* - rename / icon change: would broadcast `updateOne` on PAGE_UPDATED, but
* PAGE_UPDATED also fires on every content save, so it needs a title/icon
* diff filter to avoid noise.
* - cross-space move (`movePageToSpace` / PAGE_MOVED_TO_SPACE): needs a
* deleteTreeNode in the old space + addTreeNode/refetch in the new space.
*/
@@ -68,6 +70,17 @@ export class PageWsListener {
await this.wsTree.broadcastPageMoved(event);
}
// Rename / icon change. PAGE_UPDATED also fires on every content save, so we
// only act when the emit site flagged a real title/icon change via
// `treeUpdate` — content-only saves carry no snapshot and are ignored here
// (no noisy re-broadcast). The broadcast is restriction-aware (emitTreeEvent),
// so a restricted page's title/icon can't leak to unauthorized sockets.
@OnEvent(EventName.PAGE_UPDATED)
async onPageUpdated(event: PageEvent): Promise<void> {
if (!event.treeUpdate) return;
await this.wsTree.broadcastPageUpdated(event.treeUpdate);
}
@OnEvent(EventName.PAGE_RESTORED)
async onPageRestored(event: PageEvent): Promise<void> {
// Restore can re-attach a whole subtree; a root refetch is simpler and more

View File

@@ -294,6 +294,42 @@ describe('WsService.emitTreeEvent', () => {
expect(noEmit).not.toHaveBeenCalled();
});
it('emitCommentEvent open space: broadcasts to the whole space room', async () => {
// emitCommentEvent forwards to the SAME unified restriction gate as
// emitTreeEvent, so the open-space fast path must behave identically.
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false);
const data = { operation: 'addComment' };
await service.emitCommentEvent('space-1', 'page-1', data);
expect(server.to).toHaveBeenCalledWith(getSpaceRoomName('space-1'));
expect(roomEmit).toHaveBeenCalledWith('message', data);
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
});
it('emitCommentEvent restricted page: only authorized users receive the event', async () => {
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
const okEmit = jest.fn();
const noEmit = jest.fn();
const sockets = [
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
];
server.in.mockReturnValue({
fetchSockets: jest.fn().mockResolvedValue(sockets),
});
const data = { operation: 'addComment' };
await service.emitCommentEvent('space-1', 'page-1', data);
expect(roomEmit).not.toHaveBeenCalled();
expect(okEmit).toHaveBeenCalledWith('message', data);
expect(noEmit).not.toHaveBeenCalled();
});
it('invalidateSpaceRestrictionCache deletes the cached restriction verdict for that space only', async () => {
await service.invalidateSpaceRestrictionCache('space-42');

View File

@@ -4,6 +4,7 @@ import { WsService } from './ws.service';
import {
PageMovedEvent,
TreeNodeSnapshot,
TreeUpdateSnapshot,
} from '../database/listeners/page.listener';
@Injectable()
@@ -43,6 +44,29 @@ export class WsTreeService {
});
}
// Rename / icon change: patch the in-tree node's title/icon on every client in
// the space. Routed through the restriction-aware `emitTreeEvent` so a
// restricted page's new title/icon never leaks to sockets that can't see it.
// The payload mirrors the client `UpdateEvent` shape consumed by
// `applyUpdateOne` (entity ["pages"], `id`, `payload.title` / `payload.icon`);
// only the fields that actually changed are sent (the snapshot omits the rest).
async broadcastPageUpdated(node: TreeUpdateSnapshot): Promise<void> {
await this.wsService.emitTreeEvent(node.spaceId, node.id, {
operation: 'updateOne',
spaceId: node.spaceId,
entity: ['pages'],
id: node.id,
payload: {
slugId: node.slugId,
parentPageId: node.parentPageId,
// Only include changed fields; an absent field leaves the client node
// untouched (applyUpdateOne checks `!== undefined` per field).
...(node.title !== undefined ? { title: node.title } : {}),
...(node.icon !== undefined ? { icon: node.icon } : {}),
},
});
}
async broadcastPageDeleted(page: TreeNodeSnapshot): Promise<void> {
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
operation: 'deleteTreeNode',

View File

@@ -23,57 +23,48 @@ export class WsService {
}
// Drop the cached spaceHasRestrictions verdict for a space. spaceHasRestrictions
// caches "does this space have ANY restricted page" for WS_CACHE_TTL_MS (30s),
// and emitTreeEvent / emitCommentEvent take a room-wide fast path when it is
// false. The FIRST time a space gains a restriction (or loses its last one)
// this cached verdict goes stale for up to the TTL, during which a title/icon-
// bearing tree payload could fan out to the whole room. This MUST be called by
// whatever code creates or removes a page's restriction (the page-access /
// page-permission grant/revoke/restrict path), passing the affected page's
// spaceId, so the next emit re-reads hasRestrictedPagesInSpace.
// caches "does this space have ANY restricted page" for WS_CACHE_TTL_MS, and
// emitTreeEvent / emitCommentEvent take a room-wide fast path when it is false.
// The FIRST time a space gains a restriction (or loses its last one) this cached
// verdict goes stale for up to the TTL, during which a title/icon-bearing tree
// payload could fan out to the whole room. This MUST be called by whatever code
// creates or removes a page's restriction (the page-access / page-permission
// grant/revoke/restrict path), passing the affected page's spaceId, so the next
// emit re-reads hasRestrictedPagesInSpace immediately instead of serving a
// stale cached value.
//
// NOTE: on this branch there is no permission-mutation site to call this from —
// the page-access/page-permission repo mutators (insertPageAccess /
// insertPagePermissions / deletePagePermission* / updatePagePermissionRole)
// have ZERO callers in apps/server/src; PageAccessService only validates access.
// This primitive is kept (and tested) so that flow, when it lands, has the
// correct hook to invalidate the cache.
// Because there is nothing to wire the invalidation to yet, the documented
// fallback was applied instead: WS_CACHE_TTL_MS was dropped from 30s to 3s (see
// ws.utils.ts) to bound the worst-case stale-leak window. This primitive is kept
// (and tested) so the restriction-mutation flow, when it lands, has the correct
// hook to invalidate the cache.
//
// TODO: the future restriction-mutation endpoint (restrict/grant/revoke page
// access) MUST call this with the affected page's spaceId.
// access) MUST call this with the affected page's spaceId; once wired, the TTL
// can be raised back to a higher value if desired.
async invalidateSpaceRestrictionCache(spaceId: string): Promise<void> {
await this.cacheManager.del(
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`,
);
}
// Comment broadcast. Thin wrapper over the single restriction-aware emit so
// comment and tree events share ONE restriction gate (see
// emitRestrictedAwareToSpace).
async emitCommentEvent(
spaceId: string,
pageId: string,
data: any,
): Promise<void> {
const room = getSpaceRoomName(spaceId);
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
return;
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!isRestricted) {
this.server.to(room).emit('message', data);
return;
}
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
await this.emitRestrictedAwareToSpace(spaceId, pageId, data);
}
// Server-origin tree broadcast. Mirrors emitCommentEvent exactly: respects
// per-space page restrictions (spaceHasRestrictions -> hasRestrictedAncestor
// -> broadcastToAuthorizedUsers), otherwise fans the event out to everyone in
// the space room.
// Server-origin tree broadcast. Thin wrapper over the single restriction-aware
// emit (see emitRestrictedAwareToSpace), identical routing to emitCommentEvent.
//
// The author is NOT excluded. The client receiver is idempotent (addTreeNode
// early-returns if the node id already exists; deleteTreeNode is a no-op if
@@ -83,6 +74,22 @@ export class WsService {
spaceId: string,
pageId: string,
data: any,
): Promise<void> {
await this.emitRestrictedAwareToSpace(spaceId, pageId, data);
}
// The single restriction-aware space emit. This is the ONLY place that decides
// authorized-vs-unauthorized routing for server-origin space-room events
// (comment + tree). Both emitCommentEvent and emitTreeEvent forward to it with
// their own `data`; the payload/room/event are otherwise identical.
//
// Routing: if the space has no restrictions at all (cached fast path), or the
// page has no restricted ancestor, fan `data` out to the whole space room;
// otherwise restrict the broadcast to the users authorized to see `pageId`.
private async emitRestrictedAwareToSpace(
spaceId: string,
pageId: string,
data: any,
): Promise<void> {
const room = getSpaceRoomName(spaceId);

View File

@@ -1,4 +1,16 @@
export const WS_CACHE_TTL_MS = 30_000;
// TTL for the cached spaceHasRestrictions verdict (see WsService). This cache is
// a read-side fast path: while it is `false`, emitTreeEvent/emitCommentEvent
// broadcast page-bearing payloads to the WHOLE space room. If a space gains its
// first restriction (or loses its last one), the verdict goes stale for up to
// this TTL, during which a title/icon-bearing payload could fan out to
// now-unauthorized sockets. The proper fix is to call
// WsService.invalidateSpaceRestrictionCache(spaceId) from the restriction
// mutation path — but on this branch no such mutation path exists yet (the
// page-permission repo mutators have zero callers), so there is nothing to wire
// the invalidation to. As the documented fallback, the TTL is kept short (3s)
// to bound the worst-case leak window until that endpoint lands and the
// invalidation can be wired directly.
export const WS_CACHE_TTL_MS = 3_000;
export const WS_SPACE_RESTRICTION_CACHE_PREFIX = 'ws:space-restrictions:';
export function getSpaceRoomName(spaceId: string): string {

View File

@@ -103,6 +103,21 @@ describe("html-embed codec — Node Buffer fallback branch", () => {
});
});
describe("html-embed codec — encode failure fallback", () => {
it("returns '' (not raw source) when encoding throws", () => {
// Force the catch branch: a btoa that throws (e.g. simulating the
// Latin1-boundary error). The codec must NOT return the raw source —
// raw markup in data-source would fail to decode and undermine inert
// storage — it drops to "" symmetrically with the decode side.
const src = "<script>alert(1)</script>";
// @ts-expect-error — stub btoa with a throwing impl for this test.
globalThis.btoa = () => {
throw new Error("boom");
};
expect(encodeHtmlEmbedSource(src)).toBe("");
});
});
describe("html-embed codec — decode of malformed input (browser branch)", () => {
it("returns '' for input atob rejects (catch branch)", () => {
// atob throws on characters outside the base64 alphabet; the codec catches

View File

@@ -41,7 +41,15 @@ export function encodeHtmlEmbedSource(source: string): string {
// 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.
// On an encoding error we drop to "" rather than returning the raw source.
// Returning raw markup here is NOT a safe fallback: the value is stored in
// the `data-source` attribute and read back through decodeHtmlEmbedSource,
// which base64-decodes it — raw (un-encoded) HTML would make atob/
// decodeURIComponent throw and decode to "" anyway, and an un-encoded value
// sitting in the attribute defeats the inert-storage guarantee (it could
// become an injection vector). So "" is the correct, decode-symmetric
// failure mode. In practice this is essentially unreachable: btoa runs on
// the output of encodeURIComponent, which is always Latin1-safe ASCII.
return "";
}
}