Merge remote-tracking branch 'gitea/develop' into feat/page-templates

# Conflicts:
#	apps/server/src/integrations/throttle/throttle.module.ts
#	apps/server/src/integrations/throttle/throttler-names.ts
This commit is contained in:
claude_code
2026-06-20 20:18:42 +03:00
130 changed files with 9951 additions and 3095 deletions

View File

@@ -0,0 +1,61 @@
import { describeProviderError } from './ai-error.util';
/**
* Unit tests for describeProviderError: the shared formatter used both for the
* server log line and for the error text streamed back to the client. This
* pins the behaviour, including the one behaviour change introduced when the
* two inline formatters were unified: a truncated, single-line snippet of the
* provider `responseBody`/`text` is appended (so a misconfigured endpoint's
* HTML error page is diagnosable). The util guarantees the API key is never in
* the response body, so this is safe to surface.
*/
describe('describeProviderError', () => {
it('uses the fallback for a null/empty/undefined error', () => {
expect(describeProviderError(null, 'AI stream error')).toBe(
'AI stream error',
);
expect(describeProviderError('', 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(undefined)).toBe('Unknown error');
});
it('returns a non-empty plain string error as-is', () => {
expect(describeProviderError('boom')).toBe('boom');
});
it('formats statusCode + message', () => {
expect(
describeProviderError({ statusCode: 401, message: 'Unauthorized' }),
).toBe('401: Unauthorized');
});
it('falls back to message when there is no statusCode', () => {
expect(describeProviderError({ message: 'nope' })).toBe('nope');
});
it('appends a whitespace-collapsed response body snippet', () => {
const out = describeProviderError({
statusCode: 502,
message: 'Bad Gateway',
responseBody: '<html>\n <body>upstream error</body>\n</html>',
});
expect(out.startsWith('502: Bad Gateway | response body: ')).toBe(true);
// Newlines and runs of spaces are collapsed to single spaces.
expect(out).toContain('<html> <body>upstream error</body> </html>');
});
it('reads `text` when responseBody is absent', () => {
expect(describeProviderError({ message: 'e', text: 'body-text' })).toBe(
'e | response body: body-text',
);
});
it('truncates a long body to 300 chars + ellipsis', () => {
const out = describeProviderError({
message: 'e',
responseBody: 'x'.repeat(500),
});
expect(out).toContain('…');
// 'e | response body: ' + 300 chars + '…'
expect(out.length).toBeLessThan('e | response body: '.length + 305);
});
});

View File

@@ -9,10 +9,16 @@
*
* None of these fields contain the API key (it is sent as an Authorization
* header and never echoed in the response body), so this is safe to log/return.
*
* `fallback` is used when the error carries no usable message (e.g. a bare
* object); defaults to 'Unknown error'.
*/
export function describeProviderError(err: unknown): string {
export function describeProviderError(
err: unknown,
fallback = 'Unknown error',
): string {
if (typeof err !== 'object' || err === null) {
return typeof err === 'string' ? err : 'Unknown error';
return typeof err === 'string' && err ? err : fallback;
}
const e = err as {
statusCode?: number;
@@ -23,7 +29,7 @@ export function describeProviderError(err: unknown): string {
const base =
typeof e.statusCode === 'number'
? `${e.statusCode}: ${e.message ?? ''}`.trim()
: (e.message ?? 'Unknown error');
: (e.message ?? fallback);
const body = (e.responseBody ?? e.text ?? '').trim();
if (!body) return base;
// Collapse whitespace so a multi-line HTML body stays on one log line.

View File

@@ -5,7 +5,7 @@ import { ServiceUnavailableException } from '@nestjs/common';
* driver / chat model / API key). Maps to HTTP 503 (§6.2/§6.4).
*/
export class AiNotConfiguredException extends ServiceUnavailableException {
constructor() {
super('AI provider not configured');
constructor(message = 'AI provider not configured') {
super(message);
}
}

View File

@@ -33,6 +33,8 @@ export interface UpdateAiSettingsInput {
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
sttApiKey?: string;
publicShareChatModel?: string;
publicShareAssistantRoleId?: string;
}
/**
@@ -94,6 +96,20 @@ export class AiSettingsService {
);
}
/**
* Whether the anonymous public-share AI assistant is enabled for a workspace
* (single master toggle `settings.ai.publicShareAssistant`, default false).
* Used by the public `/api/shares/ai/stream` guardrail funnel: when off, the
* route 404s so the feature's existence is not revealed.
*/
async isPublicShareAssistantEnabled(workspaceId: string): Promise<boolean> {
const workspace = await this.workspaceRepo.findById(workspaceId);
const settings = (workspace?.settings ?? {}) as {
ai?: { publicShareAssistant?: boolean };
};
return settings?.ai?.publicShareAssistant === true;
}
/** Read the stored non-secret provider settings for a workspace. */
private async readProvider(
workspaceId: string,
@@ -117,6 +133,12 @@ export class AiSettingsService {
const config: ResolvedAiConfig = {
driver: provider.driver,
chatModel: provider.chatModel,
// Cheap model id for the anonymous public-share assistant; reuses the chat
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
publicShareChatModel: provider.publicShareChatModel,
// Agent-role id whose persona the public-share assistant adopts; empty/unset
// = built-in locked persona.
publicShareAssistantRoleId: provider.publicShareAssistantRoleId,
embeddingModel: provider.embeddingModel,
sttModel: provider.sttModel,
// Plain passthrough, no fallback; the transcribe path defaults unset to
@@ -197,6 +219,8 @@ export class AiSettingsService {
sttBaseUrl: provider.sttBaseUrl,
sttApiStyle: provider.sttApiStyle,
systemPrompt: provider.systemPrompt,
publicShareChatModel: provider.publicShareChatModel,
publicShareAssistantRoleId: provider.publicShareAssistantRoleId,
hasApiKey,
hasEmbeddingApiKey,
hasSttApiKey,
@@ -234,6 +258,8 @@ export class AiSettingsService {
'sttBaseUrl',
'sttApiStyle',
'systemPrompt',
'publicShareChatModel',
'publicShareAssistantRoleId',
] as const) {
if (nonSecret[key] !== undefined) {
(providerPatch as Record<string, unknown>)[key] = nonSecret[key];

View File

@@ -0,0 +1,174 @@
import { AiService } from './ai.service';
import { AiNotConfiguredException } from './ai-not-configured.exception';
/**
* Unit test for the role model-override 503 path of AiService.getChatModel.
*
* AiService's constructor body is trivial (it only stores its deps), so it can
* be unit-constructed with stubbed collaborators — no Nest module graph, which
* the src-rooted jest setup cannot fully resolve for the heavier specs. We stub:
* - aiSettings.resolve -> a workspace configured for openai (so cfg.driver is
* set and we pass the first guard),
* - aiProviderCredentialsRepo.find -> undefined (the override driver has NO
* configured credentials),
* - secretBox -> unused on this path (no creds to decrypt).
*
* With a role override pointing at a DIFFERENT driver ('gemini') that has no
* creds, getChatModel must throw AiNotConfiguredException (503) and the message
* must name the override driver (and the role) so an admin can fix it.
*/
describe('AiService.getChatModel role model override', () => {
function makeService(opts: {
workspaceDriver: string;
credsApiKeyEnc?: string;
}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: opts.workspaceDriver,
chatModel: 'gpt-4o-mini',
apiKey: 'workspace-key',
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = {
find: jest.fn().mockResolvedValue(
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
),
};
const secretBox = {
decryptSecret: jest.fn().mockReturnValue('decrypted'),
};
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
}
it('throws AiNotConfiguredException (503) naming the override driver when its creds are missing', async () => {
const { service, aiProviderCredentialsRepo } = makeService({
workspaceDriver: 'openai',
});
await expect(
service.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
}),
).rejects.toBeInstanceOf(AiNotConfiguredException);
// Re-run to assert the message names the driver (and role) for the admin.
await service
.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
})
.then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
expect(message).toContain('gemini');
expect(message).toContain('Researcher');
},
);
// The override driver's creds were looked up for the right driver.
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
});
it('cross-driver override with creds present: resolves without throwing, using the OVERRIDE driver creds', async () => {
// Workspace driver is openai; the role overrides to gemini, which HAS creds.
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
workspaceDriver: 'openai',
credsApiKeyEnc: 'enc-gemini-key',
});
const model = await service.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
});
// A real LanguageModel was built (no 503).
expect(model).toBeDefined();
// Creds were fetched for the OVERRIDE driver, then decrypted.
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
expect(secretBox.decryptSecret).toHaveBeenCalledWith('enc-gemini-key');
});
it('cross-driver override to ollama (workspace driver != ollama): throws 503, does NOT silently reuse the workspace baseUrl', async () => {
// Workspace driver is openai with a configured (gateway) baseUrl. A role that
// overrides to ollama has no dedicated ollama endpoint, so pointing the
// ollama client at the workspace's openai baseUrl would be wrong — it must
// fail explicitly instead.
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: 'gpt-4o-mini',
apiKey: 'workspace-key',
baseUrl: 'https://openrouter.example/v1',
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
await service
.getChatModel('ws-1', {
driver: 'ollama',
chatModel: 'llama3',
roleName: 'Local',
})
.then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
// Names the role and the workspace driver, and mentions ollama.
expect(message).toContain('ollama');
expect(message).toContain('openai');
expect(message).toContain('Local');
// Must NOT leak / reuse the workspace gateway baseUrl in the path.
expect(message).not.toContain('openrouter.example');
},
);
// No ollama creds lookup happens (ollama needs no key); we fail before that.
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
});
it('chatModel-only override (no driver): reuses the workspace driver+creds, no creds lookup/decrypt', async () => {
// No override.driver => the workspace openai driver + its apiKey are reused;
// ai_provider_credentials must NOT be queried and nothing is decrypted.
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
workspaceDriver: 'openai',
});
const model = await service.getChatModel('ws-1', {
chatModel: 'gpt-4o',
roleName: 'Writer',
});
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,22 @@ import { AiNotConfiguredException } from './ai-not-configured.exception';
import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception';
import { AiSttNotConfiguredException } from './ai-stt-not-configured.exception';
import { describeProviderError } from './ai-error.util';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { SecretBoxService } from '../crypto/secret-box';
import { AiDriver } from './ai.types';
/**
* Optional chat-model override carried by an agent role (`ai_agent_roles.
* model_config`). `chatModel` swaps the model id; `driver` (optional) switches
* the whole provider, in which case its creds come from `ai_provider_credentials`
* for that driver. `roleName` is only used to produce a clear 503 message when
* the chosen driver is not configured.
*/
export interface ChatModelOverride {
driver?: AiDriver;
chatModel?: string;
roleName?: string;
}
/**
* Builds AI SDK language models from per-workspace config and runs cheap
@@ -27,23 +43,96 @@ import { describeProviderError } from './ai-error.util';
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(private readonly aiSettings: AiSettingsService) {}
constructor(
private readonly aiSettings: AiSettingsService,
private readonly aiProviderCredentialsRepo: AiProviderCredentialsRepo,
private readonly secretBox: SecretBoxService,
) {}
/**
* Resolve the workspace config and build the chat language model.
* Throws AiNotConfiguredException (→ 503) when the config is incomplete.
*
* `override` optionally swaps the model id and/or the whole provider:
* - `override.chatModel` replaces the workspace chat model id;
* - `override.driver` (when it differs from the workspace driver) switches the
* provider, pulling that driver's creds from `ai_provider_credentials`. When
* those creds are missing the call throws a 503 naming the role's driver — a
* deliberate, explicit failure rather than a silent fallback. Resolved
* BEFORE the stream starts so the 503 surfaces as clean JSON.
*
* Two callers: an agent role's `model_config` (may set driver + model), and
* the anonymous public-share assistant, which passes ONLY `chatModel` (the
* cheap `publicShareChatModel`) so the driver/baseUrl/apiKey stay the
* workspace's configured chat provider. A blank override falls back to the
* workspace `chatModel`.
*/
async getChatModel(workspaceId: string): Promise<LanguageModel> {
async getChatModel(
workspaceId: string,
override?: ChatModelOverride,
): Promise<LanguageModel> {
const cfg = await this.aiSettings.resolve(workspaceId);
if (
!cfg?.driver ||
!cfg?.chatModel ||
(cfg.driver !== 'ollama' && !cfg.apiKey)
) {
if (!cfg?.driver) {
throw new AiNotConfiguredException();
}
switch (cfg.driver) {
// Determine the effective driver + model + creds, applying the override.
const overrideDriver = override?.driver;
const driver: AiDriver = overrideDriver ?? cfg.driver;
const chatModel = override?.chatModel?.trim() || cfg.chatModel;
let apiKey = cfg.apiKey;
let baseUrl = cfg.baseUrl;
// A driver override that differs from the workspace driver needs that
// driver's own creds (the workspace driver's key would be wrong/absent).
if (overrideDriver && overrideDriver !== cfg.driver) {
if (overrideDriver === 'ollama') {
// Cross-driver override to ollama: the workspace driver is NOT ollama, so
// there is no configured ollama endpoint. `cfg.baseUrl` belongs to the
// workspace driver (e.g. an OpenAI/OpenRouter gateway) and pointing the
// ollama client at it would silently send requests to the wrong server.
// Fail explicitly (503) — a dedicated per-driver ollama endpoint is not
// supported yet. The same-driver ollama case (handled outside this block)
// legitimately reuses the workspace's ollama endpoint and is unaffected.
const who = override?.roleName ? ` for role "${override.roleName}"` : '';
throw new AiNotConfiguredException(
`An ollama model override${who} requires a dedicated ollama endpoint, ` +
`which is not supported when the workspace driver is "${cfg.driver}". ` +
`Set the role's driver to "${cfg.driver}" or switch the workspace ` +
`to ollama.`,
);
} else {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
overrideDriver,
);
apiKey = creds?.apiKeyEnc
? this.secretBox.decryptSecret(creds.apiKeyEnc)
: undefined;
if (!apiKey) {
// Explicit 503: the role chose a provider that is not set up. Name the
// driver (and role, when known) so the admin can fix it — no silent
// fallback to the workspace model (error-handling convention).
const who = override?.roleName ? ` for role "${override.roleName}"` : '';
throw new AiNotConfiguredException(
`The model provider "${overrideDriver}"${who} is selected but not ` +
`configured (no API key). Configure ${overrideDriver} in AI ` +
`settings or change the role's model.`,
);
}
// A cross-driver override does not carry the workspace baseUrl (that URL
// belongs to the workspace driver); use the provider default for the
// overridden driver.
baseUrl = undefined;
}
}
if (!chatModel || (driver !== 'ollama' && !apiKey)) {
throw new AiNotConfiguredException();
}
switch (driver) {
case 'openai':
// baseURL (when set) covers openai-compatible endpoints. Use Chat
// Completions (/chat/completions) — the portable OpenAI-compatible
@@ -51,14 +140,12 @@ export class AiService {
// Responses API (/responses), which OpenAI-compatible gateways
// (OpenRouter, etc.) reject on multi-turn requests (history with
// assistant messages) → 400.
return createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl }).chat(
cfg.chatModel,
);
return createOpenAI({ apiKey, baseURL: baseUrl }).chat(chatModel);
case 'gemini':
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(cfg.chatModel);
return createGoogleGenerativeAI({ apiKey })(chatModel);
case 'ollama':
// Ollama needs no API key.
return createOllama({ baseURL: cfg.baseUrl })(cfg.chatModel);
return createOllama({ baseURL: baseUrl })(chatModel);
default:
throw new AiNotConfiguredException();
}

View File

@@ -32,6 +32,15 @@ export interface AiProviderSettings {
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
systemPrompt?: string;
// Cheap chat model id used ONLY by the anonymous public-share assistant. The
// driver / baseUrl / apiKey of the main chat provider are reused; this is the
// model id only. Empty/unset → the public-share assistant falls back to
// `chatModel`. The workspace owner pays for anonymous tokens, so a cheaper
// model is preferred for read-only Q&A over published documentation.
publicShareChatModel?: string;
// Agent-role id whose persona the anonymous public-share assistant adopts;
// empty/unset = built-in locked persona.
publicShareAssistantRoleId?: string;
}
/**
@@ -47,6 +56,11 @@ export interface AiProviderSettings {
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
driver?: AiDriver;
chatModel?: string;
// Cheap model id for the public-share assistant; reuses the chat creds.
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts (empty/unset
// = built-in locked persona). Re-declared for parity with the explicit fields.
publicShareAssistantRoleId?: string;
apiKey?: string;
embeddingApiKey?: string;
sttApiKey?: string;
@@ -67,6 +81,10 @@ export interface MaskedAiSettings {
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
systemPrompt?: string;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty/unset
// = built-in locked persona.
publicShareAssistantRoleId?: string;
hasApiKey: boolean;
hasEmbeddingApiKey: boolean;
hasSttApiKey: boolean;

View File

@@ -57,4 +57,16 @@ export class UpdateAiSettingsDto {
@IsOptional()
@IsString()
sttApiKey?: string;
// Cheap model id for the anonymous public-share assistant; reuses the chat
// driver/baseUrl/apiKey. Empty → the assistant falls back to chatModel.
@IsOptional()
@IsString()
publicShareChatModel?: string;
// Agent-role id whose persona the anonymous public-share assistant adopts;
// empty/unset = built-in locked persona.
@IsOptional()
@IsString()
publicShareAssistantRoleId?: string;
}

View File

@@ -0,0 +1,77 @@
import { SecretBoxService } from './secret-box';
import { EnvironmentService } from '../environment/environment.service';
/**
* Unit tests for SecretBoxService: the AES-256-GCM helper that protects provider
* API keys at rest. The contract is: encrypt -> decrypt round-trips the input;
* two encryptions of the same input yield different blobs (random salt+iv) yet
* both decrypt; a tampered blob or a different APP_SECRET fails decryption with
* the recoverable "APP_SECRET may have changed" message the UI relies on.
*/
describe('SecretBoxService', () => {
// Construct a SecretBoxService whose EnvironmentService.getAppSecret returns a
// fixed 64-hex secret. Only getAppSecret is exercised, so a thin fake suffices.
function makeBox(appSecret: string): SecretBoxService {
const env = {
getAppSecret: () => appSecret,
} as unknown as EnvironmentService;
return new SecretBoxService(env);
}
const SECRET_A =
'00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
const SECRET_B =
'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
it('round-trips: decrypt(encrypt(x)) === x', () => {
const box = makeBox(SECRET_A);
const plain = 'sk-super-secret-provider-key-12345';
const blob = box.encryptSecret(plain);
expect(box.decryptSecret(blob)).toBe(plain);
});
it('produces a different blob each time, both of which decrypt', () => {
const box = makeBox(SECRET_A);
const plain = 'identical-input';
const blob1 = box.encryptSecret(plain);
const blob2 = box.encryptSecret(plain);
// Random per-record salt + iv => the ciphertext blobs must differ.
expect(blob1).not.toBe(blob2);
expect(box.decryptSecret(blob1)).toBe(plain);
expect(box.decryptSecret(blob2)).toBe(plain);
});
it('throws the recoverable error on a tampered auth tag', () => {
const box = makeBox(SECRET_A);
const blob = box.encryptSecret('tamper-me');
// Layout: base64( salt[16] | iv[12] | authTag[16] | ciphertext ). Flip a bit
// in the auth-tag region so GCM verification (decipher.final) rejects it.
const data = Buffer.from(blob, 'base64');
const authTagByteIndex = 16 + 12; // first byte of the auth tag
data[authTagByteIndex] = data[authTagByteIndex] ^ 0xff;
const tampered = data.toString('base64');
expect(() => box.decryptSecret(tampered)).toThrow(/APP_SECRET may have changed/);
});
it('throws the recoverable error on a tampered ciphertext byte', () => {
const box = makeBox(SECRET_A);
const blob = box.encryptSecret('tamper-the-body');
const data = Buffer.from(blob, 'base64');
// Last byte is part of the ciphertext; flipping it must fail GCM auth.
data[data.length - 1] = data[data.length - 1] ^ 0xff;
const tampered = data.toString('base64');
expect(() => box.decryptSecret(tampered)).toThrow(/APP_SECRET may have changed/);
});
it('throws when decrypting under a different APP_SECRET', () => {
const boxA = makeBox(SECRET_A);
const boxB = makeBox(SECRET_B);
const blob = boxA.encryptSecret('rotate-me');
// A different APP_SECRET derives a different scrypt key => GCM auth fails.
expect(() => boxB.decryptSecret(blob)).toThrow(/APP_SECRET may have changed/);
});
});

View File

@@ -214,6 +214,13 @@ export class EnvironmentService {
return !this.isCloud();
}
isCompactPageTreeEnabled(): boolean {
const compactTree = this.configService
.get<string>('COMPACT_PAGE_TREE', 'true')
.toLowerCase();
return compactTree === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}

View File

@@ -552,9 +552,13 @@ export class FileImportTaskService {
}
if (validPageIds.size > 0) {
// Carry the destination spaceId so the WS listener can trigger a root
// refetch for the imported subtree (no `pages` snapshot -> refetch
// fallback rather than per-node addTreeNode).
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: Array.from(validPageIds),
workspaceId: fileTask.workspaceId,
spaceId: fileTask.spaceId,
});
}

View File

@@ -0,0 +1,533 @@
// Pure, self-contained helpers for the embedded /mcp per-user auth flow. They
// are deliberately framework-free (no Nest, no DI, no concrete service imports)
// so they can be unit-tested in isolation WITHOUT loading the heavy auth/space
// dependency graph, and reused by McpService. Nothing here logs the password or
// the Authorization header.
import { UnauthorizedException } from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
/**
* Decode an `Authorization: Basic base64(email:password)` header into its
* email/password parts. The split is on the FIRST ':' because a password may
* itself contain ':' characters (everything after the first ':' is the
* password). Returns null when the header is absent or not a Basic header, or
* when no ':' separator is present (malformed credentials).
*/
export function parseBasicAuth(
authHeader: string | undefined,
): { email: string; password: string } | null {
if (!authHeader || !authHeader.startsWith('Basic ')) return null;
const b64 = authHeader.slice('Basic '.length).trim();
let decoded: string;
try {
decoded = Buffer.from(b64, 'base64').toString('utf8');
} catch {
return null;
}
const sep = decoded.indexOf(':');
if (sep === -1) return null; // no separator -> not valid email:password
const email = decoded.slice(0, sep);
if (!email) return null; // empty email -> not valid credentials
return {
email,
password: decoded.slice(sep + 1),
};
}
/**
* Lightweight in-memory, per-key fixed-window rate limiter for FAILED /mcp
* Basic logins. Calling AuthService.login directly bypasses the controller's
* ThrottlerGuard, so this blunts brute-force attempts against /mcp. State lives
* in-process (per server instance); it is intentionally simple and not shared
* across a cluster — it is a speed bump, not a hard security boundary.
*
* A key is typically `<ip>` and/or `<ip>:<email>`. When the number of failures
* within `windowMs` reaches `threshold`, `isBlocked` returns true until the
* window rolls over. A SUCCESSFUL login should clear the key via `reset`.
*/
export class FailedLoginLimiter {
private readonly windowMs: number;
private readonly threshold: number;
// key -> { count, windowStart }
private readonly buckets = new Map<
string,
{ count: number; windowStart: number }
>();
constructor(threshold = 5, windowMs = 60_000) {
this.threshold = threshold;
this.windowMs = windowMs;
}
private bucket(key: string, now: number) {
const existing = this.buckets.get(key);
if (!existing || now - existing.windowStart >= this.windowMs) {
const fresh = { count: 0, windowStart: now };
this.buckets.set(key, fresh);
return fresh;
}
return existing;
}
/** True when the key has already reached the failure threshold this window. */
isBlocked(key: string, now: number = Date.now()): boolean {
const b = this.bucket(key, now);
return b.count >= this.threshold;
}
/** Record one failed attempt for the key (within the current window). */
recordFailure(key: string, now: number = Date.now()): void {
const b = this.bucket(key, now);
b.count += 1;
}
/** Clear the key after a successful login so it does not accumulate. */
reset(key: string): void {
this.buckets.delete(key);
}
/** Drop expired buckets to bound memory. Safe to call periodically. */
sweep(now: number = Date.now()): void {
for (const [key, b] of this.buckets) {
if (now - b.windowStart >= this.windowMs) this.buckets.delete(key);
}
}
}
// The per-session DocmostMcpConfig shape understood by @docmost/mcp: either the
// service-account credentials variant OR the per-user getToken variant.
export type DocmostMcpConfig =
| { apiUrl: string; email: string; password: string }
| { apiUrl: string; getToken: () => Promise<string> };
export interface ResolvedMcpAuth {
config: DocmostMcpConfig;
// Opaque identity key bound to the MCP session for anti-fixation, or
// undefined when no per-user identity applies.
identity?: string;
}
// Narrow collaborator interfaces so this module never imports the concrete
// AuthService/TokenService/WorkspaceRepo classes (which drag in the heavy
// auth/space graph). McpService passes its injected instances; tests pass
// stubs. Decouples the testable decision logic from Nest DI wiring.
export interface McpAuthDeps {
apiUrl: string;
email?: string;
password?: string;
findWorkspace: () => Promise<{ id: string } | undefined>;
// Pre-token gate for the Basic path ONLY, replicating what AuthController.login
// does BEFORE issuing a token: validateSsoEnforcement(workspace) and the lazy
// EE MFA requirement check. It is invoked with the resolved (default)
// workspace right after it is loaded and BEFORE any login()/verifyCredentials()
// call, so an SSO-enforced workspace or an MFA-required user never gets a token
// via /mcp Basic. It MUST throw (UnauthorizedException) to reject; on a fork
// without the EE MFA module bundled it behaves exactly like the controller
// (no MFA module -> no MFA gate). The Bearer path skips this gate because those
// ACCESS JWTs were already minted post-gate by the normal controller login.
// Optional so existing callers/tests that don't exercise the gate are unchanged.
enforceBasicGate?: (
workspace: { id: string },
creds: { email: string; password: string },
) => Promise<void> | void;
// Full login: mints a user session + JWT, writes the USER_LOGIN audit event
// and updates lastLoginAt. Called at MOST once per MCP session (at the
// session-init request) so we do not spam the audit log / user_sessions table
// on every tool call.
login: (
creds: { email: string; password: string },
workspaceId: string,
) => Promise<string>;
// Non-side-effecting credential check: same lookup/password/email-verified/
// disabled checks as login() but mints NO session, writes NO audit row,
// updates NO lastLoginAt. Used for per-request anti-fixation re-validation on
// SUBSEQUENT requests so a correct repeat does not spawn a new DB session,
// while a wrong password still throws (preserving anti-fixation).
verifyCredentials: (
creds: { email: string; password: string },
workspaceId: string,
) => Promise<void>;
// Bearer access-JWT verification. Verifies signature/exp/type AND (in the
// McpService wiring) session-active + user-not-disabled, mirroring JwtStrategy
// so a revoked/logged-out/disabled user with an unexpired token is rejected.
verifyAccessJwt: (token: string) => Promise<{ sub?: string; email?: string }>;
limiter: FailedLoginLimiter;
clientIp: string;
// True when this is the session-INIT request (no mcp-session-id header).
// INIT mints a user session via login(); SUBSEQUENT requests only re-validate
// credentials via verifyCredentials() (no side effects). See resolveMcp...
isSessionInit: boolean;
}
/**
* True when an error from login()/verifyCredentials() represents an actual
* CREDENTIALS failure (unknown email, disabled user, or wrong password) — i.e.
* a guessed-password signal that should count toward the brute-force limiter.
*
* It must NOT match business errors like "email not verified" (a
* BadRequestException), which are a legitimate 401/400 surface but not a
* password-guess signal — counting those would let an attacker burn a victim's
* limiter budget (DoS) and would dilute the brute-force signal. AuthService
* throws an UnauthorizedException with exactly this message for every
* credentials-mismatch case (no user / disabled / wrong password), so we match
* on that.
*
* The message is NOT hardcoded here: it matches against the shared
* CREDENTIALS_MISMATCH_MESSAGE constant that AuthService.verifyUserCredentials
* also throws, so a reworded auth error cannot silently stop counting toward the
* limiter (single source of truth — see auth.constants.ts).
*/
export function isCredentialsFailure(err: unknown): boolean {
return (
err instanceof UnauthorizedException &&
typeof err.message === 'string' &&
err.message
.toLowerCase()
.includes(CREDENTIALS_MISMATCH_MESSAGE.toLowerCase())
);
}
/**
* Constant-time comparison of the optional shared X-MCP-Token guard. A header
* value may arrive as string | string[] (multiple X-MCP-Token headers), so we
* normalise to the first string. crypto.timingSafeEqual avoids leaking the
* token's length via early-exit string comparison; it requires equal buffer
* lengths, so a length mismatch is treated as a non-match WITHOUT calling
* timingSafeEqual (which throws on unequal lengths). A non-string / undefined
* value is never a match.
*
* Pure and framework-free so it is unit-testable; McpService.handle delegates to
* it for the X-MCP-Token shared guard.
*/
export function sharedTokenMatches(
expected: string,
provided: string | string[] | undefined,
): boolean {
const value = Array.isArray(provided) ? provided[0] : provided;
if (typeof value !== 'string') return false;
const a = Buffer.from(value);
const b = Buffer.from(expected);
// Early-return before timingSafeEqual, which throws on unequal-length buffers.
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// Minimal structural shape of the bits of a Fastify request that `clientIp`
// needs. Kept structural so this module never imports the Fastify types.
export interface ClientIpRequest {
ip?: string;
socket?: { remoteAddress?: string };
headers: Record<string, string | string[] | undefined>;
}
/**
* Best-effort client IP for the failed-login limiter key. Precedence:
* 1. req.ip — Fastify's resolved IP (honours a configured trustProxy
* chain); the trustworthy value when a proxy is set up.
* 2. socket.remoteAddress — the raw TCP peer, used only when req.ip is absent.
* 3. first X-Forwarded-For hop — LAST resort only, because XFF is
* client-forgeable when no trusted proxy is configured.
* 4. 'unknown' — nothing usable.
*
* A forged IP can only dodge the per-IP limiter keys; the GLOBAL per-email key
* in resolveMcpSessionConfig is the real account-brute backstop and does not
* depend on this value. Pure/framework-free so it is unit-testable; McpService
* delegates to it.
*/
export function clientIp(req: ClientIpRequest): string {
if (req.ip) return req.ip;
if (req.socket?.remoteAddress) return req.socket.remoteAddress;
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string' && xff.length > 0) {
return xff.split(',')[0].trim();
}
return 'unknown';
}
// Minimal structural shape of the TokenService.verifyJwt method we depend on,
// so this module never imports the concrete TokenService (heavy graph).
export interface AccessJwtVerifier {
verifyJwt: (
token: string,
type: JwtType,
) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}>;
}
/**
* Bind a TokenService-like verifier into a one-arg `verifyJwt(token)` that
* ALWAYS enforces `JwtType.ACCESS`. This is the single place where the /mcp
* Bearer path pins the token type: a Bearer access token must be verified AS an
* access token (not refresh/exchange/collab/etc.), so the type literal is fixed
* here rather than at the call site. McpService.verifyMcpBearer delegates to
* this, keeping the `JwtType.ACCESS` choice testable without the heavy graph.
*/
export function bindAccessJwtVerifier(
tokenService: AccessJwtVerifier,
): (token: string) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}> {
return (token: string) => tokenService.verifyJwt(token, JwtType.ACCESS);
}
// Minimal shapes for the Bearer revocation/disabled check. Kept structural so
// this module never imports the concrete repos/JwtPayload (heavy graph).
export interface BearerVerifyDeps {
// Verify signature/exp and that type === ACCESS; returns the decoded payload.
verifyJwt: (
token: string,
) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}>;
// Load the user (or undefined) for the disabled check.
findUser: (
sub: string,
workspaceId: string,
) => Promise<{ deactivatedAt?: Date | null; deletedAt?: Date | null } | undefined>;
// Load an ACTIVE (not revoked, not expired) session by id, or undefined.
findActiveSession: (
sessionId: string,
) => Promise<{ userId: string; workspaceId: string } | undefined>;
}
/**
* Verify a /mcp Bearer access JWT to the SAME strength as JwtStrategy: not just
* signature/exp/type (verifyJwt), but also that the user is not disabled and —
* when the token carries a sessionId — that the session is still active and
* belongs to that user+workspace. This rejects a logged-out/revoked or disabled
* user who still holds an unexpired access token. Throws UnauthorizedException
* on any failure; never leaks why (uniform "Invalid or expired token").
*/
export async function verifyBearerAccess(
token: string,
deps: BearerVerifyDeps,
): Promise<{ sub?: string; email?: string }> {
const generic = 'Invalid or expired token';
const payload = await deps.verifyJwt(token);
if (!payload.sub || !payload.workspaceId) {
throw new UnauthorizedException(generic);
}
const user = await deps.findUser(payload.sub, payload.workspaceId);
if (!user || user.deactivatedAt || user.deletedAt) {
throw new UnauthorizedException(generic);
}
if (payload.sessionId) {
const session = await deps.findActiveSession(payload.sessionId);
if (
!session ||
session.userId !== payload.sub ||
session.workspaceId !== payload.workspaceId
) {
throw new UnauthorizedException(generic);
}
}
return { sub: payload.sub, email: payload.email };
}
/**
* Detect a genuine JSON-RPC `initialize` request from an already-parsed body.
* Mirrors the @modelcontextprotocol/sdk `isInitializeRequest` signal that
* packages/mcp/src/http.ts uses to decide whether to mint a session, but
* framework/SDK-free so it is unit-testable and usable from the CommonJS
* McpService. An initialize request is a single JSON-RPC object whose `method`
* is exactly 'initialize'; a batch (array) body is never an initialize request.
*
* This is the second half of the session-INIT decision: `isSessionInit` is
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Using it
* ensures the side-effecting login() (user_sessions insert + USER_LOGIN audit +
* lastLoginAt) only runs for a real initialize, never for an arbitrary
* header-less request that http.ts will subsequently 400.
*/
export function isInitializeRequestBody(body: unknown): boolean {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
return (body as { method?: unknown }).method === 'initialize';
}
/** Extract a Bearer token from an Authorization header (case-insensitive). */
export function extractBearer(
authHeader: string | undefined,
): string | undefined {
const [type, token] = authHeader?.split(' ') ?? [];
return type?.toLowerCase() === 'bearer' ? token : undefined;
}
/**
* Pure decision logic for the /mcp per-session identity. Precedence:
* 1. HTTP Basic (email:password) -> validate via `login`, issue the user's
* JWT, run as that user (chosen path). Throttle FAILED logins per IP/email.
* 2. Authorization: Bearer <jwt> -> verify as an ACCESS JWT, run with it.
* 3. Env service account -> back-compat fallback.
* 4. none -> meaningful 401.
*
* Throws UnauthorizedException with a SPECIFIC reason on failure (never a
* generic "MCP error"); never returns/logs the password or the Authorization
* header. The `JwtType.ACCESS` enforcement lives in `verifyAccessJwt`.
*/
export async function resolveMcpSessionConfig(
authHeader: string | undefined,
deps: McpAuthDeps,
): Promise<ResolvedMcpAuth> {
const { apiUrl } = deps;
// --- 1) chosen path: Basic login/password ---
const basic = parseBasicAuth(authHeader);
if (basic) {
const emailLc = basic.email.toLowerCase();
const ipKey = `ip:${deps.clientIp}`;
const ipEmailKey = `ip-email:${deps.clientIp}:${emailLc}`;
// GLOBAL per-email key (no IP). Without this an attacker who rotates IP /
// X-Forwarded-For evades the per-IP and per-IP+email keys entirely and can
// brute a single account unthrottled. Keying one extra bucket on the email
// alone closes that account-brute hole regardless of source address.
// XFF tradeoff: clientIp is derived from the first X-Forwarded-For hop when
// present (see McpService.clientIp), which a client can forge when no
// 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)
) {
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.
try {
if (deps.isSessionInit) {
const authToken = await deps.login(
{ email: basic.email, password: basic.password },
workspace.id,
);
deps.limiter.reset(ipKey);
deps.limiter.reset(ipEmailKey);
deps.limiter.reset(emailKey);
return {
config: { apiUrl, getToken: async () => authToken },
identity: `basic:${emailLc}`,
};
}
await deps.verifyCredentials(
{ email: basic.email, password: basic.password },
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);
}
const message =
err instanceof Error && err.message
? err.message
: 'Email or password does not match';
throw new UnauthorizedException(message);
}
// Subsequent request, credentials valid: clear the per-IP and per-IP+email
// budget, but DELIBERATELY do NOT reset the GLOBAL per-email key here. That
// email key is the only brute-force backstop that survives IP/XFF rotation;
// resetting it on every periodic tool call of a victim's live MCP session
// would repeatedly wipe a parallel attacker's failed-login budget for that
// 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.
deps.limiter.reset(ipKey);
deps.limiter.reset(ipEmailKey);
return {
config: { apiUrl, getToken: async () => '' },
identity: `basic:${emailLc}`,
};
}
// --- 2) fallback A: Bearer access-JWT (user-supplied token) ---
const bearer = extractBearer(authHeader);
if (bearer) {
let payload: { sub?: string; email?: string };
try {
payload = await deps.verifyAccessJwt(bearer);
} catch (err) {
const message =
err instanceof Error && err.message
? err.message
: 'Invalid or expired token';
throw new UnauthorizedException(message);
}
return {
config: { apiUrl, getToken: async () => bearer },
identity: `bearer:${payload.sub ?? payload.email ?? 'unknown'}`,
};
}
// --- 3) fallback B: env service account (existing behaviour, optional) ---
if (deps.email && deps.password) {
return {
config: { apiUrl, email: deps.email, password: deps.password },
identity: 'service-account',
};
}
// --- 4) nothing usable ---
throw new UnauthorizedException(
'MCP requires HTTP Basic auth (email:password) or a Bearer access token, ' +
'or a configured MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account.',
);
}
// Re-export JwtType so callers binding `verifyAccessJwt` know which type to
// enforce, without importing it separately.
export { JwtType };

View File

@@ -3,13 +3,16 @@ import { McpController } from './mcp.controller';
import { McpService } from './mcp.service';
import { DatabaseModule } from '@docmost/db/database.module';
import { EnvironmentModule } from '../environment/environment.module';
import { AuthModule } from '../../core/auth/auth.module';
import { TokenModule } from '../../core/auth/token.module';
// Community MCP feature: the server itself serves the Model Context Protocol
// over HTTP at /mcp. DatabaseModule (global) provides WorkspaceRepo and
// EnvironmentModule (global) provides EnvironmentService; both are imported
// explicitly for clarity.
// EnvironmentModule (global) provides EnvironmentService. AuthModule supplies
// AuthService (per-user HTTP-Basic login validation) and TokenModule supplies
// TokenService (Bearer access-JWT verification for the token fallback).
@Module({
imports: [DatabaseModule, EnvironmentModule],
imports: [DatabaseModule, EnvironmentModule, AuthModule, TokenModule],
controllers: [McpController],
providers: [McpService],
})

View File

@@ -0,0 +1,771 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import {
parseBasicAuth,
FailedLoginLimiter,
resolveMcpSessionConfig,
isCredentialsFailure,
isInitializeRequestBody,
verifyBearerAccess,
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
McpAuthDeps,
} from './mcp-auth.helpers';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
// The /mcp per-user auth decision logic is tested through the framework-free
// `resolveMcpSessionConfig` helper that McpService delegates to. McpService
// itself cannot be instantiated under jest because importing AuthService drags
// in the React email templates + queue constants graph; extracting the pure
// logic (and wiring it in) keeps it both tested AND used (per the plan).
function basicHeader(email: string, password: string): string {
return 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
}
function makeDeps(over: Partial<McpAuthDeps> = {}): McpAuthDeps {
return {
apiUrl: 'http://127.0.0.1:3000/api',
email: over.email,
password: over.password,
findWorkspace:
over.findWorkspace ?? jest.fn().mockResolvedValue({ id: 'ws-1' }),
login: over.login ?? jest.fn().mockResolvedValue('issued-user-jwt'),
verifyCredentials:
over.verifyCredentials ?? jest.fn().mockResolvedValue(undefined),
verifyAccessJwt:
over.verifyAccessJwt ??
jest.fn().mockResolvedValue({ sub: 'user-1', email: 'u@e.com' }),
// Default gate is a no-op (pass-through), matching a build with no SSO
// enforcement and no EE MFA module. Individual tests override it to assert
// the SSO/MFA reject behaviour.
enforceBasicGate: over.enforceBasicGate,
limiter: over.limiter ?? new FailedLoginLimiter(5, 60_000),
clientIp: over.clientIp ?? '10.0.0.1',
// Default to the session-INIT request (no mcp-session-id) so existing
// assertions about login() being called keep their meaning.
isSessionInit: over.isSessionInit ?? true,
};
}
describe('parseBasicAuth', () => {
it('decodes email:password', () => {
expect(parseBasicAuth(basicHeader('a@b.com', 'pw'))).toEqual({
email: 'a@b.com',
password: 'pw',
});
});
it('splits on the FIRST colon so passwords may contain colons', () => {
expect(parseBasicAuth(basicHeader('a@b.com', 'p:w:x'))).toEqual({
email: 'a@b.com',
password: 'p:w:x',
});
});
it('returns null for non-Basic / malformed headers', () => {
expect(parseBasicAuth(undefined)).toBeNull();
expect(parseBasicAuth('Bearer xyz')).toBeNull();
expect(
parseBasicAuth('Basic ' + Buffer.from('nocolon').toString('base64')),
).toBeNull();
});
it('returns null when the email part is empty (":password")', () => {
expect(
parseBasicAuth('Basic ' + Buffer.from(':pw').toString('base64')),
).toBeNull();
});
});
describe('isCredentialsFailure', () => {
it('is true for the credentials-mismatch UnauthorizedException', () => {
expect(
isCredentialsFailure(
new UnauthorizedException('Email or password does not match'),
),
).toBe(true);
});
it('is false for business errors like email-not-verified', () => {
expect(
isCredentialsFailure(
new BadRequestException('Please verify your email address.'),
),
).toBe(false);
expect(isCredentialsFailure(new Error('boom'))).toBe(false);
});
// --- Cross-file coupling lock (item 1) ---------------------------------
// The /mcp Basic brute-force limiter ONLY counts a failure when
// isCredentialsFailure(err) is true. AuthService.verifyUserCredentials throws
// the credentials failure with the shared CREDENTIALS_MISMATCH_MESSAGE for
// unknown email / wrong password / disabled user. If that message were
// reworded without updating the matcher, the limiter would stop counting and
// /mcp Basic would become an unthrottled password-guessing oracle. These
// tests lock the coupling to the SHARED constant (single source of truth) so a
// reword is a compile-time/test-time break, not a silent security regression.
it('recognises the exact UnauthorizedException AuthService throws (the shared constant)', () => {
// Reconstruct the EXACT exception AuthService.verifyUserCredentials throws
// for every credentials-failure case (it uses CREDENTIALS_MISMATCH_MESSAGE),
// and assert the REAL isCredentialsFailure recognises it. No hardcoded string
// is duplicated here — both sides reference the single shared constant.
const authThrows = new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE);
expect(isCredentialsFailure(authThrows)).toBe(true);
});
it('the matcher is coupled to the single source of truth, not a local literal', () => {
// If someone reworded CREDENTIALS_MISMATCH_MESSAGE, this still passes only
// because the matcher derives its substring from the SAME constant. This
// pins the coupling structurally: there is one message both files share.
expect(CREDENTIALS_MISMATCH_MESSAGE).toBeTruthy();
expect(
isCredentialsFailure(
new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE),
),
).toBe(true);
// A DIFFERENT message (a hypothetical reword that forgot to go through the
// constant) must NOT be silently recognised, proving the matcher is not just
// "always true".
expect(
isCredentialsFailure(new UnauthorizedException('totally different wording')),
).toBe(false);
});
});
describe('AuthService verifyUserCredentials <-> isCredentialsFailure coupling (item 1)', () => {
// AuthService cannot be constructed under jest: importing it pulls in
// src/integrations/queue/constants (a `src/`-rooted absolute import) which the
// jest moduleNameMapper does not resolve under rootDir:src — the heavy auth
// graph. So instead of a live AuthService unit, we assert the security
// contract structurally: AuthService.verifyUserCredentials throws an
// UnauthorizedException built from the SHARED CREDENTIALS_MISMATCH_MESSAGE
// (see auth.service.ts), and the REAL isCredentialsFailure recognises it. The
// single shared constant is the lock: there is no second copy of the string to
// drift out of sync.
it('the credentials-failure UnauthorizedException is counted by the limiter matcher', () => {
// unknown email / disabled user / wrong password all surface as this:
const credentialsFailure = new UnauthorizedException(
CREDENTIALS_MISMATCH_MESSAGE,
);
expect(isCredentialsFailure(credentialsFailure)).toBe(true);
});
it('email-not-verified (a different, business error) is NOT counted', () => {
// throwIfEmailNotVerified throws a BadRequestException, which must not burn a
// victim's limiter budget; the matcher rejects it.
expect(
isCredentialsFailure(
new BadRequestException('Please verify your email address.'),
),
).toBe(false);
});
});
describe('FailedLoginLimiter', () => {
it('blocks after threshold failures within the window; reset clears it', () => {
const lim = new FailedLoginLimiter(3, 1000);
const k = 'ip:1.2.3.4';
expect(lim.isBlocked(k, 0)).toBe(false);
lim.recordFailure(k, 0);
lim.recordFailure(k, 0);
expect(lim.isBlocked(k, 0)).toBe(false);
lim.recordFailure(k, 0);
expect(lim.isBlocked(k, 0)).toBe(true);
lim.reset(k);
expect(lim.isBlocked(k, 0)).toBe(false);
});
it('rolls over after the window', () => {
const lim = new FailedLoginLimiter(1, 1000);
const k = 'ip:1.2.3.4';
lim.recordFailure(k, 0);
expect(lim.isBlocked(k, 0)).toBe(true);
expect(lim.isBlocked(k, 1000)).toBe(false);
});
});
describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
const goodPayload = {
sub: 'user-1',
email: 'u@e.com',
workspaceId: 'ws-1',
sessionId: 'sess-1',
};
function bearerDeps(over: Partial<Parameters<typeof verifyBearerAccess>[1]> = {}) {
return {
verifyJwt: over.verifyJwt ?? jest.fn().mockResolvedValue(goodPayload),
findUser:
over.findUser ?? jest.fn().mockResolvedValue({ deactivatedAt: null }),
findActiveSession:
over.findActiveSession ??
jest
.fn()
.mockResolvedValue({ userId: 'user-1', workspaceId: 'ws-1' }),
};
}
it('valid token + active session + enabled user -> resolves identity', async () => {
const res = await verifyBearerAccess('t', bearerDeps());
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
});
it('rejects when the session is no longer active (logged out / revoked)', async () => {
await expect(
verifyBearerAccess(
't',
bearerDeps({ findActiveSession: jest.fn().mockResolvedValue(undefined) }),
),
).rejects.toThrow(UnauthorizedException);
});
it('rejects when the session belongs to a different user', async () => {
await expect(
verifyBearerAccess(
't',
bearerDeps({
findActiveSession: jest
.fn()
.mockResolvedValue({ userId: 'other', workspaceId: 'ws-1' }),
}),
),
).rejects.toThrow(UnauthorizedException);
});
it('rejects when the user is disabled (deactivated/deleted)', async () => {
await expect(
verifyBearerAccess(
't',
bearerDeps({
findUser: jest.fn().mockResolvedValue({ deactivatedAt: new Date() }),
}),
),
).rejects.toThrow(UnauthorizedException);
await expect(
verifyBearerAccess(
't',
bearerDeps({ findUser: jest.fn().mockResolvedValue(undefined) }),
),
).rejects.toThrow(UnauthorizedException);
});
it('propagates a verifyJwt failure (bad signature/exp/type)', async () => {
await expect(
verifyBearerAccess(
't',
bearerDeps({
verifyJwt: jest
.fn()
.mockRejectedValue(new UnauthorizedException('jwt expired')),
}),
),
).rejects.toThrow('jwt expired');
});
});
describe('resolveMcpSessionConfig', () => {
it('Basic good creds -> calls login with the default workspace, returns a getToken config', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const findWorkspace = jest.fn().mockResolvedValue({ id: 'ws-1' });
const resolved = await resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, findWorkspace }),
);
expect(findWorkspace).toHaveBeenCalled();
expect(login).toHaveBeenCalledWith(
{ email: 'user@example.com', password: 'pw' },
'ws-1',
);
expect('getToken' in resolved.config).toBe(true);
const cfg = resolved.config as { getToken: () => Promise<string> };
await expect(cfg.getToken()).resolves.toBe('issued-user-jwt');
expect(resolved.identity).toBe('basic:user@example.com');
});
it('Basic password containing a colon is split on the first colon', async () => {
const login = jest.fn().mockResolvedValue('jwt');
await resolveMcpSessionConfig(
basicHeader('user@example.com', 'a:b:c'),
makeDeps({ login }),
);
expect(login).toHaveBeenCalledWith(
{ email: 'user@example.com', password: 'a:b:c' },
'ws-1',
);
});
it('Basic bad creds -> specific 401 (not generic) and increments the limiter', async () => {
const limiter = new FailedLoginLimiter(5, 60_000);
const login = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('Email or password does not match'),
);
const deps = makeDeps({ login, limiter });
await expect(
resolveMcpSessionConfig(basicHeader('user@example.com', 'wrong'), deps),
).rejects.toThrow('Email or password does not match');
// The failure was recorded; drive to the threshold (5) -> throttled message.
for (let i = 0; i < 4; i++) {
await resolveMcpSessionConfig(
basicHeader('user@example.com', 'wrong'),
deps,
).catch(() => undefined);
}
await expect(
resolveMcpSessionConfig(basicHeader('user@example.com', 'wrong'), deps),
).rejects.toThrow(/Too many failed MCP login attempts/);
});
it('Bearer -> verifies as ACCESS and returns a getToken config', async () => {
const verifyAccessJwt = jest
.fn()
.mockResolvedValue({ sub: 'user-9', email: 'u@e.com' });
const resolved = await resolveMcpSessionConfig(
'Bearer some.jwt.value',
makeDeps({ verifyAccessJwt }),
);
expect(verifyAccessJwt).toHaveBeenCalledWith('some.jwt.value');
const cfg = resolved.config as { getToken: () => Promise<string> };
await expect(cfg.getToken()).resolves.toBe('some.jwt.value');
expect(resolved.identity).toBe('bearer:user-9');
});
it('Bearer invalid -> specific 401 from verifyAccessJwt', async () => {
const verifyAccessJwt = jest
.fn()
.mockRejectedValue(new UnauthorizedException('jwt expired'));
await expect(
resolveMcpSessionConfig('Bearer expired', makeDeps({ verifyAccessJwt })),
).rejects.toThrow('jwt expired');
});
it('no creds + env service account configured -> service-account config', async () => {
const resolved = await resolveMcpSessionConfig(
undefined,
makeDeps({ email: 'svc@example.com', password: 'svcpw' }),
);
expect('email' in resolved.config).toBe(true);
const cfg = resolved.config as { email: string; password: string };
expect(cfg.email).toBe('svc@example.com');
expect(cfg.password).toBe('svcpw');
expect(resolved.identity).toBe('service-account');
});
it('no creds + no env service account -> meaningful 401 listing accepted methods', async () => {
await expect(
resolveMcpSessionConfig(undefined, makeDeps()),
).rejects.toThrow(/HTTP Basic auth.*Bearer access token.*service account/s);
});
it('SESSION INIT Basic -> mints a session via login() (verifyCredentials NOT called)', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
const resolved = await resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, verifyCredentials, isSessionInit: true }),
);
expect(login).toHaveBeenCalledTimes(1);
expect(verifyCredentials).not.toHaveBeenCalled();
const cfg = resolved.config as { getToken: () => Promise<string> };
await expect(cfg.getToken()).resolves.toBe('issued-user-jwt');
expect(resolved.identity).toBe('basic:user@example.com');
});
it('SUBSEQUENT Basic correct creds -> uses verifyCredentials, NEVER login() (no new session/audit), same identity', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
const resolved = await resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, verifyCredentials, isSessionInit: false }),
);
// The side-effecting login() (audit + lastLoginAt + user_sessions insert)
// is NOT hit on a subsequent request: only the non-side-effecting verify.
expect(login).not.toHaveBeenCalled();
expect(verifyCredentials).toHaveBeenCalledWith(
{ email: 'user@example.com', password: 'pw' },
'ws-1',
);
// Identity still matches the init identity so anti-fixation accepts it.
expect(resolved.identity).toBe('basic:user@example.com');
});
it('SUBSEQUENT Basic wrong password -> still 401 (anti-fixation), without minting a session', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('Email or password does not match'),
);
await expect(
resolveMcpSessionConfig(
basicHeader('user@example.com', 'wrong'),
makeDeps({ login, verifyCredentials, isSessionInit: false }),
),
).rejects.toThrow('Email or password does not match');
expect(login).not.toHaveBeenCalled();
});
it('global per-email limiter key blocks an attacker rotating IP/XFF for one account', async () => {
const limiter = new FailedLoginLimiter(5, 60_000);
const login = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('Email or password does not match'),
);
// 5 failures against the SAME email but DIFFERENT IPs each time. The per-IP
// and per-IP+email keys never accumulate, but the global per-email key does.
for (let i = 0; i < 5; i++) {
await resolveMcpSessionConfig(
basicHeader('victim@example.com', 'wrong'),
makeDeps({ login, limiter, clientIp: `10.0.0.${i}` }),
).catch(() => undefined);
}
// A 6th attempt from yet another fresh IP is now throttled purely by the
// email key — proving IP/XFF rotation no longer evades the limiter.
await expect(
resolveMcpSessionConfig(
basicHeader('victim@example.com', 'wrong'),
makeDeps({ login, limiter, clientIp: '10.0.0.99' }),
),
).rejects.toThrow(/Too many failed MCP login attempts/);
});
it('limiter does NOT count business errors (email not verified) as a failed login', async () => {
const limiter = new FailedLoginLimiter(1, 60_000);
const login = jest
.fn()
.mockRejectedValue(
new BadRequestException('Please verify your email address.'),
);
const deps = () =>
makeDeps({ login, limiter, clientIp: '10.0.0.7' });
// First attempt: business error, surfaced as 401, but must NOT increment.
await resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
deps(),
).catch(() => undefined);
// With threshold 1, if it had counted, the next attempt would be throttled.
// Instead it should reach login() again (same business error, NOT throttle).
await expect(
resolveMcpSessionConfig(basicHeader('user@example.com', 'pw'), deps()),
).rejects.toThrow(/verify your email/);
});
it('anti-fixation: different users yield different identity keys (compared by the http identify hook)', async () => {
const a = await resolveMcpSessionConfig(
basicHeader('alice@example.com', 'pw'),
makeDeps(),
);
const b = await resolveMcpSessionConfig(
basicHeader('bob@example.com', 'pw'),
makeDeps(),
);
expect(a.identity).toBe('basic:alice@example.com');
expect(b.identity).toBe('basic:bob@example.com');
expect(a.identity).not.toBe(b.identity);
});
// --- BLOCKER: SSO/MFA pre-token gate on the Basic path ---
it('Basic rejected (no token) when the SSO/MFA gate throws (SSO enforced)', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
// The service wires enforceBasicGate to validateSsoEnforcement + the lazy
// MFA check. Here we stub it to throw as it would for an SSO-enforced
// workspace; the gate runs BEFORE login()/verifyCredentials, so no token.
const enforceBasicGate = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('This workspace has enforced SSO login.'),
);
await expect(
resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, verifyCredentials, enforceBasicGate }),
),
).rejects.toThrow(/enforced SSO/);
expect(enforceBasicGate).toHaveBeenCalledWith(
{ id: 'ws-1' },
{ email: 'user@example.com', password: 'pw' },
);
// The pre-token gate fired first: no token-minting login() and no
// verifyCredentials() happened.
expect(login).not.toHaveBeenCalled();
expect(verifyCredentials).not.toHaveBeenCalled();
});
it('Basic rejected with a "use a Bearer token" message when MFA is required', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
// Mirror McpService.enforceBasicLoginGate when the EE MFA module is present
// and the user has MFA: it throws telling the caller to use a Bearer token.
const enforceBasicGate = jest
.fn()
.mockRejectedValue(
new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
),
);
await expect(
resolveMcpSessionConfig(
basicHeader('mfa-user@example.com', 'pw'),
makeDeps({ login, enforceBasicGate }),
),
).rejects.toThrow(/use a Bearer access token/);
expect(login).not.toHaveBeenCalled();
});
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.
const enforceBasicGate = jest.fn();
const resolved = await resolveMcpSessionConfig(
'Bearer some.jwt.value',
makeDeps({ enforceBasicGate }),
);
expect(enforceBasicGate).not.toHaveBeenCalled();
expect('getToken' in resolved.config).toBe(true);
});
it('a session-INIT login() success DOES reset the global per-email key', async () => {
const limiter = new FailedLoginLimiter(5, 60_000);
// Pre-load some failure budget on the global email key.
const emailKey = 'email:victim@example.com';
limiter.recordFailure(emailKey);
limiter.recordFailure(emailKey);
await resolveMcpSessionConfig(
basicHeader('victim@example.com', 'pw'),
makeDeps({ limiter, isSessionInit: true }),
);
// After a real init login, the deliberate authentication clears the email
// bucket entirely.
expect(limiter.isBlocked(emailKey)).toBe(false);
limiter.recordFailure(emailKey);
// Only one failure now (bucket was reset), so still far from threshold 5.
expect(limiter.isBlocked(emailKey)).toBe(false);
});
it('a SUBSEQUENT valid login does NOT reset the global per-email bucket (only per-IP keys)', async () => {
const limiter = new FailedLoginLimiter(2, 60_000);
const clientIp = '10.0.0.5';
const emailLc = 'victim@example.com';
const emailKey = `email:${emailLc}`;
const ipKey = `ip:${clientIp}`;
const ipEmailKey = `ip-email:${clientIp}:${emailLc}`;
// An attacker (different IP rotation) has driven the global email key to the
// threshold; also seed the per-IP keys for the victim's own IP.
limiter.recordFailure(emailKey);
limiter.recordFailure(emailKey);
limiter.recordFailure(ipKey);
limiter.recordFailure(ipEmailKey);
// The victim's live session would be throttled too (shared email key), so to
// exercise the SUBSEQUENT success path we use a SEPARATE limiter assertion:
// verify the reset behaviour directly on the keys the helper touches. Build a
// limiter where only the per-IP budget is set so the request is not blocked.
const lim2 = new FailedLoginLimiter(2, 60_000);
lim2.recordFailure(emailKey); // 1 failure on the global email key
lim2.recordFailure(ipKey);
lim2.recordFailure(ipEmailKey);
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
await resolveMcpSessionConfig(
basicHeader(emailLc, 'pw'),
makeDeps({ limiter: lim2, clientIp, verifyCredentials, isSessionInit: false }),
);
expect(verifyCredentials).toHaveBeenCalled();
// Per-IP keys were cleared by the subsequent success...
expect(lim2.isBlocked(ipKey)).toBe(false);
// ...but the global per-email key was DELIBERATELY left intact (still 1).
lim2.recordFailure(emailKey); // -> 2 == threshold
expect(lim2.isBlocked(emailKey)).toBe(true);
});
});
describe('isInitializeRequestBody (session-INIT detection)', () => {
it('true only for a single JSON-RPC object with method === "initialize"', () => {
expect(isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' })).toBe(
true,
);
});
it('false for a non-initialize method (e.g. tools/call)', () => {
expect(
isInitializeRequestBody({ jsonrpc: '2.0', method: 'tools/call' }),
).toBe(false);
});
it('false for a batch (array) body, null/undefined, or a non-object', () => {
expect(
isInitializeRequestBody([{ jsonrpc: '2.0', method: 'initialize' }]),
).toBe(false);
expect(isInitializeRequestBody(undefined)).toBe(false);
expect(isInitializeRequestBody(null)).toBe(false);
expect(isInitializeRequestBody('initialize')).toBe(false);
});
});
describe('isSessionInit decision (no mcp-session-id AND initialize body)', () => {
// The service computes isSessionInit = !mcp-session-id && isInitializeRequestBody(body).
// This proves a header-less but NON-initialize request is NOT treated as init,
// so it goes down the non-side-effecting verifyCredentials path (no orphan
// session/audit before http.ts 400s it).
const decide = (sessionId: string | undefined, body: unknown): boolean =>
!sessionId && isInitializeRequestBody(body);
it('no header + initialize body -> init', () => {
expect(decide(undefined, { method: 'initialize' })).toBe(true);
});
it('no header + non-initialize body -> NOT init (verifyCredentials path)', () => {
expect(decide(undefined, { method: 'tools/list' })).toBe(false);
});
it('has session-id -> never init regardless of body', () => {
expect(decide('sess-1', { method: 'initialize' })).toBe(false);
});
});
describe('resolveMcpSessionConfig non-initialize request side effects', () => {
it('header-less NON-initialize request does NOT call session-minting login() (uses verifyCredentials)', async () => {
// Simulate the service decision: no mcp-session-id but body is NOT initialize
// -> isSessionInit false -> the helper must use verifyCredentials, not login.
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
const isSessionInit = isInitializeRequestBody({ method: 'tools/call' }); // false
await resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, verifyCredentials, isSessionInit }),
);
expect(login).not.toHaveBeenCalled();
expect(verifyCredentials).toHaveBeenCalledWith(
{ email: 'user@example.com', password: 'pw' },
'ws-1',
);
});
});
describe('sharedTokenMatches (X-MCP-Token constant-time guard, item 2)', () => {
it('equal token -> true', () => {
expect(sharedTokenMatches('s3cr3t-token', 's3cr3t-token')).toBe(true);
});
it('wrong token of the SAME length -> false (timingSafeEqual path)', () => {
// Same length so it reaches timingSafeEqual; the bytes differ -> no match.
expect(sharedTokenMatches('aaaaaa', 'aaaaab')).toBe(false);
});
it('different-length token -> false WITHOUT throwing (early-return before timingSafeEqual)', () => {
// timingSafeEqual throws on unequal-length buffers; the early length check
// must short-circuit so a length mismatch is a clean non-match, not a throw.
expect(() => sharedTokenMatches('expected', 'short')).not.toThrow();
expect(sharedTokenMatches('expected', 'short')).toBe(false);
expect(sharedTokenMatches('expected', 'a-much-longer-provided-value')).toBe(
false,
);
});
it('array-valued header -> uses the FIRST element', () => {
// Multiple X-MCP-Token headers arrive as string[]; only the first is used.
expect(sharedTokenMatches('tok', ['tok', 'ignored'])).toBe(true);
expect(sharedTokenMatches('tok', ['wrong', 'tok'])).toBe(false);
});
it('undefined / non-string provided -> false', () => {
expect(sharedTokenMatches('tok', undefined)).toBe(false);
// An empty array yields provided[0] === undefined -> non-string -> false.
expect(sharedTokenMatches('tok', [])).toBe(false);
expect(sharedTokenMatches('tok', [undefined as unknown as string])).toBe(
false,
);
});
});
describe('clientIp (XFF-fallback precedence, item 5)', () => {
it('req.ip wins over socket.remoteAddress AND over X-Forwarded-For', () => {
expect(
clientIp({
ip: '1.1.1.1',
socket: { remoteAddress: '2.2.2.2' },
headers: { 'x-forwarded-for': '3.3.3.3' },
}),
).toBe('1.1.1.1');
});
it('socket.remoteAddress is used only when req.ip is absent (still beats XFF)', () => {
expect(
clientIp({
socket: { remoteAddress: '2.2.2.2' },
headers: { 'x-forwarded-for': '3.3.3.3' },
}),
).toBe('2.2.2.2');
});
it('X-Forwarded-For is the LAST resort, and only the FIRST hop is taken', () => {
expect(
clientIp({
headers: { 'x-forwarded-for': '3.3.3.3, 4.4.4.4, 5.5.5.5' },
}),
).toBe('3.3.3.3');
});
it("returns 'unknown' when nothing usable is present", () => {
expect(clientIp({ headers: {} })).toBe('unknown');
// An array-valued XFF header is not treated as a string source -> unknown.
expect(
clientIp({ headers: { 'x-forwarded-for': ['3.3.3.3'] } }),
).toBe('unknown');
// An empty XFF string is ignored too.
expect(clientIp({ headers: { 'x-forwarded-for': '' } })).toBe('unknown');
});
});
describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
it('calls TokenService.verifyJwt with JwtType.ACCESS as the second argument', async () => {
// Mock TokenService: assert the type literal is pinned to ACCESS so swapping
// to REFRESH (or omitting the type) breaks this test.
const verifyJwt = jest
.fn()
.mockResolvedValue({ sub: 'user-1', workspaceId: 'ws-1' });
const verify = bindAccessJwtVerifier({ verifyJwt });
await verify('the.access.jwt');
expect(verifyJwt).toHaveBeenCalledTimes(1);
expect(verifyJwt).toHaveBeenCalledWith('the.access.jwt', JwtType.ACCESS);
// Pin the real enum value too, so renaming/repointing the enum member is caught.
expect(verifyJwt.mock.calls[0][1]).toBe('access');
});
it('passes through the verified payload', async () => {
const payload = { sub: 'user-9', email: 'u@e.com', workspaceId: 'ws-1' };
const verifyJwt = jest.fn().mockResolvedValue(payload);
await expect(
bindAccessJwtVerifier({ verifyJwt })('t'),
).resolves.toBe(payload);
});
// The Bearer revocation/disabled checks (verifyBearerAccess) are covered above;
// this binds the ACCESS-type enforcement that verifyMcpBearer wires in.
it('feeds verifyBearerAccess so the whole Bearer chain enforces ACCESS', async () => {
const verifyJwt = jest.fn().mockResolvedValue({
sub: 'user-1',
workspaceId: 'ws-1',
sessionId: 'sess-1',
});
const res = await verifyBearerAccess('t', {
verifyJwt: bindAccessJwtVerifier({ verifyJwt }),
findUser: jest.fn().mockResolvedValue({ deactivatedAt: null }),
findActiveSession: jest
.fn()
.mockResolvedValue({ userId: 'user-1', workspaceId: 'ws-1' }),
});
expect(verifyJwt).toHaveBeenCalledWith('t', JwtType.ACCESS);
expect(res).toEqual({ sub: 'user-1', email: undefined });
});
});

View File

@@ -1,8 +1,33 @@
import { Injectable, Logger } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleDestroy,
UnauthorizedException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { pathToFileURL } from 'node:url';
import { IncomingMessage } from 'node:http';
import { FastifyReply, FastifyRequest } from 'fastify';
import { EnvironmentService } from '../environment/environment.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { AuthService } from '../../core/auth/services/auth.service';
import { TokenService } from '../../core/auth/services/token.service';
import { validateSsoEnforcement } from '../../core/auth/auth.util';
import { JwtPayload } from '../../core/auth/dto/jwt-payload';
import { Workspace } from '@docmost/db/types/entity.types';
import {
FailedLoginLimiter,
resolveMcpSessionConfig,
verifyBearerAccess,
isInitializeRequestBody,
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
DocmostMcpConfig,
ResolvedMcpAuth,
} from './mcp-auth.helpers';
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
interface McpHttpHandler {
@@ -13,14 +38,23 @@ interface McpHttpHandler {
): Promise<void>;
}
type McpConfigResolver = (
req: IncomingMessage,
) => DocmostMcpConfig | Promise<DocmostMcpConfig>;
interface McpHttpModule {
createMcpHttpHandler(config: {
apiUrl: string;
email: string;
password: string;
}): McpHttpHandler;
createMcpHttpHandler(
config: DocmostMcpConfig | McpConfigResolver,
options?: { identify?: (req: IncomingMessage) => string | Promise<string> },
): McpHttpHandler;
}
// Stash key for the per-request resolved config/identity computed (and
// validated) in handle() BEFORE res.hijack(), then read back by the resolver
// the MCP package invokes. Doing the validation pre-hijack lets a bad-creds
// failure return a clean 401 JSON instead of tearing a hijacked response.
const MCP_RESOLVED = Symbol('mcpResolvedConfig');
// 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
@@ -31,19 +65,51 @@ const esmImport = new Function(
) as (specifier: string) => Promise<unknown>;
@Injectable()
export class McpService {
export class McpService implements OnModuleDestroy {
private readonly logger = new Logger(McpService.name);
private handler: McpHttpHandler | null = null;
private handlerPromise: Promise<McpHttpHandler> | null = null;
private warnedMissingCreds = false;
// In-memory per-IP/email throttle for FAILED /mcp Basic logins. Calling
// AuthService.login directly bypasses the controller's ThrottlerGuard, so
// this is the brute-force speed bump for /mcp. 5 failures per 60s window.
private readonly failedLogins = new FailedLoginLimiter(5, 60_000);
// Periodically drop expired limiter buckets so never-revisited keys do not
// accumulate forever (unbounded memory growth / DoS via forgeable XFF keys).
// unref()'d so it never keeps the process alive; cleared on module destroy.
// Mirrors the sweepTimer pattern in packages/mcp/src/http.ts.
private readonly sweepIntervalMs = 60_000;
private readonly sweepTimer: NodeJS.Timeout;
constructor(
private readonly environmentService: EnvironmentService,
private readonly workspaceRepo: WorkspaceRepo,
) {}
private readonly authService: AuthService,
private readonly tokenService: TokenService,
private readonly userRepo: UserRepo,
private readonly userSessionRepo: UserSessionRepo,
private readonly moduleRef: ModuleRef,
) {
this.sweepTimer = setInterval(() => {
try {
this.failedLogins.sweep();
} catch (err) {
this.logger.error('MCP failed-login limiter sweep failed', err as Error);
}
}, this.sweepIntervalMs);
// Do not let this interval hold the event loop open.
this.sweepTimer.unref?.();
}
onModuleDestroy(): void {
clearInterval(this.sweepTimer);
}
// Service account the embedded MCP uses to talk back to this Docmost
// instance over loopback REST + the collaboration WebSocket.
// instance over loopback REST + the collaboration WebSocket. Now OPTIONAL:
// it is only a fallback when no per-user Basic/Bearer credentials are sent.
private getEmail(): string | undefined {
return process.env.MCP_DOCMOST_EMAIL;
}
@@ -80,8 +146,141 @@ export class McpService {
}
}
// Bearer access-JWT verification for the /mcp token fallback. verifyJwt only
// checks signature/exp/type, but a logged-out (revoked) or disabled user can
// still hold an unexpired access JWT. JwtStrategy additionally checks the
// session is active and the user is not disabled; we mirror those exact checks
// here so the MCP Bearer path is not weaker than the normal cookie/header path.
private async verifyMcpBearer(
token: string,
): Promise<{ sub?: string; email?: string }> {
// The revocation/disabled decision logic lives in the framework-free
// verifyBearerAccess helper (unit-testable without the heavy auth graph);
// this method only wires in the concrete TokenService + repos.
return verifyBearerAccess(token, {
// The JwtType.ACCESS enforcement lives in bindAccessJwtVerifier (a pure,
// testable seam) so the type literal cannot silently drift to REFRESH.
verifyJwt: bindAccessJwtVerifier(this.tokenService) as (
t: string,
) => Promise<JwtPayload>,
findUser: (sub, workspaceId) =>
this.userRepo.findById(sub, workspaceId),
findActiveSession: (sessionId) =>
this.userSessionRepo.findActiveById(sessionId),
});
}
/**
* Resolve the per-session identity from the request and produce the
* DocmostMcpConfig the MCP package will run under, plus an opaque identity
* key for anti-fixation. The decision logic lives in the framework-free
* `resolveMcpSessionConfig` helper (so it is unit-testable without the heavy
* auth graph); this method only wires McpService's injected collaborators in.
*
* Throws UnauthorizedException with a SPECIFIC message on failure (never a
* generic "MCP error"); never logs/echoes the password or Authorization
* header. Run BEFORE res.hijack() so the 401 is clean JSON.
*/
async resolveSessionConfig(req: FastifyRequest): Promise<ResolvedMcpAuth> {
const authHeader = req.headers['authorization'] as string | undefined;
// A request carrying an mcp-session-id is operating on an ALREADY
// established session (see packages/mcp/src/http.ts: a new session is only
// minted by an initialize POST with no session id). The session-minting
// login() (user_sessions insert + USER_LOGIN audit + lastLoginAt bump) must
// run ONLY for a genuine session INITIALIZE: no mcp-session-id AND the
// JSON-RPC body is an `initialize` request — the same signal http.ts uses to
// decide whether to mint a session. Any other request (e.g. a non-initialize
// body with no session id, which http.ts will 400) uses the non-side-
// effecting verifyCredentials path so it never mints an orphan DB
// session/audit row before being rejected.
const isSessionInit =
!req.headers['mcp-session-id'] &&
isInitializeRequestBody((req as unknown as { body?: unknown }).body);
return resolveMcpSessionConfig(authHeader, {
apiUrl: this.getApiUrl(),
email: this.getEmail(),
password: this.getPassword(),
findWorkspace: () => this.workspaceRepo.findFirst(),
enforceBasicGate: (workspace, creds) =>
this.enforceBasicLoginGate(workspace as Workspace, creds),
login: (creds, workspaceId) => this.authService.login(creds, workspaceId),
verifyCredentials: async (creds, workspaceId) => {
await this.authService.verifyUserCredentials(creds, workspaceId);
},
verifyAccessJwt: (token) => this.verifyMcpBearer(token),
limiter: this.failedLogins,
clientIp: clientIp(req),
isSessionInit,
});
}
// Pre-token gate for the /mcp HTTP-Basic path, replicating EXACTLY what
// AuthController.login does before issuing a token, so the Basic path is not
// an SSO/MFA bypass:
// 1) validateSsoEnforcement(workspace) — reject if the workspace enforces
// SSO (a password login is not allowed there).
// 2) Lazily require the EE MFA module (same pattern/path as the controller).
// If it is bundled and the user has MFA enabled OR the workspace enforces
// MFA, reject the Basic path and tell the caller to use a Bearer token (a
// Bearer ACCESS JWT is only minted AFTER the normal gated login, so it is
// safe). A fork WITHOUT the EE module behaves exactly like the controller:
// no MFA module -> no MFA gate.
// Throws UnauthorizedException on rejection (surfaced as a clean 401, never a
// torn/hijacked response, never a token). Never logs the password.
private async enforceBasicLoginGate(
workspace: Workspace,
creds: { email: string; password: string },
): Promise<void> {
// 1) SSO enforcement. validateSsoEnforcement throws BadRequestException; we
// re-surface it as Unauthorized so the /mcp 401 path is consistent and a
// token is never issued.
try {
validateSsoEnforcement(workspace);
} catch {
throw new UnauthorizedException(
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
);
}
// 2) MFA gate — lazy-require the EE module exactly like AuthController.login.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let MfaModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
} catch {
// No EE MFA module bundled in this build: same as the controller -> no
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
return;
}
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
// Use the same requirement check the controller uses. We pass NO FastifyReply
// (the controller passes `res` only to set a cookie on the no-MFA happy path,
// which we never take here): we only read the requirement flags. Be tolerant
// of either a (loginInput, workspace) or (loginInput, workspace, res) shape.
const mfaResult = await mfaService.checkMfaRequirements(
creds,
workspace,
undefined,
);
if (mfaResult && (mfaResult.userHasMfa || mfaResult.requiresMfaSetup)) {
throw new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
);
}
}
// Lazily create the HTTP handler exactly once. The import is indirected so
// the ESM-only @docmost/mcp package can be loaded from this CommonJS module.
// The handler is created with a per-request RESOLVER (and an `identify` hook
// for anti-fixation): both read the auth that handle() resolved and stashed
// on req before hijack, so the package never re-parses credentials.
private async getHandler(): Promise<McpHttpHandler> {
if (this.handler) {
return this.handler;
@@ -95,11 +294,29 @@ export class McpService {
const mod = (await esmImport(
pathToFileURL(httpEntry).href,
)) as McpHttpModule;
const handler = mod.createMcpHttpHandler({
apiUrl: this.getApiUrl(),
email: this.getEmail()!,
password: this.getPassword()!,
});
const handler = mod.createMcpHttpHandler(
(req: IncomingMessage) => {
const resolved = (req as unknown as Record<symbol, unknown>)[
MCP_RESOLVED
] as ResolvedMcpAuth | undefined;
if (!resolved) {
// Should never happen: handle() always stashes before delegating.
throw new UnauthorizedException('MCP authentication missing.');
}
return resolved.config;
},
{
identify: (req: IncomingMessage) => {
const resolved = (req as unknown as Record<symbol, unknown>)[
MCP_RESOLVED
] as ResolvedMcpAuth | undefined;
if (!resolved || resolved.identity === undefined) {
throw new UnauthorizedException('MCP authentication missing.');
}
return resolved.identity;
},
},
);
this.handler = handler;
return handler;
})().catch((err) => {
@@ -112,13 +329,13 @@ export class McpService {
}
async handle(req: FastifyRequest, res: FastifyReply): Promise<void> {
// Optional static bearer-token guard. When MCP_TOKEN is set, the request
// must carry a matching `Authorization: Bearer <token>` header. When unset,
// /mcp relies on the workspace toggle and network isolation (no auth).
const token = process.env.MCP_TOKEN;
if (token) {
const authHeader = req.headers['authorization'];
if (authHeader !== `Bearer ${token}`) {
// Optional shared-guard. When MCP_TOKEN is set, the request must carry a
// matching `X-MCP-Token` header. It now lives in its OWN header so it never
// collides with `Authorization`, which carries the per-user credentials.
const sharedToken = process.env.MCP_TOKEN;
if (sharedToken) {
const provided = req.headers['x-mcp-token'];
if (!sharedTokenMatches(sharedToken, provided)) {
res.status(401).send({ error: 'Unauthorized' });
return;
}
@@ -129,20 +346,40 @@ export class McpService {
return;
}
if (!this.credsConfigured()) {
if (!this.warnedMissingCreds) {
this.warnedMissingCreds = true;
this.logger.warn(
'MCP is enabled but not configured: set MCP_DOCMOST_EMAIL and MCP_DOCMOST_PASSWORD.',
);
// Resolve + validate the per-session identity BEFORE hijacking the response
// so bad credentials surface as a clean 401 JSON (never a torn response and
// never a generic "MCP error"). The resolved config/identity is stashed on
// the raw request for the package's resolver + identify hook to read back.
let resolved: ResolvedMcpAuth;
try {
resolved = await this.resolveSessionConfig(req);
} catch (err) {
if (err instanceof UnauthorizedException) {
// Warn once if the only thing missing is the service account, to keep
// the original operator hint.
if (
!this.credsConfigured() &&
!req.headers['authorization'] &&
!this.warnedMissingCreds
) {
this.warnedMissingCreds = true;
this.logger.warn(
'MCP is enabled but received a request with no credentials and no ' +
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
);
}
res.status(401).send({ error: err.message });
return;
}
res.status(503).send({
error:
'MCP is not configured (set MCP_DOCMOST_EMAIL / MCP_DOCMOST_PASSWORD)',
});
this.logger.error('MCP auth resolution failed', err as Error);
res.status(500).send({ error: 'Internal server error' });
return;
}
// Stash the resolved auth on the raw request so the package's resolver +
// identify hook (wired in getHandler) read it back instead of re-parsing.
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] = resolved;
// Hand the raw Node req/res to the MCP transport. hijack() tells Fastify
// to stop managing this response so the transport can write to it directly.
res.hijack();

View File

@@ -35,6 +35,7 @@ export class StaticModule implements OnModuleInit {
ENV: this.environmentService.getNodeEnv(),
APP_URL: this.environmentService.getAppUrl(),
CLOUD: this.environmentService.isCloud(),
COMPACT_PAGE_TREE: this.environmentService.isCompactPageTreeEnabled(),
FILE_UPLOAD_SIZE_LIMIT:
this.environmentService.getFileUploadSizeLimit(),
FILE_IMPORT_SIZE_LIMIT:

View File

@@ -8,6 +8,7 @@ import {
AUTH_THROTTLER,
AI_CHAT_THROTTLER,
PAGE_TEMPLATE_THROTTLER,
PUBLIC_SHARE_AI_THROTTLER,
} from './throttler-names';
import Redis from 'ioredis';
@@ -27,6 +28,8 @@ import Redis from 'ioredis';
// a scripted client could drive heavy content fan-out. 30 req/min
// per user is plenty for legitimate render-time batched lookups.
{ name: PAGE_TEMPLATE_THROTTLER, ttl: 60_000, limit: 30 },
// Anonymous public-share assistant: ~5 req/min per IP.
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(

View File

@@ -1,3 +1,8 @@
export const AUTH_THROTTLER = 'auth';
export const AI_CHAT_THROTTLER = 'ai-chat';
export const PAGE_TEMPLATE_THROTTLER = 'page-template';
// IP-keyed throttler for the anonymous public-share AI assistant. There is no
// authenticated user on that route, so it is keyed by client IP (the default
// ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays
// for the tokens.
export const PUBLIC_SHARE_AI_THROTTLER = 'public-share-ai';