From e19849d980037d0cd3f42f3f35862e835214a742 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:07:53 +0300 Subject: [PATCH 01/30] fix(share-ai): fail-closed workspace limiter on Redis failure (#62) The per-workspace anonymous share-AI cost cap failed OPEN on a Redis error (return true => admit), so a Redis outage removed the cap entirely (unmetered billable anonymous calls). The feature is optional, so unavailability is harmless: fail CLOSED (return false => controller 429s) instead. Co-Authored-By: Claude Opus 4.8 --- .../ai-chat/public-share-workspace-limiter.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts b/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts index bf14d7d6..83a1079d 100644 --- a/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts +++ b/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts @@ -99,9 +99,11 @@ export class PublicShareWorkspaceLimiter { /** * Account one call for `key`. Returns true if it is within the cap (allowed), * false if the cap over the trailing window is exceeded (caller must 429). - * On a Redis failure we FAIL OPEN (return true): the cap is a cost backstop, - * not an auth boundary, and the access funnel + per-IP throttle still apply — - * we never want a transient Redis blip to take the assistant fully offline. + * On a Redis failure we FAIL CLOSED (return false): if Redis is down we cannot + * prove the workspace is under its cap, so we DENY rather than admit an + * unmetered, billable anonymous call. The feature is optional, so the + * temporary denial is harmless. (Operators wanting a tighter steady-state cap + * can lower the default via SHARE_AI_WORKSPACE_MAX_PER_HOUR, e.g. =100.) */ async tryConsume(key: string): Promise { const t = this.now(); @@ -120,15 +122,14 @@ export class PublicShareWorkspaceLimiter { ); return admitted === 1; } catch (err) { - // Fail OPEN: this per-workspace cap is a COST backstop, not an access - // control — the funnel access gates and the per-IP throttle still apply. - // A transient Redis failure must not take the public-share assistant - // fully offline, so we admit the call rather than 500 the request. + // FAIL CLOSED: if Redis is down we cannot prove the workspace is under its + // cap, so DENY (controller 429s) rather than admit an unmetered, billable + // anonymous call. The feature is optional, so denial is harmless. this.logger.error( - `share-ai workspace limiter Redis failure for key "${key}"; failing open`, + `share-ai workspace limiter Redis failure for key "${key}"; failing closed`, err as Error, ); - return true; + return false; } } } -- 2.49.1 From 2b4ec0bfccd1a3b089267ee102432addecc77c14 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:07:53 +0300 Subject: [PATCH 02/30] fix(share-ai): reject non-text message parts to close size-cap bypass (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_SHARE_MESSAGE_CHARS only counted text parts, so a forged non-text part (tool-result/file/data) bypassed the cap and bloated the model input (token-DoS); convertToModelMessages would also expand a forged tool-result. The anonymous path runs no tools, so a client non-text part is never legitimate — reject any message with a non-text part (isTextUIPart) before the size check. Co-Authored-By: Claude Opus 4.8 --- .../src/core/ai-chat/public-share-chat.controller.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/core/ai-chat/public-share-chat.controller.ts b/apps/server/src/core/ai-chat/public-share-chat.controller.ts index 4c8d0a39..726b6487 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.controller.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.controller.ts @@ -31,7 +31,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. @@ -281,6 +281,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); } -- 2.49.1 From d79f709742b7085d70acb803811193706845834f Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:07:53 +0300 Subject: [PATCH 03/30] fix(share): surface the real error on the share AI widget (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget hardcoded a generic 'Something went wrong' body and ignored error.message, violating AGENTS.md. Render describeChatError(error.message, t) — the same helper the internal chat uses — so a reader sees the real 402/429/503 cause instead of a bare 'try again'. Co-Authored-By: Claude Opus 4.8 --- .../client/src/features/share/components/share-ai-widget.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx index 5212e2c4..382eb60e 100644 --- a/apps/client/src/features/share/components/share-ai-widget.tsx +++ b/apps/client/src/features/share/components/share-ai-widget.tsx @@ -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. */ @@ -174,7 +175,9 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) { 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)} )} -- 2.49.1 From 5344a9bddeea804e535353808f546248916664e8 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:07:53 +0300 Subject: [PATCH 04/30] fix(auth): handle null-password (SSO/LDAP-only) accounts without bcrypt throw (#70) A user with password=NULL passed the missing/disabled guard and reached comparePasswordHash(pw, null), which native bcrypt rejects -> 500 on /api/auth/login and, on /mcp, a leaky 401 that the brute-force limiter ignored (enumeration oracle + limiter evasion). Treat a null/empty password like a missing user in verifyUserCredentials (dummy compare for timing parity + unified CREDENTIALS_MISMATCH_MESSAGE) and reject early in changePassword before bcrypt. Contract spec asserts the null-password guard. Co-Authored-By: Claude Opus 4.8 --- .../src/core/auth/services/auth.service.ts | 26 +++++++++++++++---- .../verify-user-credentials.contract.spec.ts | 24 +++++++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 1c952f6e..986084b9 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -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, diff --git a/apps/server/src/core/auth/services/verify-user-credentials.contract.spec.ts b/apps/server/src/core/auth/services/verify-user-credentials.contract.spec.ts index e7b37e08..4b84ac50 100644 --- a/apps/server/src/core/auth/services/verify-user-credentials.contract.spec.ts +++ b/apps/server/src/core/auth/services/verify-user-credentials.contract.spec.ts @@ -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. -- 2.49.1 From 05a7a4001f0ad4f38b9f4fad1c2feeefe2caf1b0 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:08:34 +0300 Subject: [PATCH 05/30] fix(share-ai): cap per-request output + unify provider errors (#60, #95) #60: streamText had no maxOutputTokens, so one anonymous request could run up the provider bill. Add maxOutputTokens (env SHARE_AI_MAX_OUTPUT_TOKENS, default 512) via resolveShareAiMaxOutputTokens(). #95: the anonymous path hand-built error strings, diverging from the unified describeProviderError format used on the authenticated path; both onError blocks now call describeProviderError so a share reader sees 402/429/503 causes in the same form (and the stack is still logged). Both changes are in this one file and share hunks, hence one commit. Co-Authored-By: Claude Opus 4.8 --- .../core/ai-chat/public-share-chat.service.ts | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/server/src/core/ai-chat/public-share-chat.service.ts b/apps/server/src/core/ai-chat/public-share-chat.service.ts index d385c4f0..310fd0cf 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.service.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.service.ts @@ -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 @@ -63,6 +64,24 @@ export interface PublicShareChatStreamArgs { export const MAX_SHARE_MESSAGES = 30; export const MAX_SHARE_MESSAGE_CHARS = 8000; +/** + * Default per-request output cap for the anonymous share assistant. Bounds the + * tokens a single anonymous request can generate; worst case = steps x this. + */ +export const SHARE_AI_MAX_OUTPUT_TOKENS = 512; + +/** + * Read the per-request output cap from the environment (overridable seam), + * falling back to the sane default. A non-positive / unparseable value uses the + * default. Mirrors resolveShareAiWorkspaceMax(). + */ +export function resolveShareAiMaxOutputTokens(): number { + const raw = Number(process.env.SHARE_AI_MAX_OUTPUT_TOKENS); + return Number.isFinite(raw) && raw > 0 + ? Math.floor(raw) + : SHARE_AI_MAX_OUTPUT_TOKENS; +} + /** * Keep ONLY genuine conversation turns from the client-held transcript. The * payload is fully attacker-controlled; a forged `system` turn could try to @@ -204,16 +223,15 @@ export class PublicShareChatService { tools, // Bound the agent loop for anonymous callers. stopWhen: stepCountIs(5), + // Bounds per-request output so one anonymous request can't run up the + // provider bill; worst case = steps x this. + 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}`, @@ -228,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'); }, }); -- 2.49.1 From 212bcea4d7d470d6a41169609224df9bd45068fa Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:08:34 +0300 Subject: [PATCH 06/30] fix(page): movePage cycle guard + no phantom PAGE_MOVED (#67, #64) #67: movePage didn't check the destination wasn't the page itself or inside its own subtree, so MCP/REST/agent/fast-drag could persist+broadcast a cycle. Reject before the update (self-parent, or moved page among the destination parent's ancestors via getPageBreadCrumbs). #64: movePage emitted PAGE_MOVED from a stale pre-read even when the row didn't change / was concurrently deleted (phantom move). Gate the emit on updateResult.numUpdatedRows !== 0n. Both are movePage hardening in one method. Co-Authored-By: Claude Opus 4.8 --- .../src/core/page/services/page.service.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index dc17e188..bc6e5105 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -964,9 +964,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, @@ -982,6 +1000,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 -- 2.49.1 From 099d31f594e5981388970bbc200686950492e5e6 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:17:37 +0300 Subject: [PATCH 07/30] fix(ai): sandwich SAFETY_FRAMEWORK around the role persona (#68) A custom AI-role's text preceded the only SAFETY_FRAMEWORK block and replaced the persona, so a jailbreak in the role text sat before the safety rules. buildSystemPrompt now emits SAFETY both before AND after the persona, with the role/persona delimited as lower-trust (); the default persona is sandwiched too. Context (currently-viewing-page) preserved. Co-Authored-By: Claude Opus 4.8 --- .../src/core/ai-chat/ai-chat.prompt.spec.ts | 25 +++++++++++++++++++ .../server/src/core/ai-chat/ai-chat.prompt.ts | 25 ++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts b/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts index bc49e038..3a2b429a 100644 --- a/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts @@ -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(''); + 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, diff --git a/apps/server/src/core/ai-chat/ai-chat.prompt.ts b/apps/server/src/core/ai-chat/ai-chat.prompt.ts index 04e55777..8fe50ee3 100644 --- a/apps/server/src/core/ai-chat/ai-chat.prompt.ts +++ b/apps/server/src/core/ai-chat/ai-chat.prompt.ts @@ -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 + * 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 + // tags noting it only shapes tone/voice. Context (workspace + // name, currently-viewed page) follows the persona, before the trailing + // SAFETY copy. + return [ + SAFETY_FRAMEWORK, + '', + base, + '', + context, + SAFETY_FRAMEWORK, + ].join('\n'); } -- 2.49.1 From afbc6b220200bf5c5936a4f9638b4438193e5c25 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:17:37 +0300 Subject: [PATCH 08/30] docs(html-embed): correct the encode-catch comment (returns '', not raw) (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encode catch comment promised 'fall back to raw' but the code returns ''; returning raw source wouldn't help anyway (un-encoded markup can't be atob-decoded downstream, so decode would yield '' regardless), and a raw value in data-source breaks the inert-storage guarantee. '' is the correct decode-symmetric failure — fix the misleading comment to say so. Adds a codec test for the encode-throw path. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/html-embed/html-embed-codec.spec.ts | 15 +++++++++++++++ .../editor-ext/src/lib/html-embed/html-embed.ts | 10 +++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts b/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts index fbee45d2..917f1d51 100644 --- a/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts +++ b/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts @@ -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 = ""; + // @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 diff --git a/packages/editor-ext/src/lib/html-embed/html-embed.ts b/packages/editor-ext/src/lib/html-embed/html-embed.ts index d3d004a1..2a47eb43 100644 --- a/packages/editor-ext/src/lib/html-embed/html-embed.ts +++ b/packages/editor-ext/src/lib/html-embed/html-embed.ts @@ -39,7 +39,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 ""; } } -- 2.49.1 From ff342ca7057313056e4b93aeb48d687f9450e328 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:17:37 +0300 Subject: [PATCH 09/30] cleanup(page-embed): remove dead isPageEmbedCycle/isPageEmbedTooDeep (#71) After a merge decideEmbedState became the canonical guard and inlines the cycle/too-deep logic, leaving these predicates called only by their own tests. Remove them (and their test blocks); keep PAGE_EMBED_MAX_DEPTH (used by decideEmbedState). Production behavior stays covered by decide-embed-state.test.ts. Co-Authored-By: Claude Opus 4.8 --- .../page-embed-ancestry-context.test.tsx | 58 ------------------- .../page-embed-ancestry-context.tsx | 23 -------- 2 files changed, 81 deletions(-) diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx index 867922d2..42cdffb4 100644 --- a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx +++ b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx @@ -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); - }); -}); diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx index cdd7f109..c989ee21 100644 --- a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx +++ b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx @@ -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; -} -- 2.49.1 From e52f069fc63423b76033a622cc57a52ca4473fb0 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:17:37 +0300 Subject: [PATCH 10/30] fix(ws): resync the sidebar tree on socket reconnect (#66) WS events missed during a disconnect (wifi blip, sleep) were lost, so the sidebar tree silently diverged until a manual reload. On RECONNECT (not the first connect) invalidate the root-sidebar-pages + sidebar-pages queries so the tree refetches through the authorized API and re-converges. Co-Authored-By: Claude Opus 4.8 --- apps/client/src/features/user/user-provider.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx index 4e7c726c..5720f29e 100644 --- a/apps/client/src/features/user/user-provider.tsx +++ b/apps/client/src/features/user/user-provider.tsx @@ -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 () => { -- 2.49.1 From 52159135333d59d99e4fc59c4f51bd95454f468f Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:17:37 +0300 Subject: [PATCH 11/30] fix(security): env-configurable trustProxy with a safe default (#61) trustProxy was unconditionally true, so req.ip came from a client-forgeable X-Forwarded-For and the per-IP throttles (share-AI, /mcp brute-force) were spoofable. Make it env-configurable (TRUST_PROXY) with a safe default that trusts XFF only from loopback/private proxies, documented in .env.example. NOTE: this changes the default from trust-all; deployments whose proxy is on a public IP must set TRUST_PROXY (caveat documented). Co-Authored-By: Claude Opus 4.8 --- .env.example | 25 +++++++++++++++++++++---- apps/server/src/main.ts | 15 ++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index a19fd2d7..4cebe788 100644 --- a/.env.example +++ b/.env.example @@ -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) +# - number of trusted proxy hops in front of the app +# - comma-separated CIDR/IP list of trusted proxies, e.g. +# `127.0.0.1, 10.0.0.0/8` +# TRUST_PROXY= # minimum of 32 characters. Generate one with: openssl rand -hex 32 APP_SECRET=REPLACE_WITH_LONG_SECRET diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1c2ccebf..3588f045 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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( AppModule, new FastifyAdapter({ - trustProxy: true, + trustProxy: resolveTrustProxy(process.env.TRUST_PROXY), routerOptions: { maxParamLength: 1000, ignoreTrailingSlash: true, -- 2.49.1 From c0d312d8f5af92c5d60ed67aad2be2b2d81a8bb7 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:17:37 +0300 Subject: [PATCH 12/30] harden(transclusion): depth-cap the ProseMirror collectors (#55) collectPageEmbedsFromPmJson (and the sibling collectors/remap) recursed with no guard, so a pathological/cyclic non-JSON input could stack-overflow (RangeError). Add a depth cap (1000, far above any real doc nesting) so such input is handled gracefully. Normal documents are unaffected. Updates a stale test that asserted the old throwing behavior. Co-Authored-By: Claude Opus 4.8 --- .../transclusion/spec/page-embed.util.spec.ts | 17 ++++--- .../transclusion-prosemirror.util.spec.ts | 46 +++++++++++++++++++ .../utils/transclusion-prosemirror.util.ts | 43 ++++++++++++----- 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts b/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts index 9219154c..fd3d9986 100644 --- a/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts +++ b/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts @@ -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; + expect(() => { + got = collectPageEmbedsFromPmJson(node); + }).not.toThrow(); + expect(got!).toEqual([]); }); }); diff --git a/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts b/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts index 1661a090..705a4a83 100644 --- a/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts +++ b/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts index d8cdf3bf..6431b71f 100644 --- a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts +++ b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts @@ -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; @@ -27,8 +34,11 @@ export function collectTransclusionsFromPmJson( const byId = new Map(); - 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 === TRANSCLUSION_TYPE) { const id = node.attrs?.id; @@ -42,11 +52,11 @@ export function collectTransclusionsFromPmJson( } 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 Array.from(byId.values()); } @@ -65,8 +75,11 @@ export function collectReferencesFromPmJson( const seen = new Set(); const out: TransclusionReferenceSnapshot[] = []; - 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 === REFERENCE_TYPE) { const sourcePageId = node.attrs?.sourcePageId; @@ -91,11 +104,11 @@ export function collectReferencesFromPmJson( 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 out; } @@ -133,8 +146,11 @@ export function remapPageEmbedSourceIds( doc: T, idMap: Map, ): 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 +165,11 @@ export function remapPageEmbedSourceIds( 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; } @@ -171,8 +187,11 @@ export function collectPageEmbedsFromPmJson( const seen = new Set(); const out: PageEmbedSnapshot[] = []; - const visit = (node: any): void => { + const visit = (node: any, depth: number): void => { if (!node || typeof node !== 'object') return; + // Stop before the stack can overflow on a pathological/cyclic non-JSON + // input; far above any real doc nesting so genuine docs are unaffected. + if (depth > MAX_PM_WALK_DEPTH) return; if (node.type === PAGE_EMBED_TYPE) { const sourcePageId = node.attrs?.sourcePageId; @@ -188,10 +207,10 @@ export function collectPageEmbedsFromPmJson( 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 out; } -- 2.49.1 From 342bb47b302530e1264f1eece552004ec212aa51 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:28:58 +0300 Subject: [PATCH 13/30] fix(ai-roles): validate chatModel + guard driver-enum drift (#52) chatModel was a free string accepted with empty/garbage values, failing only at runtime as a provider 503; tighten it (trim + non-empty + max 200). Driver was already @IsIn(AI_DRIVERS). Collapse the client driver list to one AI_DRIVER_VALUES source and add a contract test that reads the server AI_DRIVERS and fails on client/server drift. Co-Authored-By: Claude Opus 4.8 --- .../ai-agent-role-form.drivers.test.ts | 53 ++++++++++++ .../components/ai-agent-role-form.tsx | 26 ++++-- .../ai-chat/roles/dto/agent-role.dto.spec.ts | 81 +++++++++++++++++++ .../core/ai-chat/roles/dto/agent-role.dto.ts | 11 ++- 4 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.drivers.test.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.drivers.test.ts b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.drivers.test.ts new file mode 100644 index 00000000..9ff4f30b --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.drivers.test.ts @@ -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); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx index f3e7a930..afbb4f59 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx @@ -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 = { + 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(), }); diff --git a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts new file mode 100644 index 00000000..bb063dc1 --- /dev/null +++ b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts index 02ff840e..9aff4c36 100644 --- a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts +++ b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts @@ -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; } -- 2.49.1 From 40f68e95fb7209b6b1e05501281ffe388edc01fe Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:28:58 +0300 Subject: [PATCH 14/30] fix(ws): shrink restriction-cache TTL to bound the leak window (#53) invalidateSpaceRestrictionCache has no callers because no restriction-mutation path exists yet (PagePermissionRepo mutators are uncalled; there is no restrict/grant/revoke endpoint), so the 30s spaceHasRestrictions cache could serve a stale 'no restrictions' verdict. Until a mutation endpoint exists to wire the direct invalidation, lower the TTL (30s -> 3s) to bound the worst-case window; the invalidation primitive is kept for that future endpoint. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/ws/ws.service.ts | 27 ++++++++++++++++----------- apps/server/src/ws/ws.utils.ts | 14 +++++++++++++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 75cf598e..5c8303eb 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -23,24 +23,29 @@ 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 { await this.cacheManager.del( `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`, diff --git a/apps/server/src/ws/ws.utils.ts b/apps/server/src/ws/ws.utils.ts index a1eb1569..ebadfd3a 100644 --- a/apps/server/src/ws/ws.utils.ts +++ b/apps/server/src/ws/ws.utils.ts @@ -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 { -- 2.49.1 From 317fdb94240f218f772a67a72f87db99094831bd Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:28:58 +0300 Subject: [PATCH 15/30] test(public-share): cover getSharePage positive + soft-deleted branches (#85) The anonymous share page-fetch tool's positive branch (sanitize via updatePublicAttachments then jsonToMarkdown before returning to the model) was untested, so a dropped/reordered sanitizer would ship a comment-mark/raw- attachment leak with green tests. Add a positive-branch test pinning the sanitizer call + that markdown derives from sanitized content, and a soft-deleted test asserting a generic error with no content fetch. Co-Authored-By: Claude Opus 4.8 --- .../public-share-chat-tools.service.spec.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts index dd46b527..1c3f6f8d 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts @@ -129,4 +129,112 @@ describe('PublicShareChatToolsService.forShare', () => { expect(shareService.getShareForPage).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, pageRepo, pagePermissionRepo } = makeService({ + // getShareForPage resolves to THIS share (id matches the forShare scope). + getShareForPage: jest.fn().mockResolvedValue({ id: 'SHARE-A' }), + findById: jest.fn().mockResolvedValue(page), + }); + // Page has no restricted ancestor => passes the restriction gate. + pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false); + // 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 }; + + // Membership + liveness + restriction checks were all consulted. + expect(shareService.getShareForPage).toHaveBeenCalledWith( + 'page-1', + 'ws-1', + ); + expect(pageRepo.findById).toHaveBeenCalledWith('page-1', { + includeContent: true, + }); + expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith( + 'page-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 soft-deleted page', () => { + it('findById returns a soft-deleted page (deletedAt set) => generic error, NO content fetch (updatePublicAttachments not called, nothing leaked)', async () => { + const deletedPage = { + id: 'page-1', + title: 'Deleted Page', + deletedAt: new Date(), + content: { type: 'doc', content: [] }, + }; + const { svc, shareService, pagePermissionRepo } = makeService({ + getShareForPage: jest.fn().mockResolvedValue({ id: 'SHARE-A' }), + findById: jest.fn().mockResolvedValue(deletedPage), + }); + + const tools = svc.forShare('SHARE-A', 'ws-1'); + // Same generic message as an out-of-share page (no info leak). + await expect( + (tools.getSharePage as unknown as ToolExec).execute({ + pageId: 'page-1', + }), + ).rejects.toThrow('That page is not part of this published share.'); + + // Short-circuits before the restriction gate AND before the sanitizer: + // no content is ever fetched/returned for a soft-deleted page. + expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled(); + expect(shareService.updatePublicAttachments).not.toHaveBeenCalled(); + }); + }); }); -- 2.49.1 From b597841cf08cf3feba296ff8683fdab1d197ff72 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:28:58 +0300 Subject: [PATCH 16/30] test(ai-roles): cover update() happy-path return shape (#88) The concurrent-soft-delete guard was already covered; add the missing assertion that update() returns toView(updated) from the post-update re-fetch (full AgentRoleView shape, distinct second findById row), so a regression returning the stale pre-update view or leaking columns is caught. Co-Authored-By: Claude Opus 4.8 --- .../roles/ai-agent-roles.service.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts index e86cbbf5..37dd387d 100644 --- a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts +++ b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts @@ -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() }); -- 2.49.1 From c78177c28b161335258a38f8bb5bcab9e1e88c14 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:29:52 +0300 Subject: [PATCH 17/30] test(page): exercise the real getSidebarPagesTree via an extracted pure helper (#75) sidebar-pages-tree.spec tested a LOCAL COPY of the tree-shaping (so a regression in the real getSidebarPagesTree was invisible) and justified it with a false jest-config claim (the ^src mapping exists). Extract the pure shaping into shapeSidebarPagesTree(); the service now calls it and the spec imports the REAL helper. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/core/page/services/page.service.ts | 39 ++------ .../page/services/sidebar-pages-tree.spec.ts | 88 +++++-------------- .../page/services/sidebar-pages-tree.util.ts | 73 +++++++++++++++ 3 files changed, 101 insertions(+), 99 deletions(-) create mode 100644 apps/server/src/core/page/services/sidebar-pages-tree.util.ts diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index bc6e5105..b6de92a1 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -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'; @@ -1425,37 +1426,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(); - 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; } } diff --git a/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts b/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts index 0c3a43c9..4f69af6e 100644 --- a/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts +++ b/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts @@ -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; - }, -) { - const parentIds = new Set(); - 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, }); diff --git a/apps/server/src/core/page/services/sidebar-pages-tree.util.ts b/apps/server/src/core/page/services/sidebar-pages-tree.util.ts new file mode 100644 index 00000000..33acbcef --- /dev/null +++ b/apps/server/src/core/page/services/sidebar-pages-tree.util.ts @@ -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; + }, +): ShapedSidebarPage[] { + const parentIds = new Set(); + 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; +} -- 2.49.1 From 6928817ceeb20deb49b8bf14d989714eecef445d Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:29:52 +0300 Subject: [PATCH 18/30] fix(ws): broadcast realtime page rename/icon change (#72) handleMessage became a no-op and PageWsListener intentionally ignored PAGE_UPDATED, so a rename/icon change (client operation:updateOne) was no longer rebroadcast -> other clients saw stale title/icon in the sidebar+breadcrumbs until a reload (create/duplicate/restore were covered; updateOne regressed). Add a server-authoritative onPageUpdated handler: PageService.update detects a real title/icon change (DTO carries the field AND value differs; no-op/content- only saves excluded) and attaches a treeUpdate snapshot to PAGE_UPDATED; the listener broadcasts a tree updateOne via the restriction-aware emitTreeEvent (so a restricted page's title never leaks). Content-only saves attach nothing. Co-Authored-By: Claude Opus 4.8 --- .../src/core/page/services/page.service.ts | 27 ++++++++++ .../src/database/listeners/page.listener.ts | 24 +++++++++ .../src/database/repos/page/page.repo.ts | 19 ++++++- .../src/ws/listeners/page-ws.listener.spec.ts | 51 +++++++++++++++++++ .../src/ws/listeners/page-ws.listener.ts | 21 ++++++-- apps/server/src/ws/ws-tree.service.ts | 24 +++++++++ 6 files changed, 161 insertions(+), 5 deletions(-) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index b6de92a1..93a13bfe 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -270,6 +270,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, @@ -287,6 +298,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 diff --git a/apps/server/src/database/listeners/page.listener.ts b/apps/server/src/database/listeners/page.listener.ts index 2b67b364..3a779aa3 100644 --- a/apps/server/src/database/listeners/page.listener.ts +++ b/apps/server/src/database/listeners/page.listener.ts @@ -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; } /** diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 504d01b3..51c6132b 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -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; diff --git a/apps/server/src/ws/listeners/page-ws.listener.spec.ts b/apps/server/src/ws/listeners/page-ws.listener.spec.ts index 3282d318..cb8d8d90 100644 --- a/apps/server/src/ws/listeners/page-ws.listener.spec.ts +++ b/apps/server/src/ws/listeners/page-ws.listener.spec.ts @@ -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); + }); + + 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(); + }); +}); diff --git a/apps/server/src/ws/listeners/page-ws.listener.ts b/apps/server/src/ws/listeners/page-ws.listener.ts index 100d0f85..3de6da35 100644 --- a/apps/server/src/ws/listeners/page-ws.listener.ts +++ b/apps/server/src/ws/listeners/page-ws.listener.ts @@ -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 { + if (!event.treeUpdate) return; + await this.wsTree.broadcastPageUpdated(event.treeUpdate); + } + @OnEvent(EventName.PAGE_RESTORED) async onPageRestored(event: PageEvent): Promise { // Restore can re-attach a whole subtree; a root refetch is simpler and more diff --git a/apps/server/src/ws/ws-tree.service.ts b/apps/server/src/ws/ws-tree.service.ts index 5fa8ef5d..5e052485 100644 --- a/apps/server/src/ws/ws-tree.service.ts +++ b/apps/server/src/ws/ws-tree.service.ts @@ -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 { + 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 { await this.wsService.emitTreeEvent(page.spaceId, page.id, { operation: 'deleteTreeNode', -- 2.49.1 From a11c87c4dc9ff387c47a0ef5104358033227ea9c Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:40:40 +0300 Subject: [PATCH 19/30] docs(page-templates): document that lookupTemplate is flat (no server recursion) (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assessment of the page-embed depth/cycle cap: the server /pages/template/lookup returns FLAT single-level content and does NOT recurse into embedded pages — the recursive expansion + the PAGE_EMBED_MAX_DEPTH cap are entirely client render concerns, and a scripted client is already bounded by the per-user throttle (30/min) + the ArrayMaxSize(50) per-call cap. So no server-side depth guard is needed; documented at lookupTemplate so future readers don't add a redundant one or assume server recursion exists. Co-Authored-By: Claude Opus 4.8 --- .../src/core/page/transclusion/transclusion.service.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index 76bb8cfb..4dc0de9c 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -395,6 +395,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[], -- 2.49.1 From d45ca00bccb7f52d9275b405365b8e34885801d0 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:40:40 +0300 Subject: [PATCH 20/30] docs(mcp): document the MCP_TOKEN header breaking change + one-time warning (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared MCP_TOKEN guard moved from 'Authorization: Bearer ' to the X-MCP-Token header (Authorization is now per-user Basic/Bearer), silently breaking existing /mcp clients. Document it as a Breaking Change in CHANGELOG (reconfigure to X-MCP-Token). Add a once-per-process migration warning: when MCP_TOKEN is set, no x-mcp-token is present, and Authorization carries the old 'Bearer ', log a hint to migrate — without changing the auth decision (still rejected) or logging the token value. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 +++++++ .../src/integrations/mcp/mcp.service.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29058510..8bdec8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes + +- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard + no longer reads `Authorization: Bearer `; it now reads only the + `X-MCP-Token` header. Existing MCP clients (e.g. Claude Desktop) configured + with `Authorization: Bearer ` must be reconfigured to send + `X-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 diff --git a/apps/server/src/integrations/mcp/mcp.service.ts b/apps/server/src/integrations/mcp/mcp.service.ts index cb746b90..637f3e56 100644 --- a/apps/server/src/integrations/mcp/mcp.service.ts +++ b/apps/server/src/integrations/mcp/mcp.service.ts @@ -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 `; 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 `, 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 ` ' + + '(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. -- 2.49.1 From 8016b1c54091e0a5ad8e6862523f0bf838b862f5 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:40:40 +0300 Subject: [PATCH 21/30] docs: sync AGENTS.md + README with shipped features (#89) Fix doc drift: /mcp per-user auth + X-MCP-Token (was 'service account + optional MCP_TOKEN'); CI builds :develop on push to develop (was main); add page_template_references to the fork-tables list + is_template schema; mark arbitrary HTML embed as shipped (was in-progress plan); remove the dead page-templates-plan.md README link and move Page templates to implemented. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 8 ++++---- README.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed200604..b17edd89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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, arbitrary HTML embed). `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, streaming dictation). Arbitrary HTML embed has **shipped** (admin-gated by the `htmlEmbed` workspace toggle in Workspace settings) and is no longer a planning doc. `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. diff --git a/README.md b/README.md index 578790f0..0a96253b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ community feature, with no enterprise license. Open it from the page header; the - ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs. - ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP). - ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it. +- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). ### In progress @@ -108,7 +109,6 @@ community feature, with no enterprise license. Open it from the page header; the ### Planned -- 🔭 **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). See [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Viewer comments** — let read-only viewers leave comments. - 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Password-protected pages** — protect individual pages / shares with a password. -- 2.49.1 From c486750b2ab41cd171a3f4b60be619e35860bda3 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:40:40 +0300 Subject: [PATCH 22/30] test-infra: re-enable 16 disabled server suites (jest DI + lib0 ESM) (#56) 16 suites were disabled via testPathIgnorePatterns due to two root causes: lib0 ESM not transformed (the @hocuspocus/server -> lib0/decoding.js chain) and stock 'should be defined' specs built via Test.createTestingModule without providers. Add lib0 to transformIgnorePatterns; convert the 14 DI placeholders to direct new X(...) instantiation with stub deps (keeping a real construct smoke test); re-enable the suites. Also updates the public-share limiter test to assert the fail-closed behavior from #62 (surfaced now that the suite runs). Full server suite: 67 passed, 689 tests. Co-Authored-By: Claude Opus 4.8 --- apps/server/package.json | 20 ++---------- .../core/ai-chat/public-share-chat.spec.ts | 11 +++---- .../src/core/auth/auth.controller.spec.ts | 18 ++++++----- .../core/auth/services/auth.service.spec.ts | 24 +++++++++----- .../core/auth/services/token.service.spec.ts | 13 ++++---- .../src/core/comment/comment.service.spec.ts | 19 +++++++----- .../src/core/group/group.controller.spec.ts | 16 +++++----- .../core/group/services/group.service.spec.ts | 21 ++++++++----- .../src/core/page/page.controller.spec.ts | 24 ++++++++------ .../core/page/services/page.service.spec.ts | 26 +++++++++++----- .../core/space/services/space.service.spec.ts | 21 ++++++++----- .../src/core/space/space.controller.spec.ts | 18 +++++------ .../src/core/user/user.controller.spec.ts | 15 ++++----- .../services/workspace.service.spec.ts | 31 ++++++++++++++----- .../environment/environment.service.spec.ts | 13 ++++---- .../storage/storage.service.spec.ts | 14 ++++----- 16 files changed, 174 insertions(+), 130 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index cabff6df..03cb57bf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -169,23 +169,7 @@ "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "testPathIgnorePatterns": [ - "/node_modules/", - "/core/auth/auth.controller.spec.ts", - "/core/auth/services/auth.service.spec.ts", - "/core/auth/services/token.service.spec.ts", - "/core/comment/comment.service.spec.ts", - "/core/group/group.controller.spec.ts", - "/core/group/services/group.service.spec.ts", - "/core/page/page.controller.spec.ts", - "/core/page/services/page.service.spec.ts", - "/core/search/search.controller.spec.ts", - "/core/search/search.service.spec.ts", - "/core/space/services/space.service.spec.ts", - "/core/space/space.controller.spec.ts", - "/core/user/user.controller.spec.ts", - "/core/workspace/services/workspace.service.spec.ts", - "/integrations/environment/environment.service.spec.ts", - "/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" diff --git a/apps/server/src/core/ai-chat/public-share-chat.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.spec.ts index 2be6a5f4..6cc9039a 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.spec.ts @@ -477,11 +477,10 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace expect(await limiter.tryConsume('ws-1')).toBe(true); }); - it('FAILS OPEN (returns true) when the Redis eval rejects', async () => { - // The per-workspace cap is a COST backstop, not an access boundary: the - // funnel access gates and the per-IP throttle still apply. A transient - // Redis failure must therefore ADMIT the call (true) rather than 500/429, - // so a Redis blip cannot take the public-share assistant fully offline. + it('FAILS CLOSED (returns false) when the Redis eval rejects', async () => { + // FAIL CLOSED (#62): if Redis is down we cannot prove the workspace is under + // its cap, so DENY (the controller 429s) rather than admit an unmetered, + // billable anonymous call. The feature is optional, so denial is harmless. const failingRedis = { eval: () => Promise.reject(new Error('redis down')), } as unknown as import('ioredis').Redis; @@ -495,7 +494,7 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace const errSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); - expect(await limiter.tryConsume('ws-1')).toBe(true); + expect(await limiter.tryConsume('ws-1')).toBe(false); expect(errSpy).toHaveBeenCalled(); // the failure MUST be logged, not swallowed errSpy.mockRestore(); }); diff --git a/apps/server/src/core/auth/auth.controller.spec.ts b/apps/server/src/core/auth/auth.controller.spec.ts index 27a31e61..4746f249 100644 --- a/apps/server/src/core/auth/auth.controller.spec.ts +++ b/apps/server/src/core/auth/auth.controller.spec.ts @@ -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); + beforeEach(() => { + controller = new AuthController( + {} as any, // authService + {} as any, // sessionService + {} as any, // environmentService + {} as any, // moduleRef + {} as any, // auditService + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/auth/services/auth.service.spec.ts b/apps/server/src/core/auth/services/auth.service.spec.ts index 800ab662..797431cb 100644 --- a/apps/server/src/core/auth/services/auth.service.spec.ts +++ b/apps/server/src/core/auth/services/auth.service.spec.ts @@ -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); + 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', () => { diff --git a/apps/server/src/core/auth/services/token.service.spec.ts b/apps/server/src/core/auth/services/token.service.spec.ts index a5f5d655..b298ebd3 100644 --- a/apps/server/src/core/auth/services/token.service.spec.ts +++ b/apps/server/src/core/auth/services/token.service.spec.ts @@ -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); + beforeEach(() => { + service = new TokenService( + {} as any, // jwtService + {} as any, // environmentService + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/comment/comment.service.spec.ts b/apps/server/src/core/comment/comment.service.spec.ts index 0f57aec2..9384a2b8 100644 --- a/apps/server/src/core/comment/comment.service.spec.ts +++ b/apps/server/src/core/comment/comment.service.spec.ts @@ -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); + 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', () => { diff --git a/apps/server/src/core/group/group.controller.spec.ts b/apps/server/src/core/group/group.controller.spec.ts index 0a68f0cd..663b70cc 100644 --- a/apps/server/src/core/group/group.controller.spec.ts +++ b/apps/server/src/core/group/group.controller.spec.ts @@ -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); + beforeEach(() => { + controller = new GroupController( + {} as any, // groupService + {} as any, // groupUserService + {} as any, // workspaceAbility + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/group/services/group.service.spec.ts b/apps/server/src/core/group/services/group.service.spec.ts index 495dd796..579a29d2 100644 --- a/apps/server/src/core/group/services/group.service.spec.ts +++ b/apps/server/src/core/group/services/group.service.spec.ts @@ -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); + 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', () => { diff --git a/apps/server/src/core/page/page.controller.spec.ts b/apps/server/src/core/page/page.controller.spec.ts index b59a02c1..e369d51e 100644 --- a/apps/server/src/core/page/page.controller.spec.ts +++ b/apps/server/src/core/page/page.controller.spec.ts @@ -1,17 +1,23 @@ -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); + beforeEach(() => { + controller = new PageController( + {} as any, // pageService + {} as any, // pageRepo + {} as any, // workspaceRepo + {} as any, // pageHistoryService + {} as any, // spaceAbility + {} as any, // pageAccessService + {} as any, // backlinkService + {} as any, // labelService + {} as any, // auditService + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/page/services/page.service.spec.ts b/apps/server/src/core/page/services/page.service.spec.ts index 7cadcb7f..c0d7dbc9 100644 --- a/apps/server/src/core/page/services/page.service.spec.ts +++ b/apps/server/src/core/page/services/page.service.spec.ts @@ -1,15 +1,27 @@ -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); + 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 + {} as any, // workspaceRepo + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/space/services/space.service.spec.ts b/apps/server/src/core/space/services/space.service.spec.ts index f97afbed..befdf06c 100644 --- a/apps/server/src/core/space/services/space.service.spec.ts +++ b/apps/server/src/core/space/services/space.service.spec.ts @@ -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); + 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', () => { diff --git a/apps/server/src/core/space/space.controller.spec.ts b/apps/server/src/core/space/space.controller.spec.ts index 4e7b9f87..4e11a012 100644 --- a/apps/server/src/core/space/space.controller.spec.ts +++ b/apps/server/src/core/space/space.controller.spec.ts @@ -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); + beforeEach(() => { + controller = new SpaceController( + {} as any, // spaceService + {} as any, // spaceMemberService + {} as any, // spaceMemberRepo + {} as any, // spaceAbility + {} as any, // workspaceAbility + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/user/user.controller.spec.ts b/apps/server/src/core/user/user.controller.spec.ts index 1f38440d..cb0429ab 100644 --- a/apps/server/src/core/user/user.controller.spec.ts +++ b/apps/server/src/core/user/user.controller.spec.ts @@ -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); + beforeEach(() => { + controller = new UserController( + {} as any, // userService + {} as any, // workspaceRepo + ); }); it('should be defined', () => { diff --git a/apps/server/src/core/workspace/services/workspace.service.spec.ts b/apps/server/src/core/workspace/services/workspace.service.spec.ts index 0f544349..bd35e296 100644 --- a/apps/server/src/core/workspace/services/workspace.service.spec.ts +++ b/apps/server/src/core/workspace/services/workspace.service.spec.ts @@ -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); + 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', () => { diff --git a/apps/server/src/integrations/environment/environment.service.spec.ts b/apps/server/src/integrations/environment/environment.service.spec.ts index cd2ad4bb..efef25b0 100644 --- a/apps/server/src/integrations/environment/environment.service.spec.ts +++ b/apps/server/src/integrations/environment/environment.service.spec.ts @@ -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); + beforeEach(() => { + service = new EnvironmentService( + {} as any, // configService + ); }); it('should be defined', () => { diff --git a/apps/server/src/integrations/storage/storage.service.spec.ts b/apps/server/src/integrations/storage/storage.service.spec.ts index 0b277788..79db48c0 100644 --- a/apps/server/src/integrations/storage/storage.service.spec.ts +++ b/apps/server/src/integrations/storage/storage.service.spec.ts @@ -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); + beforeEach(() => { + service = new StorageService( + {} as any, // storageDriver + ); }); it('should be defined', () => { -- 2.49.1 From a2ded7ecfb5ac017cf7c3b2f918d4800f2c6d89e Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:49:52 +0300 Subject: [PATCH 23/30] refactor(html-embed): extract the admin-gate strip into one tested helper (#90) The 4-step html-embed gate (feature-enabled AND role-allowed -> stripHtmlEmbedNodes) was replicated across call-sites, pinned only by brittle source-regex tests. Add stripHtmlEmbedIfNotAllowed(json, {featureEnabled, role, onStrip}) and migrate the 5 plain strip-all sites (collab handler, page create+duplicate, both import paths, transclusion) to it, each keeping its own feature/role resolve + log via onStrip. Left the 2 sites with different semantics: persistence.extension (#29 preserve- admin) and share.service (feature-only kill-switch, no role gate). Real unit tests replace the regex pins; behavior identical. Co-Authored-By: Claude Opus 4.8 --- .../collaboration/collaboration.handler.ts | 16 ++- .../helpers/prosemirror/html-embed.spec.ts | 114 ++++++++++++++++++ .../helpers/prosemirror/html-embed.util.ts | 24 ++++ .../src/core/page/services/page.service.ts | 38 +++--- .../page/transclusion/transclusion.service.ts | 18 +-- .../services/file-import-task.service.ts | 27 ++--- .../import/services/import.service.ts | 19 +-- 7 files changed, 193 insertions(+), 63 deletions(-) diff --git a/apps/server/src/collaboration/collaboration.handler.ts b/apps/server/src/collaboration/collaboration.handler.ts index debfde60..7513ca35 100644 --- a/apps/server/src/collaboration/collaboration.handler.ts +++ b/apps/server/src/collaboration/collaboration.handler.ts @@ -9,10 +9,8 @@ import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util'; import * as Y from 'yjs'; import { User } from '@docmost/db/types/entity.types'; import { - hasHtmlEmbedNode, - htmlEmbedAllowed, isHtmlEmbedFeatureEnabled, - stripHtmlEmbedNodes, + stripHtmlEmbedIfNotAllowed, } from '../common/helpers/prosemirror/html-embed.util'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; @@ -106,14 +104,14 @@ export class CollaborationHandler { const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled( (await this.workspaceRepo.findById(user?.workspaceId))?.settings, ); - if (!htmlEmbedAllowed(htmlEmbedEnabled, user?.role)) { - if (hasHtmlEmbedNode(prosemirrorJson)) { + prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, { + featureEnabled: htmlEmbedEnabled, + role: user?.role, + onStrip: () => this.logger.warn( `Stripping htmlEmbed node(s) from update by user ${user?.id} on ${documentName}`, - ); - prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); - } - } + ), + }); await this.withYdocConnection( hocuspocus, diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts index 55cbe982..f54850d3 100644 --- a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts +++ b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts @@ -5,6 +5,7 @@ import { htmlEmbedAllowed, isHtmlEmbedFeatureEnabled, stripDisallowedHtmlEmbedNodes, + stripHtmlEmbedIfNotAllowed, stripHtmlEmbedNodes, } from './html-embed.util'; import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util'; @@ -413,6 +414,119 @@ describe('htmlEmbedAllowed (toggle AND admin)', () => { }); }); +// The shared write-path strip ritual extracted from the 5 plain call-sites +// (collab handler, page create/duplicate, import, file-import-task, +// transclusion-unsync). Tested here once instead of being re-verified in each +// call-site's spec. +describe('stripHtmlEmbedIfNotAllowed (shared write-path gate)', () => { + const docWithEmbed = () => ({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'keep' }] }, + { type: 'htmlEmbed', attrs: { source: '' } }, + ], + }); + const docWithoutEmbed = () => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] }], + }); + + it('keeps the doc unchanged when feature is ON and role is admin (allowed)', () => { + const json = docWithEmbed(); + const onStrip = jest.fn(); + const result = stripHtmlEmbedIfNotAllowed(json, { + featureEnabled: true, + role: 'admin', + onStrip, + }); + // Allowed => same reference returned, embed preserved, no side-effect. + expect(result).toBe(json); + expect(hasHtmlEmbedNode(result)).toBe(true); + expect(onStrip).not.toHaveBeenCalled(); + }); + + it('keeps the doc unchanged for an owner when feature is ON (allowed)', () => { + const json = docWithEmbed(); + const onStrip = jest.fn(); + const result = stripHtmlEmbedIfNotAllowed(json, { + featureEnabled: true, + role: 'owner', + onStrip, + }); + expect(result).toBe(json); + expect(hasHtmlEmbedNode(result)).toBe(true); + expect(onStrip).not.toHaveBeenCalled(); + }); + + it('strips the embed when the feature is OFF (even for an admin)', () => { + const json = docWithEmbed(); + const onStrip = jest.fn(); + const result = stripHtmlEmbedIfNotAllowed(json, { + featureEnabled: false, + role: 'admin', + onStrip, + }); + expect(hasHtmlEmbedNode(result)).toBe(false); + expect(onStrip).toHaveBeenCalledTimes(1); + }); + + it('strips the embed for a non-admin when the feature is ON', () => { + const json = docWithEmbed(); + const onStrip = jest.fn(); + const result = stripHtmlEmbedIfNotAllowed(json, { + featureEnabled: true, + role: 'member', + onStrip, + }); + expect(hasHtmlEmbedNode(result)).toBe(false); + expect(onStrip).toHaveBeenCalledTimes(1); + }); + + it('strips the embed for a null/undefined role when the feature is ON', () => { + for (const role of [null, undefined]) { + const onStrip = jest.fn(); + const result = stripHtmlEmbedIfNotAllowed(docWithEmbed(), { + featureEnabled: true, + role, + onStrip, + }); + expect(hasHtmlEmbedNode(result)).toBe(false); + expect(onStrip).toHaveBeenCalledTimes(1); + } + }); + + it('returns input unchanged and does NOT call onStrip when no embed is present', () => { + const json = docWithoutEmbed(); + const onStrip = jest.fn(); + // Not allowed (feature OFF), but there is nothing to strip. + const result = stripHtmlEmbedIfNotAllowed(json, { + featureEnabled: false, + role: 'member', + onStrip, + }); + expect(result).toBe(json); + expect(onStrip).not.toHaveBeenCalled(); + }); + + it('calls onStrip exactly once per strip', () => { + const onStrip = jest.fn(); + stripHtmlEmbedIfNotAllowed(docWithEmbed(), { + featureEnabled: false, + role: 'member', + onStrip, + }); + expect(onStrip).toHaveBeenCalledTimes(1); + }); + + it('works without an onStrip callback (optional)', () => { + const result = stripHtmlEmbedIfNotAllowed(docWithEmbed(), { + featureEnabled: false, + role: 'member', + }); + expect(hasHtmlEmbedNode(result)).toBe(false); + }); +}); + // NOTE: a previous revision of this file re-implemented the write-path admin // gate as a local `applyAdminGate` stand-in and asserted against THAT. A // deleted/misplaced real guard would have kept those green. The stand-in is diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.util.ts b/apps/server/src/common/helpers/prosemirror/html-embed.util.ts index 146ea9d3..e25d4139 100644 --- a/apps/server/src/common/helpers/prosemirror/html-embed.util.ts +++ b/apps/server/src/common/helpers/prosemirror/html-embed.util.ts @@ -197,6 +197,30 @@ export function htmlEmbedAllowed( return featureEnabled === true && canAuthorHtmlEmbed(role); } +/** + * Strip htmlEmbed nodes unless the (feature-enabled AND role-allowed) gate + * passes. Returns the possibly-stripped doc. The caller resolves featureEnabled + * (from workspace settings) and role (actor) itself — those legitimately differ + * per call-site (e.g. share path uses role=null) — this helper owns only the + * has-check + AND + strip + optional onStrip callback. + * + * Centralizes the 4-step write-path ritual (resolve role -> resolve + * featureEnabled -> htmlEmbedAllowed AND -> stripHtmlEmbedNodes) so the plain + * strip-all call-sites share one tested decision. Sites with CUSTOM strip logic + * (e.g. the collab persist path's preserve-admin variant) keep their own code. + */ +export function stripHtmlEmbedIfNotAllowed( + json: T, + opts: { featureEnabled: boolean; role: string | null | undefined; onStrip?: () => void }, +): T { + if (htmlEmbedAllowed(opts.featureEnabled, opts.role)) return json; + if (hasHtmlEmbedNode(json)) { + opts.onStrip?.(); + return stripHtmlEmbedNodes(json); + } + return json; +} + /** * Read the workspace-level htmlEmbed feature toggle from a workspace's settings * jsonb. ABSENT/non-true => OFF (the default). Kept here so every server write diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 93a13bfe..3dff5a8a 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -32,10 +32,8 @@ import { removeMarkTypeFromDoc, } from '../../../common/helpers/prosemirror/utils'; import { - hasHtmlEmbedNode, - htmlEmbedAllowed, isHtmlEmbedFeatureEnabled, - stripHtmlEmbedNodes, + stripHtmlEmbedIfNotAllowed, } from '../../../common/helpers/prosemirror/html-embed.util'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { @@ -157,15 +155,14 @@ export class PageService { const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled( (await this.workspaceRepo.findById(workspaceId))?.settings, ); - if ( - !htmlEmbedAllowed(htmlEmbedEnabled, callerRole) && - hasHtmlEmbedNode(prosemirrorJson) - ) { - this.logger.warn( - `Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`, - ); - prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); - } + prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, { + featureEnabled: htmlEmbedEnabled, + role: callerRole, + onStrip: () => + this.logger.warn( + `Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`, + ), + }); content = prosemirrorJson; textContent = jsonToText(prosemirrorJson); @@ -782,15 +779,14 @@ export class PageService { // that contains an embed into a new page authored by them. Strip every // htmlEmbed node from each duplicated page when the duplicating user is // not an admin, BEFORE computing textContent/ydoc/insert. - if ( - !htmlEmbedAllowed(htmlEmbedEnabled, authUser.role) && - hasHtmlEmbedNode(prosemirrorJson) - ) { - this.logger.warn( - `Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`, - ); - prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); - } + prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, { + featureEnabled: htmlEmbedEnabled, + role: authUser.role, + onStrip: () => + this.logger.warn( + `Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`, + ), + }); // Add "Copy of " prefix to the root page title only for duplicates in same space let title = page.title; diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index 4dc0de9c..c3397031 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -34,10 +34,8 @@ import { jsonToNode } from '../../../collaboration/collaboration.util'; import { Page, User } from '@docmost/db/types/entity.types'; import { PageAccessService } from '../page-access/page-access.service'; import { - hasHtmlEmbedNode, - htmlEmbedAllowed, isHtmlEmbedFeatureEnabled, - stripHtmlEmbedNodes, + stripHtmlEmbedIfNotAllowed, } from '../../../common/helpers/prosemirror/html-embed.util'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; @@ -774,12 +772,14 @@ export class TransclusionService { const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled( (await this.workspaceRepo.findById(user.workspaceId))?.settings, ); - if (!htmlEmbedAllowed(htmlEmbedEnabled, user.role) && hasHtmlEmbedNode(content)) { - this.logger.warn( - `Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`, - ); - content = stripHtmlEmbedNodes(content); - } + content = stripHtmlEmbedIfNotAllowed(content, { + featureEnabled: htmlEmbedEnabled, + role: user.role, + onStrip: () => + this.logger.warn( + `Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`, + ), + }); return { content }; } diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index c87d4d8f..aa39a085 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -21,10 +21,8 @@ import { FileTask, InsertablePage } from '@docmost/db/types/entity.types'; import { markdownToHtml } from '@docmost/editor-ext'; import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils'; import { - hasHtmlEmbedNode, - htmlEmbedAllowed, isHtmlEmbedFeatureEnabled, - stripHtmlEmbedNodes, + stripHtmlEmbedIfNotAllowed, } from '../../../common/helpers/prosemirror/html-embed.util'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; @@ -177,10 +175,6 @@ export class FileImportTaskService { const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled( (await this.workspaceRepo.findById(fileTask.workspaceId))?.settings, ); - const importerCanAuthorHtmlEmbed = htmlEmbedAllowed( - htmlEmbedEnabled, - importingUser?.role, - ); const pagesMap = new Map(); @@ -534,15 +528,16 @@ export class FileImportTaskService { // SECURITY (Variant C admin gate): strip htmlEmbed nodes from pages // imported by a non-admin BEFORE computing textContent/ydoc/insert. - if ( - !importerCanAuthorHtmlEmbed && - hasHtmlEmbedNode(prosemirrorJson) - ) { - this.logger.warn( - `Stripping htmlEmbed node(s) from non-admin import by user ${fileTask.creatorId} (page ${page.id}, file ${filePath})`, - ); - prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); - } + // Gate (featureEnabled AND admin) is resolved once above and recomputed + // by the helper from the same htmlEmbedEnabled + importer role. + prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, { + featureEnabled: htmlEmbedEnabled, + role: importingUser?.role, + onStrip: () => + this.logger.warn( + `Stripping htmlEmbed node(s) from non-admin import by user ${fileTask.creatorId} (page ${page.id}, file ${filePath})`, + ), + }); const insertablePage: InsertablePage = { id: page.id, diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index 574a13ab..cf602b77 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -3,9 +3,8 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { hasHtmlEmbedNode, - htmlEmbedAllowed, isHtmlEmbedFeatureEnabled, - stripHtmlEmbedNodes, + stripHtmlEmbedIfNotAllowed, } from '../../../common/helpers/prosemirror/html-embed.util'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { MultipartFile } from '@fastify/multipart'; @@ -102,6 +101,8 @@ export class ImportService { // serialized form), which would execute raw JS in readers' browsers. Only // workspace admins/owners may author it, so strip htmlEmbed nodes from // imports performed by a non-admin user. + // Outer has-check first so the user/workspace lookups below run only when an + // embed is actually present (the common case carries none). if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) { const importingUser = await this.userRepo.findById(userId, workspaceId); // Toggle-AND-admin gate: htmlEmbed survives only when the workspace @@ -110,12 +111,14 @@ export class ImportService { const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled( (await this.workspaceRepo.findById(workspaceId))?.settings, ); - if (!htmlEmbedAllowed(htmlEmbedEnabled, importingUser?.role)) { - this.logger.warn( - `Stripping htmlEmbed node(s) from import by user ${userId}`, - ); - prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); - } + prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, { + featureEnabled: htmlEmbedEnabled, + role: importingUser?.role, + onStrip: () => + this.logger.warn( + `Stripping htmlEmbed node(s) from import by user ${userId}`, + ), + }); } const pageTitle = title || fileName; -- 2.49.1 From 7c57a386b25d5c424267515bd208ff87576ffa2b Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:49:52 +0300 Subject: [PATCH 24/30] test(mcp): coupling guard between enforceBasicLoginGate and login (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpService.enforceBasicLoginGate re-implements AuthController.login's pre-token SSO/MFA gate; silent drift would re-open the bypass. Add an AST contract test (comments stripped) asserting BOTH method bodies contain validateSsoEnforcement, the EE-MFA require, and checkMfaRequirements — so dropping the gate from either side fails CI. Test-only (no core/auth refactor). Co-Authored-By: Claude Opus 4.8 --- .../mcp-login-gate-coupling.contract.spec.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 apps/server/src/integrations/mcp/mcp-login-gate-coupling.contract.spec.ts diff --git a/apps/server/src/integrations/mcp/mcp-login-gate-coupling.contract.spec.ts b/apps/server/src/integrations/mcp/mcp-login-gate-coupling.contract.spec.ts new file mode 100644 index 00000000..78b35bbf --- /dev/null +++ b/apps/server/src/integrations/mcp/mcp-login-gate-coupling.contract.spec.ts @@ -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); + }); +}); -- 2.49.1 From 3147b6ddf4d4a38158fb1790aca38b7541d9443f Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:49:52 +0300 Subject: [PATCH 25/30] refactor(ws): single restriction-aware emit for tree + comment events (#93) emitTreeEvent and emitCommentEvent were byte-identical (same room resolution, spaceHasRestrictions gate, hasRestrictedAncestor, authorized-only vs broadcast fallback). Collapse the body into one private emitRestrictedAwareToSpace; both stay thin wrappers with unchanged signatures, so the restriction-routing gate lives in exactly one place. Adds coverage for the comment entry point. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/ws/ws-tree.service.spec.ts | 36 +++++++++++++++++++ apps/server/src/ws/ws.service.ts | 42 +++++++++++----------- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/apps/server/src/ws/ws-tree.service.spec.ts b/apps/server/src/ws/ws-tree.service.spec.ts index 973e6b00..e007e249 100644 --- a/apps/server/src/ws/ws-tree.service.spec.ts +++ b/apps/server/src/ws/ws-tree.service.spec.ts @@ -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'); diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 5c8303eb..18e20fb0 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -52,33 +52,19 @@ export class WsService { ); } + // 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 { - 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 @@ -88,6 +74,22 @@ export class WsService { spaceId: string, pageId: string, data: any, + ): Promise { + 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 { const room = getSpaceRoomName(spaceId); -- 2.49.1 From 3953ecdb170659223be2f28c397e129038110c0b Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:49:52 +0300 Subject: [PATCH 26/30] refactor(ai-chat): single live+enabled role resolve in the repo (#95) resolveRoleForRequest and resolveShareRole duplicated the security invariant 'role exists, not soft-deleted, enabled, workspace-scoped, else null'. Move it to AiAgentRoleRepo.findLiveEnabled(id, workspaceId) (deletedAt IS NULL + enabled + workspace scope) and have both services call it, preserving each one's roleId derivation + null handling. (describeProviderError half of #95 was done earlier.) Co-Authored-By: Claude Opus 4.8 --- .../core/ai-chat/ai-chat.role-resolve.spec.ts | 37 +++++++++----- .../src/core/ai-chat/ai-chat.service.ts | 16 +++--- .../core/ai-chat/public-share-chat.service.ts | 8 +-- .../core/ai-chat/public-share-chat.spec.ts | 16 +++--- .../ai-agent-roles.repo.spec.ts | 51 +++++++++++++++++++ .../ai-agent-roles/ai-agent-roles.repo.ts | 23 +++++++++ 6 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts diff --git a/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts b/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts index 1b0afeb4..f8348dfb 100644 --- a/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts @@ -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(); }); }); diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 46cb6e90..4f6f4f47 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -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 + ); } /** diff --git a/apps/server/src/core/ai-chat/public-share-chat.service.ts b/apps/server/src/core/ai-chat/public-share-chat.service.ts index 310fd0cf..2844b33c 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.service.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.service.ts @@ -153,9 +153,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 + ); } /** diff --git a/apps/server/src/core/ai-chat/public-share-chat.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.spec.ts index 6cc9039a..55e5571b 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.spec.ts @@ -251,8 +251,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?: { @@ -272,7 +274,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( @@ -314,14 +318,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(); }); diff --git a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts new file mode 100644 index 00000000..91a3ffad --- /dev/null +++ b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts index 4adfa677..1d44c776 100644 --- a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts +++ b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts @@ -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 { + 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 { return this.db -- 2.49.1 From 3f464961929ffd3055b83ca9294c9249df464e20 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 04:04:09 +0300 Subject: [PATCH 27/30] refactor(share): single resolveReadableSharePage for the share access boundary (#92) The '(shareId,pageId) -> usable non-restricted page in THIS share' boundary was written as 3 must-be-identical async sequences. They weren't: the chat funnel omitted an explicit page.deletedAt check (latently safe via getShareForPage's CTE) and layered isSharingAllowed separately. Add ShareService.resolveReadable- SharePage(shareId,pageId,workspaceId) running the single canonical sequence (getShareForPage -> id match (skipped when null) -> findById -> !deletedAt -> !hasRestrictedAncestor) returning {share,page}|null; getSharedPage, the funnel, and the getSharePage tool all use it. hasRestrictedAncestor now lives in the one method no caller can skip; the funnel still returns uniform 404s and keeps isSharingAllowed. Adds a direct security-invariant test. Co-Authored-By: Claude Opus 4.8 --- .../public-share-chat.controller.spec.ts | 74 +++++----- .../ai-chat/public-share-chat.controller.ts | 56 ++++---- .../core/ai-chat/public-share-chat.spec.ts | 67 ++++----- .../public-share-chat-tools.service.spec.ts | 63 ++++---- .../tools/public-share-chat-tools.service.ts | 47 +++--- .../share/share-resolve-readable-page.spec.ts | 136 ++++++++++++++++++ apps/server/src/core/share/share.service.ts | 82 +++++++++-- 7 files changed, 347 insertions(+), 178 deletions(-) create mode 100644 apps/server/src/core/share/share-resolve-readable-page.spec.ts diff --git a/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts index 83f6252e..13c19f8b 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts @@ -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 () => { diff --git a/apps/server/src/core/ai-chat/public-share-chat.controller.ts b/apps/server/src/core/ai-chat/public-share-chat.controller.ts index 726b6487..74f8b538 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.controller.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.controller.ts @@ -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 { @@ -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; + // 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; - pagePermissionRepo: Pick; 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>>; + share: NonNullable< + Awaited> + >['share']; model: Awaited>; 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> | 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> + >['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, })); } } diff --git a/apps/server/src/core/ai-chat/public-share-chat.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.spec.ts index 55e5571b..a1ef621c 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.spec.ts @@ -525,17 +525,15 @@ 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, + {} as never, ); const tools = svc.forShare('THIS-SHARE', 'ws-1'); @@ -546,37 +544,29 @@ 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, + {} as never, ); const tools = svc.forShare('THIS-SHARE', 'ws-1'); @@ -587,11 +577,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(); }); @@ -735,25 +721,32 @@ 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, + {} 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; }; - // ...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', () => { diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts index 1c3f6f8d..9069dfda 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts @@ -13,11 +13,13 @@ 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() }; @@ -120,13 +122,15 @@ 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(); }); }); @@ -168,13 +172,15 @@ describe('PublicShareChatToolsService.forShare', () => { content: rawContent, }; - const { svc, shareService, pageRepo, pagePermissionRepo } = makeService({ - // getShareForPage resolves to THIS share (id matches the forShare scope). - getShareForPage: jest.fn().mockResolvedValue({ id: 'SHARE-A' }), - findById: jest.fn().mockResolvedValue(page), + 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 }), }); - // Page has no restricted ancestor => passes the restriction gate. - pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false); // The sanitizer returns the SANITIZED content (raw secrets removed). shareService.updatePublicAttachments.mockResolvedValue(sanitizedContent); @@ -183,17 +189,13 @@ describe('PublicShareChatToolsService.forShare', () => { pageId: ' page-1 ', })) as { title: string; markdown: string }; - // Membership + liveness + restriction checks were all consulted. - expect(shareService.getShareForPage).toHaveBeenCalledWith( + // 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', ); - expect(pageRepo.findById).toHaveBeenCalledWith('page-1', { - includeContent: true, - }); - expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith( - 'page-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 @@ -210,30 +212,23 @@ describe('PublicShareChatToolsService.forShare', () => { }); }); - describe('getSharePage soft-deleted page', () => { - it('findById returns a soft-deleted page (deletedAt set) => generic error, NO content fetch (updatePublicAttachments not called, nothing leaked)', async () => { - const deletedPage = { - id: 'page-1', - title: 'Deleted Page', - deletedAt: new Date(), - content: { type: 'doc', content: [] }, - }; - const { svc, shareService, pagePermissionRepo } = makeService({ - getShareForPage: jest.fn().mockResolvedValue({ id: 'SHARE-A' }), - findById: jest.fn().mockResolvedValue(deletedPage), + 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'); - // Same generic message as an out-of-share page (no info leak). await expect( (tools.getSharePage as unknown as ToolExec).execute({ pageId: 'page-1', }), ).rejects.toThrow('That page is not part of this published share.'); - // Short-circuits before the restriction gate AND before the sanitizer: - // no content is ever fetched/returned for a soft-deleted page. - expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled(); + // No content is ever fetched/returned for a non-resolving page. expect(shareService.updatePublicAttachments).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts index b15a6f69..8f0998f5 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts @@ -22,10 +22,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 { @@ -99,38 +99,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 diff --git a/apps/server/src/core/share/share-resolve-readable-page.spec.ts b/apps/server/src/core/share/share-resolve-readable-page.spec.ts new file mode 100644 index 00000000..237e483e --- /dev/null +++ b/apps/server/src/core/share/share-resolve-readable-page.spec.ts @@ -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, + }); + }); +}); diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 846cc96a..9de364ef 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -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>>; + 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); -- 2.49.1 From 31fcb764d76912b234776e8efaae4c766f046e6f Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 04:04:09 +0300 Subject: [PATCH 28/30] refactor(transclusion): unify the ProseMirror collectors into collectNodes (#94) The three collect*FromPmJson collectors shared the same recursion (and the #55 depth cap) but were copy-pasted, so a future edit could diverge them. Extract a generic collectNodes(doc, {type, map, key, lastWins, skipChildrenOfType}) and reimplement all three on it, byte-output-identical (transclusions last-wins; references/embeds first-wins + transclusionSource skip). Documents (not removes) the write-only page_template_references graph and the near-duplicate client lookup-context as tracked follow-ups, per the issue's guidance. Co-Authored-By: Claude Opus 4.8 --- .../page-embed/page-embed-lookup-context.tsx | 9 + .../page/transclusion/transclusion.service.ts | 12 + .../utils/transclusion-prosemirror.util.ts | 206 ++++++++++-------- 3 files changed, 136 insertions(+), 91 deletions(-) diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx index d29c19dc..237c8ae4 100644 --- a/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx +++ b/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx @@ -25,6 +25,15 @@ const PageEmbedLookupContext = createContext(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, diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index c3397031..ebee79f9 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -273,6 +273,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, diff --git a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts index 6431b71f..c49b1bf5 100644 --- a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts +++ b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts @@ -20,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( + 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(); + + 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 @@ -30,34 +103,23 @@ export type PageEmbedSnapshot = { export function collectTransclusionsFromPmJson( doc: unknown, ): TransclusionNodeSnapshot[] { - if (!doc || typeof doc !== 'object') return []; - - const byId = new Map(); - - 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 === TRANSCLUSION_TYPE) { + // last-wins on duplicate ids (Map.set overwrites) — matches prior behaviour. + return collectNodes(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, depth + 1); - } - }; - - visit(doc, 0); - 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). + }); } /** @@ -70,46 +132,26 @@ export function collectTransclusionsFromPmJson( export function collectReferencesFromPmJson( doc: unknown, ): TransclusionReferenceSnapshot[] { - if (!doc || typeof doc !== 'object') return []; - - const seen = new Set(); - const out: TransclusionReferenceSnapshot[] = []; - - 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 === REFERENCE_TYPE) { + // first-wins dedup on (sourcePageId, transclusionId); skip recursing into a + // transclusionSource (schema forbids references inside one). + return collectNodes(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, depth + 1); - } - }; - - visit(doc, 0); - return out; + return { sourcePageId, transclusionId }; + }, + key: (snapshot) => `${snapshot.sourcePageId}::${snapshot.transclusionId}`, + skipChildrenOfType: TRANSCLUSION_TYPE, + }); } /** @@ -182,35 +224,17 @@ export function remapPageEmbedSourceIds( export function collectPageEmbedsFromPmJson( doc: unknown, ): PageEmbedSnapshot[] { - if (!doc || typeof doc !== 'object') return []; - - const seen = new Set(); - const out: PageEmbedSnapshot[] = []; - - const visit = (node: any, depth: number): void => { - if (!node || typeof node !== 'object') return; - // Stop before the stack can overflow on a pathological/cyclic non-JSON - // input; far above any real doc nesting so genuine docs are unaffected. - if (depth > MAX_PM_WALK_DEPTH) return; - - if (node.type === PAGE_EMBED_TYPE) { + // first-wins dedup on sourcePageId; skip recursing into a transclusionSource. + return collectNodes(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, depth + 1); - } - }; - - visit(doc, 0); - return out; + return { sourcePageId }; + }, + key: (snapshot) => snapshot.sourcePageId, + skipChildrenOfType: TRANSCLUSION_TYPE, + }); } -- 2.49.1 From a20f4c3876580709ab7837e964e89df1ab5f04ad Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 04:14:38 +0300 Subject: [PATCH 29/30] fix(mcp): close the brute-force limiter check-then-act race (#83) isBlocked was checked synchronously but recordFailure ran only AFTER the bcrypt awaits, so N concurrent /mcp Basic requests for one email all slipped past the threshold. Add FailedLoginLimiter.tryReserve (atomic synchronous check+increment) + release (undo), and reserve all 3 keys BEFORE any await so the (threshold+1)-th concurrent attempt is rejected before its bcrypt runs. The reservation IS the failure record (post-await recordFailure removed -> counted exactly once). Non- credential early throws (missing workspace, SSO/MFA gate) and business errors release the reservation so they don't burn a victim's budget; success clears. Tests prove login() runs exactly threshold times under concurrency and that gate/config rejects don't consume budget. Co-Authored-By: Claude Opus 4.8 --- .../src/integrations/mcp/mcp-auth.helpers.ts | 174 +++++++++++++----- .../src/integrations/mcp/mcp.service.spec.ts | 159 ++++++++++++++++ 2 files changed, 286 insertions(+), 47 deletions(-) diff --git a/apps/server/src/integrations/mcp/mcp-auth.helpers.ts b/apps/server/src/integrations/mcp/mcp-auth.helpers.ts index c3aa3292..f71dff9a 100644 --- a/apps/server/src/integrations/mcp/mcp-auth.helpers.ts +++ b/apps/server/src/integrations/mcp/mcp-auth.helpers.ts @@ -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}`, diff --git a/apps/server/src/integrations/mcp/mcp.service.spec.ts b/apps/server/src/integrations/mcp/mcp.service.spec.ts index cfa8472d..a6f93d5d 100644 --- a/apps/server/src/integrations/mcp/mcp.service.spec.ts +++ b/apps/server/src/integrations/mcp/mcp.service.spec.ts @@ -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((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. -- 2.49.1 From bc0c49db05470681148a5cdfe7d92a10a3d0dfc2 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 05:24:13 +0300 Subject: [PATCH 30/30] fix(review): address PR #101 review findings (dead DI, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai-chat: drop the unused pagePermissionRepo injection from PublicShareChatToolsService (its only use moved into ShareService.resolveReadableSharePage); update all 5 positional test construction sites to match the 3-arg constructor. - env: correct the anonymous share-AI per-workspace cap comment — the limiter FAILS CLOSED on Redis failure (#62), not open. - docs: sync README.ru.md with README.md — move "Page templates" from Planned to Done and drop the dead plan-doc link. Remaining test-coverage gaps tracked as #102, #103, #104, #105, #106. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 ++- README.ru.md | 2 +- apps/server/src/core/ai-chat/public-share-chat.spec.ts | 4 ---- .../ai-chat/tools/public-share-chat-tools.service.spec.ts | 4 +--- .../src/core/ai-chat/tools/public-share-chat-tools.service.ts | 2 -- 5 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 4cebe788..99aab852 100644 --- a/.env.example +++ b/.env.example @@ -140,6 +140,7 @@ MCP_DOCMOST_PASSWORD= # Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent, # keyed by the server-resolved workspace id) bounds the owner's bill even if the # per-IP limit is fully evaded. It is a COST backstop, not an access control, -# and FAILS OPEN if Redis is unavailable. Override the hourly cap below +# and FAILS CLOSED if Redis is unavailable (the assistant is temporarily +# denied rather than serving unmetered calls). Override the hourly cap below # (default: 300 calls per workspace per rolling hour). # SHARE_AI_WORKSPACE_MAX_PER_HOUR=300 diff --git a/README.ru.md b/README.ru.md index 0bd9a5de..303f489d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -102,6 +102,7 @@ real-time-коллаборации Docmost, поэтому запись нико - ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов. - ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP). - ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия. +- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). ### В процессе @@ -109,7 +110,6 @@ real-time-коллаборации Docmost, поэтому запись нико ### В планах -- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение. - 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем. diff --git a/apps/server/src/core/ai-chat/public-share-chat.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.spec.ts index a1ef621c..df8df047 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.spec.ts @@ -533,7 +533,6 @@ describe('PublicShareChatToolsService share scoping', () => { shareService as never, {} as never, {} as never, - {} as never, ); const tools = svc.forShare('THIS-SHARE', 'ws-1'); @@ -566,7 +565,6 @@ describe('PublicShareChatToolsService share scoping', () => { shareService as never, {} as never, {} as never, - {} as never, ); const tools = svc.forShare('THIS-SHARE', 'ws-1'); @@ -591,7 +589,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 { @@ -732,7 +729,6 @@ describe('public-share assistant boundary locks (red-team regression guards)', ( shareService as never, {} as never, {} as never, - {} as never, ); const tools = svc.forShare('FORGED-SHARE', 'ws-1'); const getSharePage = tools.getSharePage as { diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts index 9069dfda..bc80acd6 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts @@ -24,14 +24,12 @@ describe('PublicShareChatToolsService.forShare', () => { }; 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', () => { diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts index 8f0998f5..5d3e8df0 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts @@ -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'; /** @@ -35,7 +34,6 @@ export class PublicShareChatToolsService { private readonly shareService: ShareService, private readonly searchService: SearchService, private readonly pageRepo: PageRepo, - private readonly pagePermissionRepo: PagePermissionRepo, ) {} /** -- 2.49.1