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,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',
);
}
}
}