Files
gitmost/apps/server/src/core/ai-chat/public-share-chat.controller.ts
T
claude_code 90d3fab483 test: cover features since 053a9c0d + repair test tooling
Add ~330 tests across server (Jest), client (Vitest), editor-ext (Vitest)
and packages/mcp (node:test) for the gitmost features added since
053a9c0d: AI chat, AI agent roles, public-share assistant, MCP per-user
auth, HTML embed, page templates/embed, realtime tree, tree
expand/collapse, and the AI-settings UI.

Test-tooling fixes (prerequisite, were silently hiding coverage):
- Repair 3 page-template specs broken by the 11-arg TransclusionService
  constructor; they never compiled, so template access-control / content
  -leak / unsync-strip coverage was fictitious.
- Build @docmost/editor-ext before server tests via a `pretest` hook;
  the stale dist omitted the new HtmlEmbed/PageEmbed exports (TS2305).
- Let jest resolve the .tsx email templates: add `tsx` to
  moduleFileExtensions and widen the ts-jest transform to (t|j)sx?.

Behaviour-preserving "extract pure core" refactors that the tests drive:
- server: resolveShareAssistantRequest + uiMessageTextLength
  (public-share controller), decideBasicGate + mapAuthResultToResponse
  (mcp), buildErrorAssistantRecord (ai-chat), jsonbObject export (roles).
- client: render-raw-html + shouldExecute/canEdit, decide-embed-state,
  page-embed picker utils, tree-socket reducers, open/close branch maps,
  isEndpointConfigured/resolveKeyField; buildTreeWithChildren now treats
  a permission-trimmed orphan as a root instead of crashing.

Deferred (need a test DB or HTTP harness, documented in the specs):
repo-level Postgres integration tests and the public-share XFF E2E.
Pre-existing DI/lib0-ESM suite failures are untouched and out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:40:40 +03:00

318 lines
12 KiB
TypeScript

import {
Controller,
HttpException,
HttpStatus,
Logger,
NotFoundException,
Post,
Req,
Res,
ServiceUnavailableException,
UseGuards,
} from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { FastifyReply, FastifyRequest } from 'fastify';
import { Workspace, AiAgentRole } from '@docmost/db/types/entity.types';
import { Public } from '../../common/decorators/public.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
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 {
PublicShareChatService,
PublicShareChatStreamBody,
MAX_SHARE_MESSAGES,
MAX_SHARE_MESSAGE_CHARS,
} from './public-share-chat.service';
import { evaluateShareAssistantFunnel } from './public-share-chat.funnel';
import { deriveShareAccess } from './public-share-chat.access';
import type { UIMessage } from 'ai';
/**
* Anonymous, read-only AI assistant over a SINGLE public share tree.
*
* Route: POST /api/shares/ai/stream (controller path `shares/ai`, the global
* `/api` prefix is applied by main.ts). `@Public()` so no session is required;
* the workspace (tenant) is resolved from the host by DomainMiddleware
* (`req.raw.workspace`), exactly like the other `/api/shares/*` public routes —
* so no main.ts change is needed.
*
* The security boundary is the tool scope (the share tree), not identity. The
* guardrail funnel below runs entirely BEFORE res.hijack(): every failure
* returns a clean JSON error and never starts streaming.
*/
@UseGuards(JwtAuthGuard)
@Controller('shares/ai')
export class PublicShareChatController {
private readonly logger = new Logger(PublicShareChatController.name);
constructor(
private readonly shareService: ShareService,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageRepo: PageRepo,
private readonly aiSettings: AiSettingsService,
private readonly publicShareChat: PublicShareChatService,
) {}
@Public()
@SkipTransform()
// IP-keyed throttle (default ThrottlerGuard tracker = client IP): ~5/min.
// Runs FIRST, so an over-limit anonymous caller gets 429 before any work.
// DEFENSE IN DEPTH ONLY: the app runs with trustProxy, so the "client IP" is
// taken from X-Forwarded-For. This layer is only meaningful when a TRUSTED
// reverse proxy REWRITES (not appends) XFF with the real client IP; otherwise
// an attacker rotates XFF to evade it. The cluster-wide per-workspace cap
// below is the backstop that holds even when this layer is fully evaded.
@UseGuards(ThrottlerGuard)
@Throttle({ [PUBLIC_SHARE_AI_THROTTLER]: { limit: 5, ttl: 60000 } })
@Post('stream')
async stream(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
): Promise<void> {
const body = (req.body ?? {}) as PublicShareChatStreamBody;
// ---- Guardrail funnel (order matters; each failure exits before stream) ----
// The whole pre-hijack fact-resolution + cap-ordering block is a pure-ish
// helper (collaborators passed in) so every funnel branch — 404 disabled /
// share-mismatch / page-unresolvable / restricted, 503 unconfigured, 429
// over-cap, 413 too many/too long — is unit-testable against the red-team
// boundaries without the full Nest/DB graph. It throws the SAME HttpException
// the controller would, and never starts streaming.
const resolved = await resolveShareAssistantRequest(
{
aiSettings: this.aiSettings,
shareService: this.shareService,
pageRepo: this.pageRepo,
pagePermissionRepo: this.pagePermissionRepo,
publicShareChat: this.publicShareChat,
},
{ workspaceId: workspace.id, body },
);
const { shareId, share, model, role, messages, openedPage } = resolved;
// Abort the agent loop when the client disconnects (mirrors ai-chat).
const controller = new AbortController();
const onClose = (): void => {
if (!res.raw.writableEnded) controller.abort();
};
req.raw.once('close', onClose);
res.raw.once('finish', () => req.raw.off('close', onClose));
// Commit to streaming.
res.hijack();
try {
await this.publicShareChat.stream({
workspaceId: workspace.id,
shareId,
share: {
id: share.id,
pageId: share.pageId,
sharedPage: share.sharedPage,
},
openedPage,
messages,
res,
signal: controller.signal,
model,
role,
});
} catch (err) {
// After hijack we can no longer send a clean JSON error.
this.logger.error('Public share chat stream failed', err as Error);
if (!res.raw.headersSent) {
res.raw.statusCode = 500;
res.raw.setHeader('Content-Type', 'application/json');
res.raw.end(JSON.stringify({ error: 'Internal server error' }));
} else if (!res.raw.writableEnded) {
res.raw.end();
}
}
}
}
/**
* The collaborators the pre-hijack funnel needs. Declared as the minimal slice
* of each injected service it actually calls, so the resolver can be unit-tested
* with hand-rolled mocks (no Nest module graph, no DB).
*/
export interface ShareAssistantDeps {
aiSettings: Pick<AiSettingsService, 'isPublicShareAssistantEnabled'>;
shareService: Pick<
ShareService,
'getShareForPage' | 'isSharingAllowed'
>;
pageRepo: Pick<PageRepo, 'findById'>;
pagePermissionRepo: Pick<PagePermissionRepo, 'hasRestrictedAncestor'>;
publicShareChat: Pick<
PublicShareChatService,
| 'resolveShareRole'
| 'getShareChatModel'
| 'tryConsumeWorkspaceQuota'
>;
}
/** The resolved, validated request ready to stream (everything is non-null). */
export interface ResolvedShareAssistantRequest {
shareId: string;
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>;
role: AiAgentRole | null;
messages: UIMessage[];
openedPage: { id: string; title?: string };
}
/**
* Pre-hijack fact-resolution + cap-ordering for the anonymous public-share
* assistant, extracted from the controller so every funnel branch is unit-
* testable without the Nest/DB graph. Order is security-relevant and each
* failure exits BEFORE any stream/hijack:
* 1. assistant toggle off => 404 (no share/page/model lookups);
* 2. share/page access (deriveShareAccess + evaluateShareAssistantFunnel) =>
* 404 (uniform; restricted descendant and out-of-tree look identical);
* 3. provider unconfigured => 503 (AiNotConfiguredException), other errors
* re-thrown;
* 4. per-workspace quota exhausted => 429 (BEFORE any stream/hijack);
* 5. payload caps => 413 (too many messages / a single message too long).
* Throws the SAME HttpException the controller would; returns the resolved,
* non-null request otherwise.
*/
export async function resolveShareAssistantRequest(
deps: ShareAssistantDeps,
input: { workspaceId: string; body: PublicShareChatStreamBody },
): Promise<ResolvedShareAssistantRequest> {
const { workspaceId, body } = input;
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
const assistantEnabled =
await deps.aiSettings.isPublicShareAssistantEnabled(workspaceId);
// 2/3. Share usable? Page in share? Resolved via the page's share membership,
// since getShareForPage ALSO yields the share + workspace. The opened
// page is then gated by an explicit restricted-ancestor check (which
// getShareForPage does NOT do) so a restricted page hidden from the
// public view is graded not-in-share.
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
let shareUsable = false;
let pageInShare = false;
if (assistantEnabled && shareId && pageId) {
share = await deps.shareService.getShareForPage(pageId, workspaceId);
if (share && share.id === shareId) {
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;
({ shareUsable, pageInShare } = deriveShareAccess({
resolvedShareId: share.id,
requestedShareId: shareId,
sharingAllowed,
restricted,
}));
}
}
// 4. Provider configured? Resolve the model now so an unconfigured provider
// yields a clean 503 BEFORE hijack. Only after the access gates pass, to
// avoid leaking timing.
let model:
| Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>
| undefined;
let role: AiAgentRole | null = null;
let providerConfigured = false;
if (assistantEnabled && shareUsable && pageInShare) {
try {
role = await deps.publicShareChat.resolveShareRole(workspaceId);
model = await deps.publicShareChat.getShareChatModel(workspaceId, role);
providerConfigured = true;
} catch (err) {
if (err instanceof AiNotConfiguredException) {
providerConfigured = false;
} else {
throw err;
}
}
}
const outcome = evaluateShareAssistantFunnel({
assistantEnabled,
shareUsable,
pageInShare,
providerConfigured,
});
if (outcome.ok === false) {
// 404 for everything access-shaped (feature/share/page); 503 for config.
if (outcome.status === 503) {
throw new ServiceUnavailableException('AI is not configured');
}
throw new NotFoundException('Not found');
}
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). Checked
// BEFORE res.hijack(), so an over-cap workspace gets a clean 429 and spends
// nothing.
if (!(await deps.publicShareChat.tryConsumeWorkspaceQuota(workspaceId))) {
throw new HttpException(
'This documentation assistant is temporarily busy. Please try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
const messages = Array.isArray(body.messages)
? (body.messages as UIMessage[])
: [];
if (messages.length > MAX_SHARE_MESSAGES) {
throw new HttpException('Too many messages', 413);
}
for (const m of messages) {
if (uiMessageTextLength(m) > MAX_SHARE_MESSAGE_CHARS) {
throw new HttpException('Message too long', 413);
}
}
const openedPage = {
id: pageId,
title: share?.sharedPage?.title ?? undefined,
};
// The funnel passed, so share/model are guaranteed present.
return {
shareId,
share: share!,
model: model!,
role,
messages,
openedPage,
};
}
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap).
* Exported so the 413 size-cap logic is unit-testable without the Nest/DB graph.
*/
export function uiMessageTextLength(message: UIMessage | undefined): number {
if (!message?.parts || !Array.isArray(message.parts)) return 0;
let total = 0;
for (const p of message.parts) {
if (p?.type === 'text' && typeof (p as { text?: string }).text === 'string') {
total += (p as { text: string }).text.length;
}
}
return total;
}