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:
@@ -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');
|
||||
}
|
||||
}
|
||||
78
apps/server/src/integrations/ai/ai-settings.controller.ts
Normal file
78
apps/server/src/integrations/ai/ai-settings.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
169
apps/server/src/integrations/ai/ai-settings.service.ts
Normal file
169
apps/server/src/integrations/ai/ai-settings.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
apps/server/src/integrations/ai/ai.module.ts
Normal file
20
apps/server/src/integrations/ai/ai.module.ts
Normal 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 {}
|
||||
81
apps/server/src/integrations/ai/ai.service.ts
Normal file
81
apps/server/src/integrations/ai/ai.service.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
47
apps/server/src/integrations/ai/ai.types.ts
Normal file
47
apps/server/src/integrations/ai/ai.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
10
apps/server/src/integrations/crypto/crypto.module.ts
Normal file
10
apps/server/src/integrations/crypto/crypto.module.ts
Normal 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 {}
|
||||
82
apps/server/src/integrations/crypto/secret-box.ts
Normal file
82
apps/server/src/integrations/crypto/secret-box.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user