n8n
Version:
n8n Workflow Automation Tool
343 lines • 11.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.channelIntegrationRecorder = exports.ChannelIntegrationRecorder = void 0;
const promises_1 = require("fs/promises");
const n8n_workflow_1 = require("n8n-workflow");
const path_1 = require("path");
const SENSITIVE_HEADERS = new Set([
'authorization',
'cookie',
'set-cookie',
'x-api-key',
'x-auth-token',
'x-access-token',
'x-refresh-token',
'x-csrf-token',
'x-xsrf-token',
'x-slack-signature',
'x-telegram-bot-api-secret-token',
]);
const DEFAULT_FETCH_URL_PATTERNS = [
/\.slack\.com/i,
/api\.telegram\.org/i,
/api\.linear\.app/i,
/linear\.app/i,
];
const SANITIZED_N8N_HOST = 'https://n8n.host.com';
function sanitizeHeaderValue(key, value) {
const normalizedKey = key.toLowerCase();
if (!SENSITIVE_HEADERS.has(normalizedKey))
return value;
return '[REDACTED]';
}
function sanitizeWebhookHeaderValue(key, value) {
const normalizedKey = key.toLowerCase();
if (normalizedKey === 'x-forwarded-for') {
return '111.111.111.111';
}
if (normalizedKey === 'host' || normalizedKey === 'x-forwarded-host') {
return SANITIZED_N8N_HOST;
}
return sanitizeHeaderValue(key, value);
}
function sanitizeUrl(url) {
if (url.includes('api.telegram.org')) {
return url.replace(/(.+api\.telegram\.org\/bot)(\d+:\S+)(\/.+)/, '$1123456789:abcdefghijkl$3');
}
return url;
}
function sanitizeWebhookUrl(url) {
try {
const parsed = new URL(url);
return `${SANITIZED_N8N_HOST}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
catch {
return url;
}
}
function sanitizeHeaders(headers, sanitizeValue = sanitizeHeaderValue) {
if (!headers)
return undefined;
const sanitized = {};
if (headers instanceof Headers) {
headers.forEach((value, key) => {
sanitized[key] = sanitizeValue(key, value);
});
return sanitized;
}
for (const [key, value] of Object.entries(headers)) {
sanitized[key] = sanitizeValue(key, value);
}
return sanitized;
}
function sanitizeRecord(record) {
if (record.type === 'webhook') {
return {
...record,
url: sanitizeWebhookUrl(record.url),
headers: sanitizeHeaders(record.headers, sanitizeWebhookHeaderValue) ?? {},
};
}
if (record.type === 'fetch') {
return {
...record,
url: sanitizeUrl(record.url),
requestHeaders: sanitizeHeaders(record.requestHeaders),
responseHeaders: sanitizeHeaders(record.responseHeaders),
};
}
if (record.type === 'api-call') {
return {
...record,
response: sanitizeApiCallResponse(record.response),
};
}
throw new Error('Unsupported channel integration record type');
}
function sanitizeApiCallResponse(response) {
if (!response || typeof response !== 'object')
return response;
if (response instanceof Response)
return response;
if (!('headers' in response) || !(response.headers instanceof Headers))
return response;
return {
...response,
headers: sanitizeHeaders(response.headers),
};
}
function sanitizeSessionId(value) {
return value.replace(/[^a-zA-Z0-9._-]/g, '-');
}
function defaultSessionId() {
const ref = process.env.N8N_AGENT_INTEGRATION_RECORDING_REF ?? 'local';
return `session-${sanitizeSessionId(ref)}-${Date.now()}`;
}
function defaultRecordingDir() {
return (0, path_1.resolve)(process.cwd(), '.agent-recordings', 'channel-integrations');
}
async function responseToRecordable(response) {
if (response instanceof Response) {
return {
status: response.status,
headers: response.headers,
body: await response.clone().text(),
};
}
return response;
}
function getRequestUrl(input) {
if (typeof input === 'string')
return input;
if (input instanceof URL)
return input.href;
return input.url;
}
function headersToRecord(headersInit) {
if (!headersInit)
return undefined;
if (headersInit instanceof Headers) {
const headers = {};
headersInit.forEach((value, key) => {
headers[key] = value;
});
return headers;
}
if (Array.isArray(headersInit)) {
const headers = {};
for (const [key, value] of headersInit) {
headers[key] = value;
}
return headers;
}
return headersInit;
}
function getRequestMethod(input, init) {
if (init?.method)
return init.method;
if (input instanceof Request)
return input.method;
return 'GET';
}
function getRequestHeaders(input, init) {
return headersToRecord(init?.headers ?? (input instanceof Request ? input.headers : undefined));
}
async function getRequestBody(input, init) {
if (typeof init?.body === 'string')
return init.body;
if (init?.body !== undefined)
return undefined;
if (!(input instanceof Request))
return undefined;
return await input
.clone()
.text()
.catch(() => undefined);
}
class ChannelIntegrationRecorder {
constructor(options = {}) {
this.fetchUrlPatterns = DEFAULT_FETCH_URL_PATTERNS;
this.pendingRecords = new Set();
this.enabled =
options.enabled ?? process.env.N8N_AGENT_INTEGRATION_RECORDING_ENABLED === 'true';
this.sessionId = sanitizeSessionId(options.sessionId ??
process.env.N8N_AGENT_INTEGRATION_RECORDING_SESSION_ID ??
defaultSessionId());
this.recordingDir =
options.recordingDir ??
process.env.N8N_AGENT_INTEGRATION_RECORDING_DIR ??
defaultRecordingDir();
}
get isEnabled() {
return this.enabled;
}
get currentSessionId() {
return this.sessionId;
}
get currentSessionPath() {
return (0, path_1.join)(this.recordingDir, `${this.sessionId}.jsonl`);
}
async recordWebhook(platform, request) {
if (!this.enabled)
return;
await this.recordBestEffort(async () => {
const headers = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
await this.appendRecord({
type: 'webhook',
timestamp: Date.now(),
platform,
method: request.method,
url: request.url,
headers,
body: await request.clone().text(),
});
});
}
async recordApiCall(platform, method, args, response, error) {
if (!this.enabled)
return;
await this.recordBestEffort(async () => {
await this.appendRecord({
type: 'api-call',
timestamp: Date.now(),
platform,
method,
args,
response: await responseToRecordable(response),
...(error ? { error: error.message } : {}),
});
});
}
startFetchRecording(urlPatterns = DEFAULT_FETCH_URL_PATTERNS) {
if (!this.enabled || this.originalFetch)
return;
this.fetchUrlPatterns = urlPatterns;
this.originalFetch = globalThis.fetch;
const originalFetch = this.originalFetch;
globalThis.fetch = async (input, init) => {
const url = getRequestUrl(input);
const shouldRecord = this.fetchUrlPatterns.some((pattern) => pattern.test(url));
if (!shouldRecord)
return await originalFetch(input, init);
const startTime = Date.now();
const requestMethod = getRequestMethod(input, init);
const requestHeaders = getRequestHeaders(input, init);
const requestBody = await getRequestBody(input, init);
let response;
let error;
try {
response = await originalFetch(input, init);
return response;
}
catch (caught) {
error = caught instanceof Error ? caught : new Error(String(caught));
throw caught;
}
finally {
let responseHeaders;
if (response) {
responseHeaders = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
}
const responseBody = response
? await response
.clone()
.text()
.catch(() => undefined)
: undefined;
const record = {
type: 'fetch',
timestamp: Date.now(),
method: requestMethod,
url,
durationMs: Date.now() - startTime,
requestHeaders,
requestBody,
status: response?.status,
responseHeaders,
responseBody,
...(error ? { error: error.message } : {}),
};
this.trackPendingRecord(this.appendRecord(record).catch(() => { }));
}
};
}
stopFetchRecording() {
if (!this.originalFetch)
return;
globalThis.fetch = this.originalFetch;
this.originalFetch = undefined;
}
async listSessions() {
await (0, promises_1.mkdir)(this.recordingDir, { recursive: true });
const files = await (0, promises_1.readdir)(this.recordingDir);
const sessions = await Promise.all(files
.filter((file) => file.endsWith('.jsonl'))
.map(async (file) => {
const contents = await (0, promises_1.readFile)((0, path_1.join)(this.recordingDir, file), 'utf8');
return {
sessionId: file.slice(0, -'.jsonl'.length),
entries: contents.split('\n').filter(Boolean).length,
};
}));
return sessions.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
}
async getRecords(sessionId = this.sessionId) {
await this.flush();
const filePath = (0, path_1.join)(this.recordingDir, `${sanitizeSessionId(sessionId)}.jsonl`);
const contents = await (0, promises_1.readFile)(filePath, 'utf8');
return contents
.split('\n')
.filter(Boolean)
.map((line) => (0, n8n_workflow_1.jsonParse)(line));
}
async exportRecords(sessionId = this.sessionId) {
return JSON.stringify(await this.getRecords(sessionId), null, 2);
}
async deleteSession(sessionId = this.sessionId) {
await (0, promises_1.rm)((0, path_1.join)(this.recordingDir, `${sanitizeSessionId(sessionId)}.jsonl`), { force: true });
}
async flush() {
await Promise.all([...this.pendingRecords]);
}
async appendRecord(record) {
await (0, promises_1.mkdir)(this.recordingDir, { recursive: true });
const { appendFile } = await import('fs/promises');
await appendFile(this.currentSessionPath, `${JSON.stringify(sanitizeRecord(record))}\n`, 'utf8');
}
async recordBestEffort(record) {
await record().catch(() => { });
}
trackPendingRecord(record) {
this.pendingRecords.add(record);
void record.finally(() => this.pendingRecords.delete(record));
}
}
exports.ChannelIntegrationRecorder = ChannelIntegrationRecorder;
exports.channelIntegrationRecorder = new ChannelIntegrationRecorder();
//# sourceMappingURL=channel-integration-recorder.js.map