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

@@ -27,6 +27,9 @@ import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
@@ -92,6 +95,9 @@ import { normalizePostgresUrl } from '../common/helpers';
WatcherRepo,
LabelRepo,
TemplateRepo,
AiChatRepo,
AiChatMessageRepo,
AiProviderCredentialsRepo,
PageListener,
],
exports: [
@@ -117,6 +123,9 @@ import { normalizePostgresUrl } from '../common/helpers';
WatcherRepo,
LabelRepo,
TemplateRepo,
AiChatRepo,
AiChatMessageRepo,
AiProviderCredentialsRepo,
],
})
export class DatabaseModule implements OnApplicationBootstrap {

View File

@@ -0,0 +1,30 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('ai_provider_credentials')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('driver', 'varchar', (col) => col.notNull())
.addColumn('api_key_enc', 'text', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('uq_ai_provider_credentials_workspace_driver', [
'workspace_id',
'driver',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('ai_provider_credentials').execute();
}

View File

@@ -0,0 +1,66 @@
import { type Kysely, sql } from 'kysely';
/**
* Agent-edit provenance backbone (§5.2 / §6.6 / §15 C2,H2).
*
* Additive provenance markers so an edit "by the agent" is recorded on the page
* and its history snapshot, plus analogous comment columns for a later unit.
* `last_updated_by_id` still names the responsible human author; these columns
* only annotate the source. `'user' | 'agent'` is stored as a short varchar to
* stay forward-compatible without an enum migration.
*/
export async function up(db: Kysely<any>): Promise<void> {
// pages: provenance of the current state (mirrors last_updated_by_id semantics)
await db.schema
.alterTable('pages')
.addColumn('last_updated_source', 'varchar(20)', (col) =>
col.notNull().defaultTo('user'),
)
.addColumn('last_updated_ai_chat_id', 'uuid', (col) =>
col.references('ai_chats.id').onDelete('set null'),
)
.execute();
// page_history: provenance snapshot, copied from the page at save time.
// Nullable (no default) — historical rows predate the marker.
await db.schema
.alterTable('page_history')
.addColumn('last_updated_source', 'varchar(20)', (col) => col)
.addColumn('last_updated_ai_chat_id', 'uuid', (col) =>
col.references('ai_chats.id').onDelete('set null'),
)
.execute();
// comments: analogous markers for a later unit (create + resolve provenance).
await db.schema
.alterTable('comments')
.addColumn('created_source', 'varchar(20)', (col) =>
col.notNull().defaultTo('user'),
)
.addColumn('ai_chat_id', 'uuid', (col) =>
col.references('ai_chats.id').onDelete('set null'),
)
.addColumn('resolved_source', 'varchar(20)', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('comments')
.dropColumn('created_source')
.dropColumn('ai_chat_id')
.dropColumn('resolved_source')
.execute();
await db.schema
.alterTable('page_history')
.dropColumn('last_updated_source')
.dropColumn('last_updated_ai_chat_id')
.execute();
await db.schema
.alterTable('pages')
.dropColumn('last_updated_source')
.dropColumn('last_updated_ai_chat_id')
.execute();
}

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
AiChatMessage,
InsertableAiChatMessage,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AiChatMessageRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
// The `tsv` column is a trigger-maintained tsvector used only for
// full-text search. It must never be selected so it cannot leak into
// HTTP responses or the chat history fed to the language model.
private baseFields: Array<keyof AiChatMessage> = [
'id',
'chatId',
'workspaceId',
'userId',
'role',
'content',
'toolCalls',
'metadata',
'createdAt',
'updatedAt',
'deletedAt',
];
async findByChat(
chatId: string,
workspaceId: string,
pagination?: PaginationOptions,
) {
const query = this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
.where('chatId', '=', chatId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null);
// Default page size when no pagination options are supplied.
const perPage = pagination?.limit ?? 50;
return executeWithCursorPagination(query, {
perPage,
cursor: pagination?.cursor,
beforeCursor: pagination?.beforeCursor,
fields: [
{ expression: 'createdAt', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),
id: cursor.id,
}),
});
}
// Load the most RECENT `limit` messages for a chat and return them in
// ascending chronological order (oldest -> newest), as the model expects.
// `findByChat` returns the FIRST page ASC (the OLDEST messages), which loses
// recent turns once a chat grows beyond a page; this rebuilds the model
// history from the tail instead. Plain query (no cursor pagination).
async findRecent(
chatId: string,
workspaceId: string,
limit: number,
): Promise<AiChatMessage[]> {
const rows = await this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
.where('chatId', '=', chatId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.limit(limit)
.execute();
// Selected newest-first for the limit; reverse to oldest-first for the model.
return rows.reverse();
}
async insert(
insertable: InsertableAiChatMessage,
trx?: KyselyTransaction,
): Promise<AiChatMessage> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiChatMessages')
.values(insertable)
.returning(this.baseFields)
.executeTakeFirst();
}
}

View File

@@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
AiChat,
InsertableAiChat,
UpdatableAiChat,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AiChatRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(id: string, workspaceId: string): Promise<AiChat | undefined> {
return this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async findByCreator(
creatorId: string,
workspaceId: string,
pagination: PaginationOptions,
) {
const query = this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'createdAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),
id: cursor.id,
}),
});
}
async insert(
insertable: InsertableAiChat,
trx?: KyselyTransaction,
): Promise<AiChat> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiChats')
.values(insertable)
.returningAll()
.executeTakeFirst();
}
async update(
id: string,
updatable: UpdatableAiChat,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiChats')
.set({ ...updatable, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
async softDelete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiChats')
.set({ deletedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { AiProviderCredentials } from '@docmost/db/types/entity.types';
/**
* Repository for per-workspace AI provider credentials.
*
* SECURITY (D9/§8.1): rows hold encrypted provider API keys. This table must
* NEVER be added to workspace `baseFields` or returned by any workspace
* endpoint. `api_key_enc` should only be read by the AI driver layer.
*/
@Injectable()
export class AiProviderCredentialsRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async find(
workspaceId: string,
driver: string,
): Promise<AiProviderCredentials | undefined> {
return this.db
.selectFrom('aiProviderCredentials')
.selectAll('aiProviderCredentials')
.where('workspaceId', '=', workspaceId)
.where('driver', '=', driver)
.executeTakeFirst();
}
async upsert(
workspaceId: string,
driver: string,
apiKeyEnc: string,
trx?: KyselyTransaction,
): Promise<AiProviderCredentials> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiProviderCredentials')
.values({ workspaceId, driver, apiKeyEnc })
.onConflict((oc) =>
oc.columns(['workspaceId', 'driver']).doUpdateSet({
apiKeyEnc,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirst();
}
async clearKey(
workspaceId: string,
driver: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiProviderCredentials')
.set({ apiKeyEnc: null, updatedAt: new Date() })
.where('workspaceId', '=', workspaceId)
.where('driver', '=', driver)
.execute();
}
}

View File

@@ -25,6 +25,8 @@ export class PageHistoryRepo {
'icon',
'coverPhoto',
'lastUpdatedById',
'lastUpdatedSource',
'lastUpdatedAiChatId',
'contributorIds',
'spaceId',
'workspaceId',
@@ -75,6 +77,9 @@ export class PageHistoryRepo {
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
// Copy the provenance marker off the page row, as for lastUpdatedById.
lastUpdatedSource: page.lastUpdatedSource,
lastUpdatedAiChatId: page.lastUpdatedAiChatId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,

View File

@@ -35,6 +35,8 @@ export class PageRepo {
'parentPageId',
'creatorId',
'lastUpdatedById',
'lastUpdatedSource',
'lastUpdatedAiChatId',
'spaceId',
'workspaceId',
'isLocked',

View File

@@ -211,6 +211,36 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
/**
* Deep-merge a partial provider config into the fixed path
* `settings.ai.provider`. Unlike `updateAiSettings` (single scalar key under
* `settings.ai`), this stores a nested object. The path is constant — only the
* provider value is parameterized (bound, not `sql.raw`) — so it cannot store
* a secret and is safe from injection. Sibling `settings.ai.*` keys (search /
* generative / chat / mcp / systemPrompt) and provider fields absent from the
* partial are preserved via jsonb `||` merge.
*/
async updateAiProviderSettings(
workspaceId: string,
provider: Record<string, unknown>,
trx?: KyselyTransaction,
): Promise<Workspace> {
const db = dbOrTx(this.db, trx);
const providerJson = JSON.stringify(provider);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('provider', COALESCE(settings->'ai'->'provider', '{}'::jsonb)
|| ${providerJson}::jsonb))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,

View File

@@ -0,0 +1,17 @@
import { Timestamp, Generated } from '@docmost/db/types/db';
// ai_provider_credentials type
// Hand-written (not generated) because codegen requires a live DB.
// Mirrors the migration 20260616T120000-ai-provider-credentials.ts.
//
// SECURITY (D9/§8.1): this table holds encrypted per-workspace provider
// API keys. It must NEVER be added to workspace `baseFields` or returned by
// any workspace endpoint.
export interface AiProviderCredentials {
id: Generated<string>;
workspaceId: string;
driver: string;
apiKeyEnc: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}

View File

@@ -157,8 +157,10 @@ export interface Billing {
}
export interface Comments {
aiChatId: string | null;
content: Json | null;
createdAt: Generated<Timestamp>;
createdSource: Generated<string>;
creatorId: string | null;
deletedAt: Timestamp | null;
editedAt: Timestamp | null;
@@ -168,6 +170,7 @@ export interface Comments {
parentCommentId: string | null;
resolvedAt: Timestamp | null;
resolvedById: string | null;
resolvedSource: string | null;
selection: string | null;
spaceId: string;
type: string | null;
@@ -254,7 +257,9 @@ export interface PageHistory {
createdAt: Generated<Timestamp>;
icon: string | null;
id: Generated<string>;
lastUpdatedAiChatId: string | null;
lastUpdatedById: string | null;
lastUpdatedSource: string | null;
pageId: string;
slug: string | null;
slugId: string | null;
@@ -276,7 +281,9 @@ export interface Pages {
icon: string | null;
id: Generated<string>;
isLocked: Generated<boolean>;
lastUpdatedAiChatId: string | null;
lastUpdatedById: string | null;
lastUpdatedSource: Generated<string>;
parentPageId: string | null;
position: string | null;
slugId: string;

View File

@@ -1,6 +1,8 @@
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import { AiProviderCredentials } from '@docmost/db/types/ai-provider-credentials.types';
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
aiProviderCredentials: AiProviderCredentials;
}

View File

@@ -39,6 +39,7 @@ import {
Templates,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import { AiProviderCredentials as AiProviderCredentialsTable } from '@docmost/db/types/ai-provider-credentials.types';
// AI Chat
export type AiChat = Selectable<AiChats>;
@@ -55,6 +56,16 @@ export type InsertableAiChatMessage = Omit<
'tsv'
>;
// AI Provider Credentials
// SECURITY (D9/§8.1): holds encrypted per-workspace provider API keys.
// Never expose this table through workspace endpoints.
export type AiProviderCredentials = Selectable<AiProviderCredentialsTable>;
export type InsertableAiProviderCredentials =
Insertable<AiProviderCredentialsTable>;
export type UpdatableAiProviderCredentials = Updateable<
Omit<AiProviderCredentialsTable, 'id'>
>;
// Workspace
export type Workspace = Selectable<Workspaces>;
export type InsertableWorkspace = Insertable<Workspaces>;