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:
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user