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

@@ -22,19 +22,36 @@ export class DocmostClient {
client;
token = null;
apiUrl;
email;
password;
// email/password are only set on the service-account (credentials) variant;
// null on the getToken variant (where there are no credentials to log in with).
email = null;
password = null;
// Per-user token provider. When set, login() calls it to obtain a BARE access
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
getTokenFn = null;
// In-flight login dedup: when the token expires, the 401 interceptor,
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
// can all call login() at once. Memoizing a single promise collapses that
// thundering herd into ONE /auth/login request that everyone awaits.
loginPromise = null;
constructor(baseURL, email, password) {
this.apiUrl = baseURL;
this.email = email;
this.password = password;
constructor(configOrBaseURL, email, password) {
// Normalize the legacy positional form into the object union.
const config = typeof configOrBaseURL === "string"
? { apiUrl: configOrBaseURL, email: email, password: password }
: configOrBaseURL;
this.apiUrl = config.apiUrl;
if ("getToken" in config) {
// Token variant: carry the user's JWT via getToken; no credentials, so
// login() must never call performLogin (there is nothing to log in with).
this.getTokenFn = config.getToken;
}
else {
// Service-account variant: behaves exactly as before (performLogin).
this.email = config.email;
this.password = config.password;
}
this.client = axios.create({
baseURL,
baseURL: this.apiUrl,
// Default request timeout so a hung connection cannot wedge a per-page
// lock or block the server indefinitely. Multipart uploads override this
// with a longer per-request timeout.
@@ -84,9 +101,16 @@ export class DocmostClient {
}
async login() {
// Reuse an in-flight login if one is already running so concurrent callers
// share a single /auth/login request instead of each issuing their own.
// share a single token fetch instead of each issuing their own.
if (!this.loginPromise) {
this.loginPromise = performLogin(this.apiUrl, this.email, this.password)
// Token variant: re-fetch a BARE JWT via getToken() (there are no
// credentials to log in with — on a 401/403 the interceptor below calls
// login() again, which re-invokes getToken()). Credentials variant:
// performLogin against /auth/login exactly as before.
const fetchToken = this.getTokenFn
? this.getTokenFn()
: performLogin(this.apiUrl, this.email, this.password);
this.loginPromise = fetchToken
.then((token) => {
this.token = token;
this.client.defaults.headers.common["Authorization"] =

View File

@@ -4,11 +4,20 @@ import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient } from "./client.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
// directly — for the credentials variant OR the per-user getToken variant.
export { DocmostClient } from "./client.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
const VERSION = packageJson.version;
// Configuration for an MCP server instance is the DocmostMcpConfig union
// (credentials OR getToken) defined and re-exported above. The factory below is
// fully side-effect-free on import: it reads no environment variables and opens
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
// (http.ts) supply this config and own the process/transport lifecycle.
// --- Modern McpServer Implementation ---
// Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents.
@@ -28,7 +37,10 @@ const jsonContent = (data) => ({
* credentials and auto-re-authenticates.
*/
export function createDocmostMcpServer(config) {
const docmostClient = new DocmostClient(config.apiUrl, config.email, config.password);
// Pass the whole config union through: the client branches internally on
// credentials vs. getToken, so both the external /mcp (creds) and the
// internal per-user (getToken) paths are wired here unchanged.
const docmostClient = new DocmostClient(config);
const server = new McpServer({
name: "docmost-mcp",
version: VERSION,

View File

@@ -54,24 +54,68 @@ import {
} from "./lib/transforms.js";
import vm from "node:vm";
/**
* Configuration for a DocmostClient / MCP server instance. A discriminated
* union: either service-account credentials (email/password — the client calls
* performLogin, powering the external /mcp HTTP endpoint and the stdio CLI) OR
* a token getter (getToken — the client uses the returned BARE access JWT as
* the Bearer and never calls performLogin; used for the internal per-user path).
*
* Housed here (not in index.ts) so client.ts has no type dependency on index.ts;
* index.ts re-exports it for the package's public surface.
*/
export type DocmostMcpConfig = { apiUrl: string } & (
| { email: string; password: string }
| { getToken: () => Promise<string> } // returns a BARE JWT; the client adds "Bearer "
);
export class DocmostClient {
private client: AxiosInstance;
private token: string | null = null;
private apiUrl: string;
private email: string;
private password: string;
// email/password are only set on the service-account (credentials) variant;
// null on the getToken variant (where there are no credentials to log in with).
private email: string | null = null;
private password: string | null = null;
// Per-user token provider. When set, login() calls it to obtain a BARE access
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
private getTokenFn: (() => Promise<string>) | null = null;
// In-flight login dedup: when the token expires, the 401 interceptor,
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
// can all call login() at once. Memoizing a single promise collapses that
// thundering herd into ONE /auth/login request that everyone awaits.
private loginPromise: Promise<void> | null = null;
constructor(baseURL: string, email: string, password: string) {
this.apiUrl = baseURL;
this.email = email;
this.password = password;
// Two construction forms:
// - new DocmostClient(config) // discriminated union (current)
// - new DocmostClient(baseURL, email, password) // legacy positional creds
// The positional form is retained so existing callers/tests keep working; it
// is exactly equivalent to the credentials branch of the object form.
constructor(config: DocmostMcpConfig);
constructor(baseURL: string, email: string, password: string);
constructor(
configOrBaseURL: DocmostMcpConfig | string,
email?: string,
password?: string,
) {
// Normalize the legacy positional form into the object union.
const config: DocmostMcpConfig =
typeof configOrBaseURL === "string"
? { apiUrl: configOrBaseURL, email: email!, password: password! }
: configOrBaseURL;
this.apiUrl = config.apiUrl;
if ("getToken" in config) {
// Token variant: carry the user's JWT via getToken; no credentials, so
// login() must never call performLogin (there is nothing to log in with).
this.getTokenFn = config.getToken;
} else {
// Service-account variant: behaves exactly as before (performLogin).
this.email = config.email;
this.password = config.password;
}
this.client = axios.create({
baseURL,
baseURL: this.apiUrl,
// Default request timeout so a hung connection cannot wedge a per-page
// lock or block the server indefinitely. Multipart uploads override this
// with a longer per-request timeout.
@@ -129,9 +173,16 @@ export class DocmostClient {
async login() {
// Reuse an in-flight login if one is already running so concurrent callers
// share a single /auth/login request instead of each issuing their own.
// share a single token fetch instead of each issuing their own.
if (!this.loginPromise) {
this.loginPromise = performLogin(this.apiUrl, this.email, this.password)
// Token variant: re-fetch a BARE JWT via getToken() (there are no
// credentials to log in with — on a 401/403 the interceptor below calls
// login() again, which re-invokes getToken()). Credentials variant:
// performLogin against /auth/login exactly as before.
const fetchToken = this.getTokenFn
? this.getTokenFn()
: performLogin(this.apiUrl, this.email!, this.password!);
this.loginPromise = fetchToken
.then((token) => {
this.token = token;
this.client.defaults.headers.common["Authorization"] =

View File

@@ -3,7 +3,13 @@ import { z } from "zod";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient } from "./client.js";
import { DocmostClient, DocmostMcpConfig } from "./client.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
// directly — for the credentials variant OR the per-user getToken variant.
export { DocmostClient } from "./client.js";
export type { DocmostMcpConfig } from "./client.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
@@ -13,15 +19,11 @@ const packageJson = JSON.parse(
);
const VERSION = packageJson.version;
// Configuration for an MCP server instance. The factory below is fully
// side-effect-free on import: it reads no environment variables and opens no
// transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
// Configuration for an MCP server instance is the DocmostMcpConfig union
// (credentials OR getToken) defined and re-exported above. The factory below is
// fully side-effect-free on import: it reads no environment variables and opens
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
// (http.ts) supply this config and own the process/transport lifecycle.
export interface DocmostMcpConfig {
apiUrl: string;
email: string;
password: string;
}
// --- Modern McpServer Implementation ---
@@ -46,11 +48,10 @@ const jsonContent = (data: any) => ({
* credentials and auto-re-authenticates.
*/
export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
const docmostClient = new DocmostClient(
config.apiUrl,
config.email,
config.password,
);
// Pass the whole config union through: the client branches internally on
// credentials vs. getToken, so both the external /mcp (creds) and the
// internal per-user (getToken) paths are wired here unchanged.
const docmostClient = new DocmostClient(config);
const server = new McpServer(
{