feat(ai-chat): per-user AI agent backend — LLM config, read-only agent, provenance schema

WIP checkpoint of the gitmost AI-chat backend (plan stages A + B1 + B3a).
The agent acts under the requesting user's JWT (Docmost CASL enforces page
access); the external service-account /mcp endpoint is untouched.

LLM provider config (A2-A4):
- integrations/crypto: AES-256-GCM SecretBoxService (key derived from APP_SECRET,
  per-record salt/iv; clear error on rotation instead of crashing).
- ai_provider_credentials table/repo/types: encrypted API key stored outside
  workspace settings/baseFields, write-only (never returned by any endpoint).
- integrations/ai: per-workspace AI SDK v6 provider driver (openai/gemini/ollama),
  admin-gated GET(masked)/PATCH(write-only key)/Test endpoints; settings.ai.provider
  holds non-secret config incl. systemPrompt. Removed unused AI_* env getters (DB is
  the single source of truth).

Chat module (A1, A5-A8):
- ai_chats/ai_chat_messages repos (workspace-scoped, soft-delete, tsv never selected).
- core/ai-chat: CRUD + POST /ai-chat/stream (Fastify hijack + AI SDK v6
  pipeUIMessageStreamToResponse, abort on disconnect, persist user/assistant msgs).
- Agent loop: streamText + stepCountIs(8); read tools searchPages/getPage via a
  per-request DocmostClient over loopback REST under the user's minted access token.
- Gate settings.ai.chat (+ 503 when provider unconfigured); buildSystemPrompt with a
  non-removable safety/anti-prompt-injection framework. Per-user rate limit.

Per-user auth (B1):
- @docmost/mcp DocmostClient gains an additive getToken variant (carry a user JWT,
  re-fetch on 401) and exports DocmostClient; the email/password service-account path
  (external /mcp, stdio) is unchanged.

Agent-edit provenance backbone (B3a):
- Migration: pages/page_history (last_updated_source, last_updated_ai_chat_id) and
  comments (created_source, ai_chat_id, resolved_source).
- Signed actor/aiChatId claim in the collab token; onAuthenticate propagates it,
  onStoreDocument writes it with a sticky agent marker, saveHistory copies it.

Migrations auto-run on boot (additive). Write tools, frontend, RAG and external MCP
servers are not in this checkpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-17 01:36:41 +03:00
parent 6914774ca8
commit 683da7a4c5
40 changed files with 2063 additions and 86 deletions

View File

@@ -0,0 +1,11 @@
import { ServiceUnavailableException } from '@nestjs/common';
/**
* Thrown when no usable AI provider config exists for the workspace (missing
* driver / chat model / API key). Maps to HTTP 503 (§6.2/§6.4).
*/
export class AiNotConfiguredException extends ServiceUnavailableException {
constructor() {
super('AI provider not configured');
}
}

View File

@@ -0,0 +1,78 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../core/casl/interfaces/workspace-ability.type';
import { AiService } from './ai.service';
import { AiSettingsService } from './ai-settings.service';
import { UpdateAiSettingsDto } from './dto/update-ai-settings.dto';
/**
* Admin-only AI provider settings (§6.4). Routes are POST to match the rest of
* this codebase (it uses POST for reads too). Access is gated by the workspace
* admin ability — the same gate as `POST /workspace/update`. No endpoint here
* ever returns the API key (only `hasApiKey`).
*/
@UseGuards(JwtAuthGuard)
@Controller('workspace/ai-settings')
export class AiSettingsController {
constructor(
private readonly aiService: AiService,
private readonly aiSettingsService: AiSettingsService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
private assertAdmin(user: User, workspace: Workspace) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
@HttpCode(HttpStatus.OK)
@Post()
async getSettings(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.aiSettingsService.getMasked(workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async updateSettings(
@Body() dto: UpdateAiSettingsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
// Returns masked settings only — never the key.
return this.aiSettingsService.update(workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('test')
async testConnection(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.aiService.testConnection(workspace.id);
}
}

View File

@@ -0,0 +1,169 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { SecretBoxService } from '../crypto/secret-box';
import {
AiDriver,
AiProviderSettings,
MaskedAiSettings,
ResolvedAiConfig,
} from './ai.types';
/**
* Shape of the partial update accepted by `update`. Mirrors the validated
* controller DTO. `apiKey` is write-only: undefined = leave, '' = clear,
* non-empty = encrypt + store (§6.4/§8).
*/
export interface UpdateAiSettingsInput {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
systemPrompt?: string;
apiKey?: string;
}
/**
* Reads/writes the per-workspace AI provider config.
*
* Non-secret fields live in `settings.ai.provider`; the API key lives encrypted
* in `ai_provider_credentials` (per driver). The decrypted key is only ever
* returned by `resolve` (server-side use) and is NEVER logged or returned to a
* client (§8).
*/
@Injectable()
export class AiSettingsService {
constructor(
private readonly workspaceRepo: WorkspaceRepo,
private readonly aiProviderCredentialsRepo: AiProviderCredentialsRepo,
private readonly secretBox: SecretBoxService,
) {}
/** Read the stored non-secret provider settings for a workspace. */
private async readProvider(
workspaceId: string,
): Promise<Partial<AiProviderSettings>> {
const workspace = await this.workspaceRepo.findById(workspaceId);
const settings = (workspace?.settings ?? {}) as {
ai?: { provider?: Partial<AiProviderSettings> };
};
return settings?.ai?.provider ?? {};
}
/**
* Resolve the full config including the decrypted API key for the stored
* driver. Returns null when no driver is configured. Ollama needs no key.
* The key is never logged.
*/
async resolve(workspaceId: string): Promise<ResolvedAiConfig | null> {
const provider = await this.readProvider(workspaceId);
if (!provider.driver) return null;
const config: ResolvedAiConfig = {
driver: provider.driver,
chatModel: provider.chatModel,
embeddingModel: provider.embeddingModel,
baseUrl: provider.baseUrl,
systemPrompt: provider.systemPrompt,
};
if (provider.driver !== 'ollama') {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
provider.driver,
);
if (creds?.apiKeyEnc) {
config.apiKey = this.secretBox.decryptSecret(creds.apiKeyEnc);
}
}
return config;
}
/**
* Masked settings safe for admin clients. NEVER includes the key (even
* encrypted); only `hasApiKey` for the current driver.
*/
async getMasked(workspaceId: string): Promise<MaskedAiSettings> {
const provider = await this.readProvider(workspaceId);
let hasApiKey = false;
if (provider.driver) {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
provider.driver,
);
hasApiKey = !!creds?.apiKeyEnc;
}
return {
driver: provider.driver,
chatModel: provider.chatModel,
embeddingModel: provider.embeddingModel,
baseUrl: provider.baseUrl,
systemPrompt: provider.systemPrompt,
hasApiKey,
};
}
/**
* Apply a partial update. Non-secret fields are persisted via
* `updateAiProviderSettings`; the API key is handled separately:
* - apiKey === undefined → leave existing key untouched
* - apiKey === '' → clear the key for the target driver
* - apiKey non-empty → encrypt + upsert for the target driver
*
* Target driver for the key = incoming dto.driver, else the stored driver.
* If a key is supplied but no driver can be determined → BadRequest.
*/
async update(
workspaceId: string,
dto: UpdateAiSettingsInput,
): Promise<MaskedAiSettings> {
const { apiKey, ...nonSecret } = dto;
// Persist non-secret provider fields (only those present in the partial).
const providerPatch: Partial<AiProviderSettings> = {};
for (const key of [
'driver',
'chatModel',
'embeddingModel',
'baseUrl',
'systemPrompt',
] as const) {
if (nonSecret[key] !== undefined) {
(providerPatch as Record<string, unknown>)[key] = nonSecret[key];
}
}
if (Object.keys(providerPatch).length > 0) {
await this.workspaceRepo.updateAiProviderSettings(
workspaceId,
providerPatch,
);
}
// Key handling (write-only).
if (apiKey !== undefined) {
const stored = await this.readProvider(workspaceId);
const targetDriver = dto.driver ?? stored.driver;
if (!targetDriver) {
throw new BadRequestException(
'Cannot set the API key without a driver; set the driver first',
);
}
if (apiKey === '') {
await this.aiProviderCredentialsRepo.clearKey(workspaceId, targetDriver);
} else {
const enc = this.secretBox.encryptSecret(apiKey);
await this.aiProviderCredentialsRepo.upsert(
workspaceId,
targetDriver,
enc,
);
}
}
return this.getMasked(workspaceId);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { CryptoModule } from '../crypto/crypto.module';
import { AiService } from './ai.service';
import { AiSettingsService } from './ai-settings.service';
import { AiSettingsController } from './ai-settings.controller';
/**
* LLM driver + provider-settings unit (§6.2/§6.4).
*
* CryptoModule supplies SecretBoxService for API-key encryption. WorkspaceRepo,
* AiProviderCredentialsRepo (DatabaseModule, global) and WorkspaceAbilityFactory
* (CaslModule, global) are resolved without explicit imports.
*/
@Module({
imports: [CryptoModule],
controllers: [AiSettingsController],
providers: [AiService, AiSettingsService],
exports: [AiService, AiSettingsService],
})
export class AiModule {}

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { generateText, type LanguageModel } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createOllama } from 'ai-sdk-ollama';
import { AiSettingsService } from './ai-settings.service';
import { AiNotConfiguredException } from './ai-not-configured.exception';
/**
* Builds AI SDK language models from per-workspace config and runs cheap
* connectivity checks.
*
* The provider client is built PER WORKSPACE on demand — never cached globally —
* and the decrypted API key is held only for the duration of the call and is
* never logged (§6.2/§8).
*/
@Injectable()
export class AiService {
constructor(private readonly aiSettings: AiSettingsService) {}
/**
* Resolve the workspace config and build the chat language model.
* Throws AiNotConfiguredException (→ 503) when the config is incomplete.
*/
async getChatModel(workspaceId: string): Promise<LanguageModel> {
const cfg = await this.aiSettings.resolve(workspaceId);
if (
!cfg?.driver ||
!cfg?.chatModel ||
(cfg.driver !== 'ollama' && !cfg.apiKey)
) {
throw new AiNotConfiguredException();
}
switch (cfg.driver) {
case 'openai':
// baseURL (when set) covers openai-compatible endpoints.
return createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl })(
cfg.chatModel,
);
case 'gemini':
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(cfg.chatModel);
case 'ollama':
// Ollama needs no API key.
return createOllama({ baseURL: cfg.baseUrl })(cfg.chatModel);
default:
throw new AiNotConfiguredException();
}
}
/**
* Cheap connectivity check. Builds the model and asks for a one-word reply.
* Never leaks the provider's raw error body or the key — only a short,
* generic message (§6.4/§8.3).
*/
async testConnection(
workspaceId: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
let model: LanguageModel;
try {
model = await this.getChatModel(workspaceId);
} catch (err) {
if (err instanceof AiNotConfiguredException) {
return { ok: false, error: 'AI provider not configured' };
}
// Defensive: do not surface internal error details.
return { ok: false, error: 'AI provider not configured' };
}
try {
await generateText({ model, prompt: 'ping' });
return { ok: true };
} catch {
// Do NOT include the provider's raw error (may echo the request/key).
return {
ok: false,
error: 'Failed to reach the AI provider. Check the settings and key.',
};
}
}
}

View File

@@ -0,0 +1,47 @@
/**
* Server-side AI provider configuration types.
*
* The non-secret provider settings live under `settings.ai.provider`; the
* encrypted API key lives ONLY in `ai_provider_credentials` (per driver) and is
* never part of these settings (§6.2/§6.4/§8).
*/
export type AiDriver = 'openai' | 'gemini' | 'ollama';
export const AI_DRIVERS: AiDriver[] = ['openai', 'gemini', 'ollama'];
/**
* Non-secret provider settings persisted under `settings.ai.provider`.
* The API key is intentionally absent here.
*/
export interface AiProviderSettings {
driver: AiDriver;
chatModel: string;
embeddingModel?: string;
baseUrl?: string;
systemPrompt?: string;
}
/**
* Fully resolved provider config, including the decrypted API key for the
* stored driver. Returned by `AiSettingsService.resolve`. The key is held in
* memory only while building the provider and is never logged.
*/
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
driver?: AiDriver;
chatModel?: string;
apiKey?: string;
}
/**
* Masked provider settings safe to return to admin clients. NEVER includes the
* API key (not even encrypted); only a `hasApiKey` boolean.
*/
export interface MaskedAiSettings {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
systemPrompt?: string;
hasApiKey: boolean;
}

View File

@@ -0,0 +1,35 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import { AI_DRIVERS, AiDriver } from '../ai.types';
/**
* Admin update payload for the workspace AI provider settings.
*
* `apiKey` is write-only (§8.2): provided → stored encrypted, '' → cleared,
* absent → left untouched. It is NEVER returned by any endpoint. The global
* ValidationPipe runs with `whitelist: true`, so unknown fields are stripped.
*/
export class UpdateAiSettingsDto {
@IsOptional()
@IsIn(AI_DRIVERS)
driver?: AiDriver;
@IsOptional()
@IsString()
chatModel?: string;
@IsOptional()
@IsString()
embeddingModel?: string;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsString()
apiKey?: string;
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EnvironmentModule } from '../environment/environment.module';
import { SecretBoxService } from './secret-box';
@Module({
imports: [EnvironmentModule],
providers: [SecretBoxService],
exports: [SecretBoxService],
})
export class CryptoModule {}

View File

@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from 'node:crypto';
import { EnvironmentService } from '../environment/environment.service';
const ALGORITHM = 'aes-256-gcm';
const SALT_LENGTH = 16; // per-record random salt for scrypt key derivation
const IV_LENGTH = 12; // recommended IV length for GCM
const AUTH_TAG_LENGTH = 16; // GCM authentication tag length
const KEY_LENGTH = 32; // 256-bit key for aes-256-gcm
/**
* Symmetric secret encryption helper (§6.3 / A2 crypto part).
*
* Encrypts short secrets (e.g. provider API keys) with AES-256-GCM. The key is
* derived from APP_SECRET via scrypt using a per-record random salt, so two
* encryptions of the same plaintext produce different blobs. The output layout
* is base64( salt | iv | authTag | ciphertext ).
*/
@Injectable()
export class SecretBoxService {
constructor(private readonly environmentService: EnvironmentService) {}
private deriveKey(salt: Buffer): Buffer {
return scryptSync(
this.environmentService.getAppSecret(),
salt,
KEY_LENGTH,
);
}
encryptSecret(plain: string): string {
const salt = randomBytes(SALT_LENGTH);
const iv = randomBytes(IV_LENGTH);
const key = this.deriveKey(salt);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([
cipher.update(plain, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return Buffer.concat([salt, iv, authTag, ciphertext]).toString('base64');
}
decryptSecret(blob: string): string {
try {
const data = Buffer.from(blob, 'base64');
const salt = data.subarray(0, SALT_LENGTH);
const iv = data.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const authTag = data.subarray(
SALT_LENGTH + IV_LENGTH,
SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,
);
const ciphertext = data.subarray(
SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,
);
const key = this.deriveKey(salt);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const plain = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plain.toString('utf8');
} catch {
// decipher.final() throws on tamper / wrong key. Surface a clear,
// recoverable error instead of crashing the process (§6.3).
throw new Error(
'Failed to decrypt secret — APP_SECRET may have changed; re-enter the API key',
);
}
}
}

View File

@@ -278,56 +278,9 @@ export class EnvironmentService {
.toLowerCase();
}
getAiDriver(): string {
return this.configService.get<string>('AI_DRIVER');
}
getAiEmbeddingModel(): string {
return this.configService.get<string>('AI_EMBEDDING_MODEL');
}
getAiCompletionModel(): string {
return this.configService.get<string>('AI_COMPLETION_MODEL');
}
getAiChatModel(): string {
return (
this.configService.get<string>('AI_CHAT_MODEL') ||
this.configService.get<string>('AI_COMPLETION_MODEL')
);
}
getAiEmbeddingDimension(): number {
return parseInt(
this.configService.get<string>('AI_EMBEDDING_DIMENSION'),
10,
);
}
getAiEmbeddingSupportsMrl(): boolean | undefined {
const val = this.configService.get<string>('AI_EMBEDDING_SUPPORTS_MRL');
if (val === undefined || val === null || val === '') return undefined;
return val === 'true';
}
getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY');
}
getOpenAiApiUrl(): string {
return this.configService.get<string>('OPENAI_API_URL');
}
getGeminiApiKey(): string {
return this.configService.get<string>('GEMINI_API_KEY');
}
getOllamaApiUrl(): string {
return this.configService.get<string>(
'OLLAMA_API_URL',
'http://localhost:11434',
);
}
// NOTE: AI_*/OPENAI_*/GEMINI_*/OLLAMA_* env getters were removed (D8/§14[M3]):
// provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getEventStoreDriver(): string {
return this.configService

View File

@@ -1,13 +1,18 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
type AuthedRequest = { user?: { id?: string } };
// JwtStrategy.validate() returns `{ user, workspace }`, so Passport sets
// `req.user = { user, workspace }` (the `@AuthUser()` decorator reads
// `request.user.user`). Reading `req.user?.id` therefore never matches and the
// limiter silently degrades to per-IP; read `req.user?.user?.id` instead.
type AuthedRequest = { user?: { id?: string; user?: { id?: string } } };
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: AuthedRequest): Promise<string> {
const userId = req.user?.id;
const userId = req.user?.user?.id ?? req.user?.id;
if (userId) return `user:${userId}`;
// Unauthenticated request: fall back to the default IP-based tracker.
return super.getTracker(req as Parameters<ThrottlerGuard['getTracker']>[0]);
}
}