n8n/packages/cli/src/modules/instance-ai/instance-ai.controller.ts
oleg 4ae4cc3602
feat: Add self-hosted AI Assistant onboarding (#35579)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Tuukka Kantola <tuukka@n8n.io>
2026-08-07 09:02:52 +00:00

1518 lines
55 KiB
TypeScript

import {
InstanceAiConfirmRequestDto,
InstanceAiFeedbackRequestDto,
InstanceAiGatewayCapabilitiesDto,
InstanceAiGatewayCreateCredentialDto,
InstanceAiFilesystemResponseDto,
InstanceAiRenameThreadRequestDto,
InstanceAiSendMessageRequest,
InstanceAiEventsQuery,
instanceAiGatewayKeySchema,
InstanceAiCorrectTaskRequest,
InstanceAiEnsureThreadRequest,
InstanceAiThreadMessagesQuery,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
InstanceAiVerifySearchRequest,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiEvalExecutionRequest,
InstanceAiEvalAgentExecutionRequest,
InstanceAiEvalCredentialAllowlistRequest,
InstanceAiEvalRestoreThreadRequest,
InstanceAiEvalSeedDataTableRowsRequest,
findUnbackedSeedWorkflowTools,
} from '@n8n/api-types';
import type {
InstanceAiAdminSettingsResponse,
InstanceAiAgentNode,
InstanceAiEvent,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import {
RestController,
GlobalScope,
Middleware,
Get,
Post,
Put,
Patch,
Delete,
OnPubSubEvent,
Param,
Body,
Query,
} from '@n8n/decorators';
import type { AgentTreeSnapshot, StoredEvent } from '@n8n/instance-ai';
import {
buildAgentTreeFromEvents,
clearedAgentBuilderTargetMetadata,
seedAgentBuilderTargetMetadata,
} from '@n8n/instance-ai';
import { UnsupportedAttachmentError, validateAttachmentMimeTypes } from '@n8n/instance-ai/parsers';
import type { NextFunction, Request, Response } from 'express';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { InstanceAiBrowserSessionService } from './browser/instance-ai-browser-session.service';
import { EvalAgentExecutionService } from './eval/agent-execution.service';
import { EvalExecutionService } from './eval/execution.service';
import { EvalThreadCredentialAllowlistService } from './eval/thread-credential-allowlist.service';
import { EvalThreadRestoreService } from './eval/thread-restore.service';
import { DurableEventLog } from './event-bus/durable-event-log';
import { DurableLogMetrics } from './event-bus/durable-log-metrics';
import { InProcessEventBus } from './event-bus/in-process-event-bus';
import { InstanceAiErrorReporterService } from './instance-ai-error-reporter.service';
import { InstanceAiGatewayService } from './instance-ai-gateway.service';
import { InstanceAiMemoryService } from './instance-ai-memory.service';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
import { InstanceAiVerificationService } from './instance-ai-verification.service';
import { InstanceAiService } from './instance-ai.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { ProjectService } from '@/services/project.service.ee';
import { UrlService } from '@/services/url.service';
type FlushableResponse = Response & { flush?: () => void };
const KEEP_ALIVE_INTERVAL_MS = 15_000;
@RestController('/instance-ai')
export class InstanceAiController {
private readonly gatewayApiKey: string;
/** Durable-log prototype flag (N8N_INSTANCE_AI_DURABLE_LOG): replay and
* cursors come from the DB-backed log instead of the in-memory bus. */
private readonly durableLogEnabled: boolean;
private static getTreeRichnessScore(tree: InstanceAiAgentNode): number {
let score = 0;
const stack = [tree];
while (stack.length > 0) {
const node = stack.pop()!;
score += 100;
score += node.toolCalls.length * 10;
score += node.timeline.length * 2;
score += (node.planItems?.length ?? 0) * 20;
score += node.toolCalls.filter((toolCall) => toolCall.confirmation).length * 50;
score += node.children.length * 25;
stack.push(...node.children);
}
return score;
}
private static selectBootstrapTree(
eventTree: InstanceAiAgentNode,
persistedTree?: InstanceAiAgentNode,
): InstanceAiAgentNode {
if (!persistedTree) return eventTree;
return InstanceAiController.getTreeRichnessScore(persistedTree) >
InstanceAiController.getTreeRichnessScore(eventTree)
? persistedTree
: eventTree;
}
constructor(
private readonly instanceAiService: InstanceAiService,
private readonly gatewayService: InstanceAiGatewayService,
private readonly browserSessionService: InstanceAiBrowserSessionService,
private readonly memoryService: InstanceAiMemoryService,
private readonly settingsService: InstanceAiSettingsService,
private readonly evalExecutionService: EvalExecutionService,
private readonly evalAgentExecutionService: EvalAgentExecutionService,
private readonly evalCredentialAllowlists: EvalThreadCredentialAllowlistService,
private readonly evalThreadRestore: EvalThreadRestoreService,
private readonly eventBus: InProcessEventBus,
private readonly eventLog: DurableEventLog,
private readonly durableLogMetrics: DurableLogMetrics,
private readonly moduleRegistry: ModuleRegistry,
private readonly push: Push,
private readonly urlService: UrlService,
private readonly userRepository: UserRepository,
private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService,
private readonly instanceAiErrorReporter: InstanceAiErrorReporterService,
private readonly publisher: Publisher,
globalConfig: GlobalConfig,
) {
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
this.durableLogEnabled = globalConfig.instanceAi.durableLog;
}
private requireInstanceAiEnabled(): void {
if (!this.settingsService.isInstanceAiEnabled()) {
throw new ForbiddenError('Instance AI is disabled');
}
}
private requireRunDebugEnabled(): void {
if (!this.instanceAiService.isRunDebugEnabled()) {
throw new NotFoundError('Run debug is not enabled');
}
}
// Each BrotliCompress stream allocates ~8.6 MB of native memory for its
// dictionary, and the compression middleware retains streams via closures on
// the response object for the lifetime of the HTTP keep-alive connection.
// Downgrade to gzip (~few KB per stream) for all instance-ai endpoints.
@Middleware()
stripBrotli(req: Request, _res: Response, next: NextFunction) {
const ae = req.headers['accept-encoding'];
if (typeof ae === 'string' && ae.includes('br')) {
req.headers['accept-encoding'] = ae.replace(/\bbr\b,?\s*/g, '').replace(/,\s*$/, '');
}
next();
}
@Post('/chat/:threadId')
@GlobalScope('instanceAi:message')
async chat(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Body payload: InstanceAiSendMessageRequest,
) {
this.requireInstanceAiEnabled();
if (!payload.message && (!payload.attachments || payload.attachments.length === 0)) {
throw new BadRequestError('Either message or attachments must be provided');
}
// Verify the requesting user owns this thread (or it's new)
await this.assertThreadAccess(req.user.id, threadId, { allowNew: true });
// Only file attachments carry a mime type to validate; workflow and agent
// attachments are resource references the agent resolves with its tools.
const fileAttachments = (payload.attachments ?? []).filter(
(attachment) => attachment.type === 'file',
);
if (fileAttachments.length > 0) {
try {
validateAttachmentMimeTypes(fileAttachments);
} catch (error) {
if (error instanceof UnsupportedAttachmentError) {
const summary = error.unsupported.map((u) => `${u.fileName} (${u.mimeType})`).join(', ');
throw new BadRequestError(
`Unsupported attachment type: ${summary}. Supported types include CSV, JSON, ` +
'PDF, DOCX, XLSX, HTML, plain text, markdown, and images.',
);
}
throw error;
}
}
// One active run per thread
if (this.instanceAiService.hasActiveRun(threadId)) {
throw new ConflictError('A run is already active for this thread');
}
const runId = this.instanceAiService.startRun(
req.user,
threadId,
payload.message,
payload.attachments,
payload.context,
payload.timeZone,
payload.pushRef,
);
return { runId };
}
// usesTemplates bypasses the send() wrapper so we can write SSE frames directly
@Get('/events/:threadId', { usesTemplates: true })
@GlobalScope('instanceAi:message')
async events(
req: AuthenticatedRequest,
res: FlushableResponse,
@Param('threadId') threadId: string,
@Query query: InstanceAiEventsQuery,
) {
this.requireInstanceAiEnabled();
// Verify the requesting user owns this thread before streaming events.
// A thread that doesn't exist yet is allowed — the frontend opens the SSE
// connection for new conversations before the first message creates the thread.
const ownership = await this.memoryService.checkThreadOwnership(req.user.id, threadId);
if (ownership === 'other_user') {
throw new ForbiddenError('Not authorized for this thread');
}
// When the thread didn't exist at connect time, another user could create
// and own it before events start flowing. We re-check once on the first
// event and close the stream if ownership changed. Events are buffered
// until the check resolves to prevent leaking data during the async gap.
let ownershipVerified = ownership === 'owned';
let ownershipCheckInFlight = false;
const pendingEvents: StoredEvent[] = [];
const userId = req.user.id;
// 1. Subscribe to live events before the async bootstrap below.
// hasSubscribers() must be true across the awaits that follow: in
// multi-main, sibling mains drop relayed events for threads without a
// local subscriber, so a relayed event arriving during an await would
// otherwise be lost for good. Events emitted while bootstrapping are
// NOT delivered here — they land in the event store and the replay in
// step 6 picks them up, avoiding duplicates.
let bootstrapping = true;
const deliver = (stored: StoredEvent) => {
if (ownershipVerified) {
this.writeSseEvent(res, stored);
return;
}
// When the thread was not_found at connect time, re-validate ownership
// on the first event. Buffer all events until the check resolves to
// avoid leaking data during the async gap.
pendingEvents.push(stored);
if (ownershipCheckInFlight) return;
ownershipCheckInFlight = true;
void this.memoryService
.checkThreadOwnership(userId, threadId)
.then((currentOwnership) => {
if (currentOwnership === 'other_user') {
res.end();
return;
}
ownershipVerified = true;
for (const buffered of pendingEvents) {
this.writeSseEvent(res, buffered);
}
pendingEvents.length = 0;
})
.catch(() => {
pendingEvents.length = 0;
res.end();
});
};
const unsubscribe = this.eventBus.subscribe(threadId, (stored) => {
if (bootstrapping) return;
deliver(stored);
});
// Cleanup is registered before the async bootstrap so a client disconnect
// (or an error response) during the awaits below doesn't leak the
// subscription.
let closed = false;
let keepAlive: NodeJS.Timeout | undefined = undefined;
const cleanup = () => {
closed = true;
unsubscribe();
if (keepAlive !== undefined) clearInterval(keepAlive);
};
req.once('close', cleanup);
res.once('finish', cleanup);
// 2. Re-publish any terminal outcomes that never reached the client.
if (ownership === 'owned') {
await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId, {
delivery: 'event',
});
}
// 3. Set SSE headers.
// Disable response compression — SSE streams small chunks where compression
// overhead exceeds the benefit, and each Brotli compressor retains ~8.6 MB
// of native memory for the lifetime of the connection.
(res as unknown as { compress: boolean }).compress = false;
res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// 4. Determine replay cursor
// Last-Event-ID header (browser auto-reconnect) takes precedence over query param.
// Both are validated as non-negative integers; invalid values fall back to 0.
const headerValue = req.headers['last-event-id'];
const parsedHeader = headerValue ? parseInt(String(headerValue), 10) : NaN;
const cursor =
Number.isFinite(parsedHeader) && parsedHeader >= 0 ? parsedHeader : (query.lastEventId ?? 0);
// 5. Collect live message groups and fetch their persisted snapshots.
// Multiple groups can be active simultaneously when a background task
// from an older turn outlives its original turn.
const threadStatus = this.instanceAiService.getThreadStatus(threadId);
// Collect all distinct message groups that have live activity.
const liveGroups = new Map<
string,
{ runIds: string[]; status: 'active' | 'suspended' | 'background' }
>();
// The active/suspended orchestrator run's group
if (threadStatus.hasActiveRun || threadStatus.isSuspended) {
const groupId = this.instanceAiService.getMessageGroupId(threadId);
if (groupId) {
liveGroups.set(groupId, {
runIds: this.instanceAiService.getRunIdsForMessageGroup(groupId),
status: threadStatus.hasActiveRun ? 'active' : 'suspended',
});
}
}
// Background tasks — each may belong to a different group
for (const task of threadStatus.backgroundTasks) {
if (task.status !== 'running' || !task.messageGroupId) continue;
if (!liveGroups.has(task.messageGroupId)) {
liveGroups.set(task.messageGroupId, {
runIds: this.instanceAiService.getRunIdsForMessageGroup(task.messageGroupId),
status: 'background',
});
}
}
const persistedSnapshots = new Map<string, AgentTreeSnapshot | undefined>();
for (const [groupId, group] of liveGroups) {
persistedSnapshots.set(
groupId,
await this.memoryService.getLatestRunSnapshot(threadId, {
messageGroupId: groupId,
// Use the group's own latest runId — NOT the thread-global
// activeRunId, which belongs to the current orchestrator turn and
// would be wrong for background groups from older turns.
runId: group.runIds.at(-1),
}),
);
}
// The client may have disconnected during the awaits above.
if (closed) return;
// 6b (used by both arms below). Emit one run-sync control frame for a live
// message group. Each frame uses a named SSE event type
// (event: run-sync) with NO id: field so the browser's lastEventId is
// unaffected and the replay cursor stays consistent.
const writeRunSyncFrame = (
groupId: string,
group: { runIds: string[]; status: 'active' | 'suspended' | 'background' },
runEvents: InstanceAiEvent[],
) => {
const persistedSnapshot = persistedSnapshots.get(groupId);
if (runEvents.length === 0 && !persistedSnapshot) return;
const eventTree = buildAgentTreeFromEvents(runEvents);
const agentTree = InstanceAiController.selectBootstrapTree(
eventTree,
persistedSnapshot?.tree,
);
res.write(
`event: run-sync\ndata: ${JSON.stringify({
runId: group.runIds.at(-1),
messageGroupId: groupId,
runIds: group.runIds,
agentTree,
status: group.status,
backgroundTasks: threadStatus.backgroundTasks,
})}\n\n`,
);
};
if (this.durableLogEnabled) {
// 6. Replay missed events from the DURABLE log — survives restarts and is
// valid on any main (the table is in the shared DB). The reads are
// async, so unlike the old synchronous memory-store replay, live
// events can land mid-bootstrap: buffer them across every await (the
// replay read, the run-sync tree reads, AND the gap read) and flush
// with seq dedupe only when no await remains before live delivery
// takes over (the drain persists before it emits, so a fact is never
// in neither place). A flushed event may already be folded into a
// run-sync tree; the shared reducer applies it idempotently, same as
// any live event arriving after a frame.
const arrivedDuringReplay: StoredEvent[] = [];
const stopBuffering = this.eventBus.subscribe(threadId, (stored) => {
arrivedDuringReplay.push(stored);
});
try {
const missed = await this.eventLog.getEventsAfter(threadId, cursor);
// The client may have disconnected during the read: stop before
// writing to the dead response or arming the keep-alive below.
if (closed) return;
let lastReplayedSeq = cursor;
for (const stored of missed) {
deliver(stored);
if (stored.id !== undefined) lastReplayedSeq = stored.id;
}
// Build each live group's bootstrap tree from the durable log, so the
// group renders fully even when the bus cache was evicted, the process
// restarted, or this main never buffered the thread (sibling main).
// Remember which coalesced blocks each delivered tree folds: the gap
// read below may return the same rows, and re-applying a block the
// tree already renders would append a duplicate timeline entry.
const blockKey = (event: {
type: string;
runId: string;
agentId: string;
responseId?: string;
payload: { text: string };
}) =>
`${event.type}:${event.runId}:${event.agentId}:${event.responseId ?? ''}:${event.payload.text}`;
const foldedBlockKeys = new Set<string>();
for (const [groupId, group] of liveGroups) {
const runEvents = await this.eventLog.getEventsForRuns(threadId, group.runIds);
if (closed) return;
writeRunSyncFrame(groupId, group, runEvents);
for (const event of runEvents) {
if (event.type === 'text-block' || event.type === 'reasoning-block') {
foldedBlockKeys.add(blockKey(event));
}
}
}
// One more durable read: coalesced blocks are persisted but never
// live-emitted (live clients saw the deltas), so a segment that closed
// during the awaits above exists only as rows the replay read predates
// — invisible to the buffering subscription. Without this read, the
// buffered fact that follows such a block would advance the browser
// cursor past it and no later replay would ever return it.
const gapRows = await this.eventLog.getEventsAfter(threadId, lastReplayedSeq);
if (closed) return;
// A still-streaming segment exists only in the log's coalesce buffer
// (deltas are never persisted), so a mid-stream refresh would render
// only the post-refresh tail. Serve each open segment as one ephemeral
// delta frame (no `id:` line — the cursor stays on durable facts), after
// the run-sync frames so live deltas keep appending to it and the
// segment's eventual block replaces it. Everything from this read to
// `bootstrapping = false` is synchronous, so a buffered delta of a
// served segment is exactly text inside the snapshot: skipping it loses
// nothing and delivering it would duplicate.
const openSegments = this.eventLog.getOpenSegments(threadId);
const segmentKey = (
kind: 'text' | 'reasoning',
event: { runId: string; agentId: string; responseId?: string },
) => `${kind}:${event.runId}:${event.agentId}:${event.responseId ?? ''}`;
const served = new Set(openSegments.map((segment) => segmentKey(segment.kind, segment)));
// Deliver the gap rows first. A block identical to one folded into a
// delivered run-sync tree is not re-applied (that would duplicate its
// text) but still counts as delivered for cursor contiguity — its
// content reached the client inside the frame. Buffered deltas of a
// gap block's segment are skipped below like served ones: their text
// is inside the block, and delivering them after it would duplicate.
const gapBlockSegments = new Set<string>();
for (const row of gapRows) {
if (row.id === undefined || row.id <= lastReplayedSeq) continue;
const { event } = row;
if (event.type === 'text-block' || event.type === 'reasoning-block') {
gapBlockSegments.add(
segmentKey(event.type === 'text-block' ? 'text' : 'reasoning', event),
);
if (foldedBlockKeys.has(blockKey(event))) {
lastReplayedSeq = row.id;
continue;
}
}
deliver(row);
lastReplayedSeq = row.id;
}
for (const stored of arrivedDuringReplay) {
if (stored.id !== undefined) {
if (stored.id <= lastReplayedSeq) continue;
if (stored.id === lastReplayedSeq + 1) {
deliver(stored);
lastReplayedSeq = stored.id;
continue;
}
// Rows between the cursor and this fact were persisted after the
// gap read (a segment closed while it was in flight): deliver the
// fact's content but strip its id line, so the cursor never
// crosses a row the client has not seen — the next replay returns
// the missing block and re-applies this fact idempotently.
deliver({ event: stored.event });
continue;
}
const { event } = stored;
if (
(event.type === 'text-delta' || event.type === 'reasoning-delta') &&
(served.has(segmentKey(event.type === 'text-delta' ? 'text' : 'reasoning', event)) ||
gapBlockSegments.has(
segmentKey(event.type === 'text-delta' ? 'text' : 'reasoning', event),
))
) {
continue;
}
deliver(stored);
}
for (const segment of openSegments) {
deliver({
event: {
type: segment.kind === 'text' ? 'text-delta' : 'reasoning-delta',
runId: segment.runId,
agentId: segment.agentId,
...(segment.responseId ? { responseId: segment.responseId } : {}),
payload: { text: segment.text },
},
});
}
this.durableLogMetrics.recordReplay(missed.length, Math.max(0, lastReplayedSeq - cursor));
} finally {
// The buffering subscription must not outlive the bootstrap, even when
// a durable read throws.
stopBuffering();
}
} else {
// 6. Replay missed events, emit run-sync frames, and flip to live delivery
// in one synchronous block. The event bus store and emitter are
// synchronous, so no event can slip between the replay and the live
// handler taking over. Events that arrived during the awaits above are
// already in the store (the early subscription in step 1 keeps relayed
// events flowing in multi-main) and are included in the replay here.
const missed = this.eventBus.getEventsAfter(threadId, cursor);
for (const stored of missed) {
deliver(stored);
}
for (const [groupId, group] of liveGroups) {
writeRunSyncFrame(groupId, group, this.eventBus.getEventsForRuns(threadId, group.runIds));
}
}
if (liveGroups.size > 0) res.flush?.();
bootstrapping = false;
// 7. Keep-alive
keepAlive = setInterval(() => {
res.write(': ping\n\n');
res.flush?.();
}, KEEP_ALIVE_INTERVAL_MS);
}
@Post('/confirm/:requestId')
@GlobalScope('instanceAi:message')
async confirm(req: AuthenticatedRequest, _res: Response, @Param('requestId') requestId: string) {
this.requireInstanceAiEnabled();
// Manual parse: `@Body` decorator can't resolve zod discriminated unions via reflection,
// so validate the request body against the union schema directly.
const parseResult = InstanceAiConfirmRequestDto.safeParse(req.body);
if (!parseResult.success) {
throw new BadRequestError(parseResult.error.errors[0].message);
}
const resolved = await this.instanceAiService.resolveConfirmation(
req.user.id,
requestId,
parseResult.data,
);
if (!resolved) {
throw new NotFoundError('Confirmation request not found or not authorized');
}
return resolved;
}
@Post('/chat/:threadId/cancel')
@GlobalScope('instanceAi:message')
async cancel(req: AuthenticatedRequest, _res: Response, @Param('threadId') threadId: string) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
await this.instanceAiService.routeCancelRun(threadId);
return { ok: true };
}
@Post('/feedback/:threadId/:responseId')
@GlobalScope('instanceAi:message')
async feedback(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Param('responseId') responseId: string,
@Body payload: InstanceAiFeedbackRequestDto,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
// Fire-and-forget: never surface LangSmith errors to the UI. The service
// logs its own failures; the .catch here guards against unhandled
// rejections from paths not wrapped internally (e.g. DB lookups).
void this.instanceAiService
.submitLangsmithFeedback(req.user, threadId, responseId, payload)
.catch(() => {});
return { ok: true };
}
@Post('/chat/:threadId/tasks/:taskId/cancel')
@GlobalScope('instanceAi:message')
async cancelTask(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Param('taskId') taskId: string,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
await this.instanceAiService.routeCancelBackgroundTask(threadId, taskId);
return { ok: true };
}
@Post('/chat/:threadId/tasks/:taskId/correct')
@GlobalScope('instanceAi:message')
async correctTask(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Param('taskId') taskId: string,
@Body payload: InstanceAiCorrectTaskRequest,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
await this.instanceAiService.routeCorrectionToTask(threadId, taskId, payload.message);
return { ok: true };
}
// ── Credits ──────────────────────────────────────────────────────────────
@Get('/credits')
@GlobalScope('instanceAi:message')
async getCredits(req: AuthenticatedRequest) {
this.requireInstanceAiEnabled();
return await this.instanceAiService.getCredits(req.user);
}
// ── Admin settings (owner/admin only) ──────────────────────────────────
@Get('/settings')
@GlobalScope('instanceAi:manage')
async getAdminSettings(_req: AuthenticatedRequest) {
return await this.settingsService.getAdminSettings();
}
@Put('/settings')
@GlobalScope('instanceAi:manage')
async updateAdminSettings(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiAdminSettingsUpdateRequest,
) {
const result = await this.settingsService.updateAdminSettings(payload, req.user);
const [publishResult] = await Promise.allSettled([
this.publisher.publishCommand({ command: 'reload-instance-ai-settings' }),
this.applyAdminSettingsSideEffects(result),
]);
if (publishResult.status === 'rejected') {
this.instanceAiErrorReporter.report(publishResult.reason, {
component: 'settings-publish',
threadId: 'admin-settings',
});
}
return result;
}
@Post('/settings/verify/model')
@GlobalScope('instanceAi:manage')
async verifyModel(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiVerifyModelRequest,
) {
return await Container.get(InstanceAiVerificationService).verifyModel(req.user, payload);
}
@Post('/settings/verify/sandbox')
@GlobalScope('instanceAi:manage')
async verifySandbox(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiVerifySandboxRequest,
) {
return await Container.get(InstanceAiVerificationService).verifySandbox(req.user, payload);
}
@Post('/settings/verify/search')
@GlobalScope('instanceAi:manage')
async verifySearch(
_req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiVerifySearchRequest,
) {
return await Container.get(InstanceAiVerificationService).verifySearch(payload);
}
@OnPubSubEvent('reload-instance-ai-settings', { instanceType: 'main' })
async reloadAdminSettings() {
await this.settingsService.reloadFromDb();
await this.applyAdminSettingsSideEffects({
enabled: this.settingsService.isInstanceAiEnabled(),
browserUseEnabled: this.settingsService.isBrowserUseEnabled(),
localGatewayDisabled: this.settingsService.isLocalGatewayDisabled(),
});
}
private async applyAdminSettingsSideEffects(
settings: Pick<
InstanceAiAdminSettingsResponse,
'enabled' | 'browserUseEnabled' | 'localGatewayDisabled'
>,
) {
const sideEffects: Array<() => Promise<void> | void> = [
async () => {
await this.moduleRegistry.refreshModuleSettings('instance-ai');
},
];
if (!settings.enabled || !settings.browserUseEnabled) {
sideEffects.push(async () => await this.browserSessionService.shutdown());
}
if (!settings.enabled || settings.localGatewayDisabled) {
sideEffects.push(() => {
const disconnectedUserIds = this.gatewayService.disconnectAllGateways();
if (disconnectedUserIds.length === 0) return;
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: false,
directory: null,
hostIdentifier: null,
toolCategories: [],
},
},
disconnectedUserIds,
);
});
}
const results = await Promise.allSettled(sideEffects.map(async (apply) => await apply()));
for (const result of results) {
if (result.status === 'rejected') {
this.instanceAiErrorReporter.report(result.reason, {
component: 'settings-side-effects',
threadId: 'admin-settings',
});
}
}
}
// ── User preferences (per-user, self-service) ──────────────────────────
@Get('/preferences')
@GlobalScope('instanceAi:message')
async getUserPreferences(req: AuthenticatedRequest) {
return await this.settingsService.getUserPreferences(req.user);
}
@Put('/preferences')
@GlobalScope('instanceAi:message')
async updateUserPreferences(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiUserPreferencesUpdateRequest,
) {
const result = await this.settingsService.updateUserPreferences(req.user, payload);
if (payload.localGatewayDisabled !== undefined) {
await this.moduleRegistry.refreshModuleSettings('instance-ai');
}
return result;
}
@Get('/settings/service-credentials')
@GlobalScope('instanceAi:manage')
async listServiceCredentials(_req: AuthenticatedRequest) {
return await this.settingsService.listInstanceServiceCredentials();
}
@Get('/settings/model-credentials')
@GlobalScope('instanceAi:manage')
async listInstanceModelCredentials(_req: AuthenticatedRequest) {
return await this.settingsService.listInstanceModelCredentials();
}
@Get('/threads')
@GlobalScope('instanceAi:message')
async listThreads(req: AuthenticatedRequest) {
this.requireInstanceAiEnabled();
return await this.memoryService.listThreads(req.user.id);
}
@Post('/threads')
@GlobalScope('instanceAi:message')
async ensureThread(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiEnsureThreadRequest,
) {
this.requireInstanceAiEnabled();
const project = await this.projectService.getProjectWithScope(req.user, payload.projectId, [
'project:read',
]);
if (!project) {
throw new ForbiddenError('You do not have access to the requested project');
}
const requestedThreadId = payload.threadId ?? randomUUID();
await this.assertThreadAccess(req.user.id, requestedThreadId, { allowNew: true });
const launchMetadata = {
source: payload.source,
origin: payload.origin ?? ('internal' as const),
sourceContext: payload.sourceContext,
};
try {
return await this.memoryService.ensureThread(
req.user.id,
requestedThreadId,
payload.projectId,
launchMetadata,
);
} catch (error) {
this.instanceAiErrorReporter.report(error, {
component: 'instance-ai-ensure-thread',
threadId: requestedThreadId,
userId: req.user.id,
projectId: payload.projectId,
});
throw error;
}
}
@Delete('/threads/:threadId')
@GlobalScope('instanceAi:message')
async deleteThread(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
await this.instanceAiService.routeClearThreadState(threadId);
await this.memoryService.deleteThread(threadId);
return { ok: true };
}
@Patch('/threads/:threadId')
@GlobalScope('instanceAi:message')
async renameThread(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Body payload: InstanceAiRenameThreadRequestDto,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
const thread = await this.memoryService.updateThread(threadId, {
title: payload.title,
metadata: payload.metadata,
});
return { thread };
}
@Get('/threads/:threadId/messages')
@GlobalScope('instanceAi:message')
async getThreadMessages(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Query query: InstanceAiThreadMessagesQuery,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId);
// ?raw=true returns the old format for the thread inspector
if (query.raw === 'true') {
return await this.memoryService.getThreadMessages(req.user.id, threadId, {
limit: query.limit,
page: query.page,
});
}
// Exclude snapshots for active/suspended runs — they have no matching
// assistant message in native memory yet and would misalign the
// positional snapshot-to-message matching in parseStoredMessages. The
// live message-group ids ride along so the durable-log fold can exclude
// a whole in-flight group even when the active run's own run-start row
// has not been persisted yet (it is the group mapping's source there).
const threadStatus = this.instanceAiService.getThreadStatus(threadId);
const activeRunId = this.instanceAiService.getActiveRunId(threadId);
const excludeRunIds: string[] = [];
const excludeMessageGroupIds: string[] = [];
if (activeRunId) {
excludeRunIds.push(activeRunId);
const activeGroupId = this.instanceAiService.getMessageGroupId(threadId);
if (activeGroupId) excludeMessageGroupIds.push(activeGroupId);
}
for (const t of threadStatus.backgroundTasks) {
if (t.status !== 'running') continue;
if (t.runId) excludeRunIds.push(t.runId);
if (t.messageGroupId) excludeMessageGroupIds.push(t.messageGroupId);
}
const result = await this.memoryService.getRichMessages(req.user.id, threadId, {
limit: query.limit,
page: query.page,
excludeRunIds: excludeRunIds.length > 0 ? excludeRunIds : undefined,
excludeMessageGroupIds:
excludeMessageGroupIds.length > 0 ? excludeMessageGroupIds : undefined,
});
// Include the next SSE event ID so the frontend can skip past events
// already covered by these historical messages (prevents duplicates).
// Flag on: durable authority, valid across restarts and mains. Flag off:
// the shared sequence, so the cursor is valid against any main.
const nextEventId = this.durableLogEnabled
? await this.eventLog.getNextEventId(threadId)
: await this.eventBus.getNextEventId(threadId);
return { ...result, nextEventId };
}
@Get('/threads/:threadId/status')
@GlobalScope('instanceAi:message')
async getThreadStatus(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
) {
this.requireInstanceAiEnabled();
// Allow new threads — the frontend polls status before the first message is sent
await this.assertThreadAccess(req.user.id, threadId, { allowNew: true });
return this.instanceAiService.getThreadStatus(threadId);
}
@Get('/debug/runs/:runId')
@GlobalScope('instanceAi:message')
async getRunDebug(req: AuthenticatedRequest, _res: Response, @Param('runId') runId: string) {
this.requireInstanceAiEnabled();
this.requireRunDebugEnabled();
const record = this.instanceAiService.getRunDebug(runId);
if (!record) {
throw new NotFoundError('Run debug record not found');
}
await this.assertThreadAccess(req.user.id, record.threadId);
return record;
}
@Get('/debug/threads/:threadId/runs')
@GlobalScope('instanceAi:message')
async listThreadDebugRuns(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
) {
this.requireInstanceAiEnabled();
this.requireRunDebugEnabled();
await this.assertThreadAccess(req.user.id, threadId);
return {
threadId,
runs: this.instanceAiService.listThreadDebugRuns(threadId),
};
}
// ── Evaluation endpoints ──────────────────────────────────────────────────
// Runs for minutes; the eval client (N8nClient) disables undici's 300s timeout for it.
@Post('/eval/execute-with-llm-mock/:workflowId')
@GlobalScope('instanceAi:eval')
async executeWithLlmMock(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
@Body payload: InstanceAiEvalExecutionRequest,
) {
return await this.evalExecutionService.executeWithLlmMock(workflowId, req.user, payload);
}
// Runs for minutes; same client timeout handling as the workflow variant.
@Post('/eval/execute-agent-with-llm-mock/:agentId')
@GlobalScope('instanceAi:eval')
async executeAgentWithLlmMock(
req: AuthenticatedRequest,
_res: Response,
@Param('agentId') agentId: string,
@Body payload: InstanceAiEvalAgentExecutionRequest,
) {
return await this.evalAgentExecutionService.executeWithLlmMock(agentId, req.user, payload);
}
/**
* Pin a build thread's credential view to a declared set. Only narrows:
* `list()` results are intersected with these IDs, so the caller cannot see
* anything they couldn't already access. The thread must exist — entries are
* cleared with the thread's state, so pins for never-created threads would
* be uncollectable.
*/
@Post('/eval/thread-credential-allowlist')
@GlobalScope('instanceAi:eval')
async setThreadCredentialAllowlist(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiEvalCredentialAllowlistRequest,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, payload.threadId);
this.evalCredentialAllowlists.set(
payload.threadId,
payload.credentialIds,
payload.bypassCredentialTest,
);
return { ok: true };
}
/**
* Seed an existing (owned) thread with a previously exported conversation:
* recreate the artifacts the history references — workflows (node credentials
* stripped — see `EvalThreadRestoreService`), data tables and agents — then
* write the native message log verbatim. The thread then continues as if the
* conversation really happened, so an eval can drive the next turn live.
*/
@Post('/eval/restore-thread')
@GlobalScope('instanceAi:eval')
async restoreEvalThread(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiEvalRestoreThreadRequest,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, payload.threadId);
const projectId = await this.memoryService.getThreadProjectId(payload.threadId);
if (!projectId) {
throw new BadRequestError('Thread is not bound to a project');
}
const workflows = payload.workflows ?? [];
const agents = payload.agents ?? [];
// Cross-field, so the schema can't own it: a seeded agent's workflow tool is
// resolved by DISPLAY NAME, and a name no seeded workflow carries restores a
// dead tool (or binds an unrelated ambient workflow of the same name).
const unbacked = findUnbackedSeedWorkflowTools(payload);
if (unbacked.length > 0) {
throw new BadRequestError(
unbacked
.map(
({ agentId, target }: { agentId: string; target: unknown }) =>
`Seed agent ${agentId} has a workflow tool targeting ${JSON.stringify(target)}, which no seeded workflow's name matches`,
)
.join('; '),
);
}
// Data tables first: the workflows reference them, and their ids are
// rewritten to the recreated tables' ids during workflow restore.
const idMap = await this.evalThreadRestore.restoreDataTables(
payload.dataTables ?? [],
projectId,
{ uniquifyNames: payload.uniquifyNames ?? true },
);
const dataTableIds = [...idMap.values()];
// Roll back everything we created if a later step fails, so a partial
// restore doesn't leak workflows/tables/agents into the shared eval project.
let restored = 0;
let createdWorkflowIds: string[] = [];
let createdAgentIds: string[] = [];
// Captured so the binding write is undoable: the message write happens after
// it, and without this a message failure left a binding pointing at agents the
// rollback had already deleted.
let priorMetadata: Record<string, unknown> | undefined;
let bindingWritten = false;
try {
createdWorkflowIds = await this.evalThreadRestore.restoreWorkflows(
workflows,
projectId,
idMap,
);
createdAgentIds = await this.evalThreadRestore.restoreAgents(agents, projectId, idMap);
// Built (and validated) BEFORE the message write: a rejected binding — two
// agents whose refs collide — must fail while the restore is still fully
// rollback-able, not after the messages have committed.
const binding =
createdAgentIds.length > 0
? seedAgentBuilderTargetMetadata(
agents.map((agent) => ({
agentId: agent.id,
projectId,
name: agent.config.name,
ref: agent.config.name,
})),
payload.messages,
)
: undefined;
// Bind the thread as the conversation that built these agents would have, or
// the live turn's first `build-agent` call is rejected as an unknown agentRef.
// BEFORE the messages, and undoable: the catch restores the prior metadata,
// so a message failure can't leave a binding pointing at deleted agents, and
// a binding failure can't leave messages referencing them.
if (binding) {
priorMetadata = await this.memoryService.getThreadMetadata(req.user.id, payload.threadId);
await this.memoryService.updateThread(payload.threadId, { metadata: binding });
bindingWritten = true;
}
// A data-table-only seed (TRUST-311) sends no messages — skip the write.
if (payload.messages.length > 0) {
({ restored } = await this.memoryService.restoreThreadMessages(
req.user.id,
payload.threadId,
payload.messages,
));
}
} catch (error) {
if (bindingWritten) {
try {
// `updateThread` MERGES, so the prior snapshot alone would leave the
// binding keys standing — this names them and restores each.
await this.memoryService.updateThread(payload.threadId, {
metadata: clearedAgentBuilderTargetMetadata(priorMetadata),
});
} catch {
// Best-effort, like the artifact deletes: never throw over the failure
// that triggered the rollback.
}
}
await this.evalThreadRestore.deleteAgents(createdAgentIds, projectId);
await this.evalThreadRestore.deleteWorkflows(createdWorkflowIds);
await this.evalThreadRestore.deleteDataTables(dataTableIds, projectId);
throw error;
}
return {
ok: true,
threadId: payload.threadId,
restored,
workflowIds: workflows.map((workflow) => workflow.id),
dataTableIds,
agentIds: createdAgentIds,
};
}
/**
* Reset an existing data table's rows to exactly the supplied set
* (clear-then-insert). The eval harness pre-creates a case's scenario data
* tables empty before the build turn (so the agent binds the real table id),
* then calls this per scenario to swap in that scenario's rows (TRUST-311
* follow-up). Auth + project scoping mirror restore-thread: the table must be
* in the thread's project.
*/
@Post('/eval/seed-data-table-rows')
@GlobalScope('instanceAi:eval')
async seedEvalDataTableRows(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiEvalSeedDataTableRowsRequest,
) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, payload.threadId);
const projectId = await this.memoryService.getThreadProjectId(payload.threadId);
if (!projectId) {
throw new BadRequestError('Thread is not bound to a project');
}
await this.evalThreadRestore.reseedDataTableRows(payload.tableId, projectId, payload.rows);
return { ok: true, tableId: payload.tableId, rowCount: payload.rows.length };
}
// ── Gateway endpoints (daemon ↔ server) ──────────────────────────────────
@Post('/gateway/create-link')
@GlobalScope('instanceAi:gateway')
async createGatewayLink(req: AuthenticatedRequest) {
await this.assertGatewayEnabled(req.user.id);
const token = this.gatewayService.generatePairingToken(req.user.id);
const expiresAt = this.gatewayService.getGatewayApiKeyExpiresAt(req.user.id, token);
const ttlSeconds = expiresAt
? Math.max(0, Math.ceil((expiresAt.getTime() - Date.now()) / 1000))
: null;
const baseUrl = this.urlService.getInstanceBaseUrl();
const command = `npx @n8n/computer-use ${baseUrl} ${token}`;
return { token, command, expiresAt: expiresAt?.toISOString() ?? null, ttlSeconds };
}
@Get('/gateway/events', { usesTemplates: true, skipAuth: true })
async gatewayEvents(req: Request, res: FlushableResponse) {
const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));
await this.assertGatewayEnabled(userId);
const gateway = this.gatewayService.getLocalGateway(userId);
// If the grace-period timer already fired (e.g. after a long reconnect gap),
// the gateway state is torn down. Reject so the daemon falls into its auth-error
// reconnect branch, which re-uploads capabilities and re-establishes state.
if (!gateway.isConnected) {
throw new ForbiddenError('Local gateway not initialized');
}
// Daemon reconnected within the grace window — cancel the pending disconnect.
this.gatewayService.clearDisconnectTimer(userId);
(res as unknown as { compress: boolean }).compress = false;
res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const unsubscribeRequest = gateway.onRequest((event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
res.flush?.();
});
const unsubscribeDisconnect = gateway.onDisconnect((event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
res.flush?.();
res.end();
});
const keepAlive = setInterval(() => {
res.write(': ping\n\n');
res.flush?.();
}, KEEP_ALIVE_INTERVAL_MS);
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
unsubscribeRequest();
unsubscribeDisconnect();
clearInterval(keepAlive);
this.gatewayService.startDisconnectTimer(userId, () => {
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: false,
directory: null,
hostIdentifier: null,
toolCategories: [],
},
},
[userId],
);
});
};
res.once('close', cleanup);
res.once('finish', cleanup);
}
@Post('/gateway/init', { skipAuth: true })
async gatewayInit(req: Request, _res: Response, @Body payload: InstanceAiGatewayCapabilitiesDto) {
const key = this.getGatewayKeyHeader(req);
const userId = this.validateGatewayApiKey(key);
await this.assertGatewayEnabled(userId);
this.gatewayService.initGateway(userId, payload);
this.gatewayService.applyToolPolicy(userId);
const status = this.gatewayService.getGatewayStatus(userId);
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: status.connected,
directory: status.directory,
hostIdentifier: status.hostIdentifier,
toolCategories: status.toolCategories,
},
},
[userId],
);
// Try to consume a pairing token and upgrade to a session key
const sessionKey = key ? this.gatewayService.consumePairingToken(userId, key) : null;
if (sessionKey) {
return { ok: true, sessionKey };
}
return { ok: true };
}
@Post('/gateway/disconnect', { skipAuth: true })
gatewayDisconnect(req: Request) {
const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));
this.gatewayService.clearDisconnectTimer(userId);
this.gatewayService.disconnectGateway(userId);
this.gatewayService.clearActiveSessionKey(userId);
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: { connected: false, directory: null, hostIdentifier: null, toolCategories: [] },
},
[userId],
);
return { ok: true };
}
@Post('/gateway/response/:requestId', { skipAuth: true })
gatewayResponse(
req: Request,
_res: Response,
@Param('requestId') requestId: string,
@Body payload: InstanceAiFilesystemResponseDto,
) {
const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));
const resolved = this.gatewayService.resolveGatewayRequest(
userId,
requestId,
payload.result,
payload.error,
);
if (!resolved) {
throw new NotFoundError('Gateway request not found or already resolved');
}
return { ok: true };
}
@Post('/gateway/credentials', { skipAuth: true })
async gatewayCreateCredential(
req: Request,
_res: Response,
@Body payload: InstanceAiGatewayCreateCredentialDto,
) {
const user = await this.resolveGatewayUser(this.getGatewayKeyHeader(req));
await this.assertGatewayEnabled(user.id);
const credential = await this.credentialsService.createUnmanagedCredential(payload, user);
return { credentialId: credential.id };
}
@Get('/gateway/status')
@GlobalScope('instanceAi:gateway')
async gatewayStatus(req: AuthenticatedRequest) {
await this.assertGatewayEnabled(req.user.id);
this.gatewayService.applyToolPolicy(req.user.id);
return this.gatewayService.getGatewayStatus(req.user.id);
}
/**
* User-initiated gateway disconnect. Tears down the paired daemon session
* so its tools are no longer exposed to the agent, without changing the
* user's preference to disabled.
*/
@Post('/gateway/disconnect-session')
@GlobalScope('instanceAi:gateway')
async gatewayDisconnectSession(req: AuthenticatedRequest) {
const userId = req.user.id;
this.gatewayService.clearDisconnectTimer(userId);
this.gatewayService.disconnectGateway(userId);
this.gatewayService.clearActiveSessionKey(userId);
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: { connected: false, directory: null, hostIdentifier: null, toolCategories: [] },
},
[userId],
);
return { ok: true };
}
@Post('/browser/create-link')
@GlobalScope('instanceAi:gateway')
async createBrowserLink(req: AuthenticatedRequest) {
this.requireInstanceAiEnabled();
this.assertBrowserChannelEnabled();
return await this.browserSessionService.createLink(req.user.id);
}
@Get('/browser/status')
@GlobalScope('instanceAi:gateway')
browserStatus(req: AuthenticatedRequest) {
this.requireInstanceAiEnabled();
this.assertBrowserChannelEnabled();
return this.browserSessionService.getStatus(req.user.id);
}
@Post('/browser/disconnect-session')
@GlobalScope('instanceAi:gateway')
async browserDisconnectSession(req: AuthenticatedRequest) {
this.requireInstanceAiEnabled();
await this.browserSessionService.disconnect(req.user.id);
return { ok: true };
}
// ── Helpers ──────────────────────────────────────────────────────────────
private assertBrowserChannelEnabled(): void {
if (!this.settingsService.isBrowserUseEnabled()) {
throw new ForbiddenError('Browser Use is disabled');
}
}
/**
* Verify thread ownership. Throws ForbiddenError if another user owns it.
* @param allowNew When true, a non-existent thread is permitted (new conversation).
*/
private async assertThreadAccess(
userId: string,
threadId: string,
options?: { allowNew?: boolean },
): Promise<void> {
const ownership = await this.memoryService.checkThreadOwnership(userId, threadId);
if (ownership === 'other_user') {
throw new ForbiddenError('Not authorized for this thread');
}
if (!options?.allowNew && ownership === 'not_found') {
throw new NotFoundError('Thread not found');
}
}
/** Throw if the local gateway is disabled globally or for this user. */
private async assertGatewayEnabled(userId: string): Promise<void> {
if (await this.settingsService.isLocalGatewayDisabledForUser(userId)) {
throw new ForbiddenError('Local gateway is disabled');
}
}
/**
* Safely extract and validate the x-gateway-key header value.
* Headers can be string | string[] | undefined — take only the first value
* and validate against the shared gateway key schema.
*/
private getGatewayKeyHeader(req: Request): string | undefined {
const raw = req.headers['x-gateway-key'];
const value = Array.isArray(raw) ? raw[0] : raw;
const parsed = instanceAiGatewayKeySchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
/**
* Validate the gateway API key from query param or header.
* Accepts: static env var key, one-time pairing token (init only), or active session key.
* Returns the userId associated with the key.
*/
private validateGatewayApiKey(key: string | undefined): string {
if (!key) {
throw new ForbiddenError('Missing API key');
}
const actual = Buffer.from(key);
// Check static env var key — out of user-scoped flow, uses a sentinel userId
if (this.gatewayApiKey) {
const expected = Buffer.from(this.gatewayApiKey);
if (expected.length === actual.length && timingSafeEqual(expected, actual)) {
return 'env-gateway';
}
}
// Check per-user pairing token or session key via reverse lookup
const userId = this.gatewayService.getUserIdForApiKey(key);
if (userId) return userId;
throw new ForbiddenError('Invalid API key');
}
/**
* Resolve a gateway API key to its associated User. Requires a user-scoped
* key — the static env-var key (which has no associated DB user) is rejected.
*/
private async resolveGatewayUser(key: string | undefined): Promise<User> {
const userId = this.validateGatewayApiKey(key);
if (userId === 'env-gateway') {
throw new ForbiddenError('Credential creation requires a user-scoped gateway key');
}
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['role', 'role.scopes'],
});
if (!user) throw new ForbiddenError('Invalid API key');
return user;
}
private writeSseEvent(res: FlushableResponse, stored: StoredEvent): void {
// No `event:` field — events are discriminated by data.type per streaming-protocol.md.
// Ephemeral events (deltas/status) carry no `id:` line, so the browser's
// Last-Event-ID only ever advances on durable facts — same precedent as
// the run-sync control frames above.
const idLine = stored.id !== undefined ? `id: ${stored.id}\n` : '';
res.write(`${idLine}data: ${JSON.stringify(stored.event)}\n\n`);
res.flush?.();
}
}