diff --git a/README.md b/README.md index 02375e3f..15e8261b 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,4 @@ Configure your agent with the base URL and API key. Agents can use REST or MCP. ## License ISC + diff --git a/package-lock.json b/package-lock.json index 5c63839e..56c58764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "agentgate": "src/cli.js" }, "devDependencies": { + "@eslint/js": "^9.0.0", "eslint": "^9.0.0", "jest": "^29.7.0", "supertest": "^7.0.0" @@ -65,6 +66,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1417,9 +1419,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1962,6 +1964,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2014,9 +2017,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2420,6 +2423,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3237,6 +3241,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3542,6 +3547,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4098,6 +4104,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7779,6 +7786,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c87897b1..8cf20860 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentgate", - "version": "0.12.2", + "version": "0.16.0", "type": "module", "description": "API gateway for AI agents with human-in-the-loop write approval", "main": "src/index.js", @@ -12,7 +12,8 @@ }, "files": [ "src/", - "public/" + "public/", + "views/" ], "repository": { "type": "git", @@ -61,8 +62,10 @@ "ejs": "^3.1.10" }, "devDependencies": { + "@eslint/js": "^9.0.0", "eslint": "^9.0.0", "jest": "^29.7.0", "supertest": "^7.0.0" } } + diff --git a/src/index.js b/src/index.js index 0439d114..cbe8f532 100644 --- a/src/index.js +++ b/src/index.js @@ -31,6 +31,7 @@ import { setupHumanChannelProxy, setAdminTokenValidator } from './routes/channel import { setupAgentChannelProxy } from './routes/channel-agent.js'; import { validateAdminChatToken } from './routes/ui/keys.js'; import llmRoutes from './routes/llm.js'; +import customProxyRoutes from './routes/custom-proxy.js'; import { createMCPPostHandler, createMCPGetHandler, createMCPDeleteHandler } from './routes/mcp.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -100,6 +101,9 @@ app.use('/api/agents/memento', apiKeyAuth, (req, res, next) => { // LLM proxy - require auth, no read-only enforcement (POST for completions) app.use('/api/llm', apiKeyAuth, llmRoutes); +// Custom service proxy - require auth +app.use('/api/custom', apiKeyAuth, customProxyRoutes); + // MCP server - Streamable HTTP transport (requires auth) // POST handles initialization + messages, GET opens optional SSE stream, DELETE terminates session app.post('/mcp', apiKeyAuth, createMCPPostHandler()); diff --git a/src/lib/credentials.js b/src/lib/credentials.js new file mode 100644 index 00000000..fe422939 --- /dev/null +++ b/src/lib/credentials.js @@ -0,0 +1,62 @@ +// Credential encryption utilities for custom service accounts (#283) +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; +const TAG_LENGTH = 16; +const SALT = 'agentgate-credentials-v1'; // Static salt; key uniqueness comes from the secret + +let _cachedKey = null; + +function getEncryptionKey() { + if (_cachedKey) return _cachedKey; + const secret = process.env.CREDENTIALS_SECRET || process.env.AGENTGATE_SECRET; + if (!secret) { + throw new Error('CREDENTIALS_SECRET or AGENTGATE_SECRET environment variable must be set to use credential encryption'); + } + _cachedKey = scryptSync(secret, SALT, KEY_LENGTH); + return _cachedKey; +} + +/** + * Encrypt a plaintext string. Returns a hex-encoded string: iv + tag + ciphertext. + */ +export function encrypt(plaintext) { + const key = getEncryptionKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // Pack as: iv (16) + tag (16) + ciphertext + return Buffer.concat([iv, tag, encrypted]).toString('hex'); +} + +/** + * Decrypt a hex-encoded string produced by encrypt(). + * Returns the original plaintext. + */ +export function decrypt(hexString) { + const key = getEncryptionKey(); + const data = Buffer.from(hexString, 'hex'); + const iv = data.subarray(0, IV_LENGTH); + const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const ciphertext = data.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + return decipher.update(ciphertext) + decipher.final('utf8'); +} + +/** + * Encrypt a credentials object (JSON-serializes then encrypts). + */ +export function encryptCredentials(credentials) { + return encrypt(JSON.stringify(credentials)); +} + +/** + * Decrypt a credentials string back to an object. + */ +export function decryptCredentials(encryptedHex) { + return JSON.parse(decrypt(encryptedHex)); +} diff --git a/src/lib/db.js b/src/lib/db.js index e0543567..eadfddff 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -5,6 +5,7 @@ import { homedir } from 'os'; import { mkdirSync, existsSync, unlinkSync, readdirSync } from 'fs'; import bcrypt from 'bcrypt'; import { stemmer } from 'stemmer'; +import { encryptCredentials, decryptCredentials } from './credentials.js'; // Data directory: AGENTGATE_DATA_DIR env var, or ~/.agentgate/ const dataDir = process.env.AGENTGATE_DATA_DIR || join(homedir(), '.agentgate'); @@ -281,6 +282,32 @@ db.exec(` -- Replay protection: unique delivery IDs per source CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_delivery_dedup ON webhook_deliveries(source, delivery_id) WHERE delivery_id IS NOT NULL; + + -- Custom services (user-defined REST API endpoints, #249) + CREATE TABLE IF NOT EXISTS custom_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + base_url TEXT NOT NULL, + docs_url TEXT, + category TEXT DEFAULT 'custom', + auth_config TEXT NOT NULL, + endpoints TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS custom_service_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL REFERENCES custom_services(id) ON DELETE CASCADE, + account_name TEXT NOT NULL, + credentials TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(service_id, account_name) + ); `); // ============================================ @@ -977,6 +1004,32 @@ export function hasAdminPassword() { return getSetting('admin_password') !== null; } +// Sidecar Secret (cached — invalidated on set/clear) +let _cachedSidecarHash = undefined; // undefined = not yet loaded + +export async function setSidecarSecret(plaintext) { + const hash = await bcrypt.hash(plaintext, 10); + setSetting('sidecar_secret', hash); + _cachedSidecarHash = hash; +} + +export function getSidecarSecretHash() { + if (_cachedSidecarHash === undefined) { + _cachedSidecarHash = getSetting('sidecar_secret') || null; + } + return _cachedSidecarHash; +} + +export function clearSidecarSecret() { + deleteSetting('sidecar_secret'); + _cachedSidecarHash = null; +} + +// Exported for testing only +export function _resetSidecarCache() { + _cachedSidecarHash = undefined; +} + // Cookie secret (generated once, persisted) export function getCookieSecret() { let secret = getSetting('cookie_secret'); @@ -1012,31 +1065,73 @@ export function getQueueEntry(id) { }; } -export function listQueueEntries(status) { - let rows; - if (status) { - rows = db.prepare('SELECT * FROM write_queue WHERE status = ? ORDER BY submitted_at DESC').all(status); - } else { - rows = db.prepare('SELECT * FROM write_queue ORDER BY submitted_at DESC').all(); - } - return rows.map(row => ({ +const QUEUE_LIGHT_COLS = 'id, service, account_name, comment, status, rejection_reason, submitted_by, submitted_at, reviewed_at, completed_at, notified, notified_at, notify_error, auto_approved, reaction_emoji, requests'; + +function mapQueueRow(row) { + return { ...row, requests: JSON.parse(row.requests), results: row.results ? JSON.parse(row.results) : null, notified: Boolean(row.notified), auto_approved: Boolean(row.auto_approved) - })); + }; } -export function listAutoApprovedEntries() { - const rows = db.prepare('SELECT * FROM write_queue WHERE auto_approved = 1 ORDER BY submitted_at DESC').all(); - return rows.map(row => ({ - ...row, - requests: JSON.parse(row.requests), - results: row.results ? JSON.parse(row.results) : null, +function mapQueueRowLight(row) { + const requests = JSON.parse(row.requests); + return { + id: row.id, + service: row.service, + account_name: row.account_name, + comment: row.comment, + status: row.status, + rejection_reason: row.rejection_reason, + submitted_by: row.submitted_by, + submitted_at: row.submitted_at, + reviewed_at: row.reviewed_at, + completed_at: row.completed_at, notified: Boolean(row.notified), - auto_approved: true - })); + notified_at: row.notified_at, + notify_error: row.notify_error, + auto_approved: Boolean(row.auto_approved), + reaction_emoji: row.reaction_emoji, + requestSummary: requests.map(r => ({ method: r.method, path: r.path })), + resultCount: row.result_count || 0 + }; +} + +export function listQueueEntries(status, { limit, offset, light } = {}) { + const cols = light + ? QUEUE_LIGHT_COLS + ', (CASE WHEN results IS NOT NULL THEN json_array_length(results) ELSE 0 END) as result_count' + : '*'; + let rows; + if (status) { + if (limit !== null && limit !== undefined) { + rows = db.prepare(`SELECT ${cols} FROM write_queue WHERE status = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?`).all(status, limit, offset || 0); + } else { + rows = db.prepare(`SELECT ${cols} FROM write_queue WHERE status = ? ORDER BY submitted_at DESC`).all(status); + } + } else { + if (limit !== null && limit !== undefined) { + rows = db.prepare(`SELECT ${cols} FROM write_queue ORDER BY submitted_at DESC LIMIT ? OFFSET ?`).all(limit, offset || 0); + } else { + rows = db.prepare(`SELECT ${cols} FROM write_queue ORDER BY submitted_at DESC`).all(); + } + } + return rows.map(light ? mapQueueRowLight : mapQueueRow); +} + +export function listAutoApprovedEntries({ limit, offset, light } = {}) { + const cols = light + ? QUEUE_LIGHT_COLS + ', (CASE WHEN results IS NOT NULL THEN json_array_length(results) ELSE 0 END) as result_count' + : '*'; + let rows; + if (limit !== null && limit !== undefined) { + rows = db.prepare(`SELECT ${cols} FROM write_queue WHERE auto_approved = 1 ORDER BY submitted_at DESC LIMIT ? OFFSET ?`).all(limit, offset || 0); + } else { + rows = db.prepare(`SELECT ${cols} FROM write_queue WHERE auto_approved = 1 ORDER BY submitted_at DESC`).all(); + } + return rows.map(light ? mapQueueRowLight : mapQueueRow); } export function getAutoApprovedCount() { @@ -1044,6 +1139,10 @@ export function getAutoApprovedCount() { return row.count; } +export function clearAutoApprovedEntries() { + return db.prepare('DELETE FROM write_queue WHERE auto_approved = 1').run(); +} + export function updateQueueNotification(id, success, error = null) { if (success) { db.prepare(` @@ -1247,10 +1346,17 @@ export function rejectAgentMessage(id, reason) { } // Admin: list all messages (for UI) -export function listAgentMessages(status = null) { +export function listAgentMessages(status = null, { limit, offset } = {}) { + const hasLimit = limit !== null && limit !== undefined; if (status) { + if (hasLimit) { + return db.prepare('SELECT * FROM agent_messages WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?').all(status, limit, offset || 0); + } return db.prepare('SELECT * FROM agent_messages WHERE status = ? ORDER BY created_at DESC').all(status); } + if (hasLimit) { + return db.prepare('SELECT * FROM agent_messages ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset || 0); + } return db.prepare('SELECT * FROM agent_messages ORDER BY created_at DESC').all(); } @@ -2449,3 +2555,153 @@ export function getChatMessageCount(channelId) { return row ? row.count : 0; } +// ============================================ +// Custom Services (#249) +// ============================================ + +export function createCustomService({ name, displayName, description, baseUrl, docsUrl, category, authConfig, endpoints }) { + const result = db.prepare(` + INSERT INTO custom_services (name, display_name, description, base_url, docs_url, category, auth_config, endpoints) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(name, displayName, description || null, baseUrl, docsUrl || null, category || 'custom', + JSON.stringify(authConfig), JSON.stringify(endpoints || [])); + return { id: Number(result.lastInsertRowid), name }; +} + +export function getCustomService(nameOrId) { + const row = typeof nameOrId === 'number' + ? db.prepare('SELECT * FROM custom_services WHERE id = ?').get(nameOrId) + : db.prepare('SELECT * FROM custom_services WHERE name = ?').get(nameOrId); + if (!row) return null; + return { + ...row, + auth_config: JSON.parse(row.auth_config), + endpoints: JSON.parse(row.endpoints), + enabled: row.enabled === 1 + }; +} + +export function listCustomServices() { + const rows = db.prepare('SELECT * FROM custom_services ORDER BY name').all(); + return rows.map(row => ({ + ...row, + auth_config: JSON.parse(row.auth_config), + endpoints: JSON.parse(row.endpoints), + enabled: row.enabled === 1 + })); +} + +export function updateCustomService(name, updates) { + const fields = []; + const values = []; + const mapping = { + displayName: 'display_name', description: 'description', baseUrl: 'base_url', + docsUrl: 'docs_url', category: 'category', enabled: 'enabled' + }; + for (const [jsKey, dbKey] of Object.entries(mapping)) { + if (updates[jsKey] !== undefined) { + fields.push(`${dbKey} = ?`); + values.push(jsKey === 'enabled' ? (updates[jsKey] ? 1 : 0) : updates[jsKey]); + } + } + if (updates.authConfig !== undefined) { + fields.push('auth_config = ?'); + values.push(JSON.stringify(updates.authConfig)); + } + if (updates.endpoints !== undefined) { + fields.push('endpoints = ?'); + values.push(JSON.stringify(updates.endpoints)); + } + if (fields.length === 0) return; + fields.push('updated_at = datetime(\'now\')'); + values.push(name); + db.prepare(`UPDATE custom_services SET ${fields.join(', ')} WHERE name = ?`).run(...values); +} + +export function deleteCustomService(name) { + return db.prepare('DELETE FROM custom_services WHERE name = ?').run(name); +} + +// Custom service accounts +export function createCustomServiceAccount(serviceName, accountName, credentials) { + const svc = db.prepare('SELECT id FROM custom_services WHERE name = ?').get(serviceName); + if (!svc) throw new Error(`Custom service '${serviceName}' not found`); + const result = db.prepare(` + INSERT INTO custom_service_accounts (service_id, account_name, credentials) + VALUES (?, ?, ?) + `).run(svc.id, accountName, encryptCredentials(credentials)); + return { id: Number(result.lastInsertRowid), service: serviceName, account_name: accountName }; +} + +export function getCustomServiceAccount(serviceName, accountName) { + const row = db.prepare(` + SELECT csa.* FROM custom_service_accounts csa + JOIN custom_services cs ON csa.service_id = cs.id + WHERE cs.name = ? AND csa.account_name = ? + `).get(serviceName, accountName); + if (!row) return null; + return { ...row, credentials: decryptCredentials(row.credentials), enabled: row.enabled === 1 }; +} + +export function listCustomServiceAccounts(serviceName) { + const rows = db.prepare(` + SELECT csa.id, csa.account_name, csa.enabled, csa.created_at + FROM custom_service_accounts csa + JOIN custom_services cs ON csa.service_id = cs.id + WHERE cs.name = ? + ORDER BY csa.account_name + `).all(serviceName); + return rows.map(r => ({ ...r, enabled: r.enabled === 1 })); +} + +export function updateCustomServiceAccount(serviceName, accountName, updates) { + const svc = db.prepare('SELECT id FROM custom_services WHERE name = ?').get(serviceName); + if (!svc) return; + if (updates.credentials !== undefined) { + db.prepare(` + UPDATE custom_service_accounts SET credentials = ? WHERE service_id = ? AND account_name = ? + `).run(encryptCredentials(updates.credentials), svc.id, accountName); + } + if (updates.enabled !== undefined) { + db.prepare(` + UPDATE custom_service_accounts SET enabled = ? WHERE service_id = ? AND account_name = ? + `).run(updates.enabled ? 1 : 0, svc.id, accountName); + } +} + +export function deleteCustomServiceAccount(serviceName, accountName) { + const svc = db.prepare('SELECT id FROM custom_services WHERE name = ?').get(serviceName); + if (!svc) return { changes: 0 }; + return db.prepare('DELETE FROM custom_service_accounts WHERE service_id = ? AND account_name = ?').run(svc.id, accountName); +} + +export function listEnabledCustomServices() { + const rows = db.prepare(` + SELECT cs.*, csa.account_name, csa.credentials as account_credentials, csa.enabled as account_enabled + FROM custom_services cs + LEFT JOIN custom_service_accounts csa ON cs.id = csa.service_id AND csa.enabled = 1 + WHERE cs.enabled = 1 + ORDER BY cs.name, csa.account_name + `).all(); + // Group by service + const services = new Map(); + for (const row of rows) { + if (!services.has(row.name)) { + services.set(row.name, { + ...row, + auth_config: JSON.parse(row.auth_config), + endpoints: JSON.parse(row.endpoints), + enabled: true, + accounts: [] + }); + } + if (row.account_name) { + services.get(row.name).accounts.push({ + account_name: row.account_name, + credentials: decryptCredentials(row.account_credentials) + }); + } + } + return [...services.values()]; +} + diff --git a/src/routes/custom-proxy.js b/src/routes/custom-proxy.js new file mode 100644 index 00000000..ecb17e22 --- /dev/null +++ b/src/routes/custom-proxy.js @@ -0,0 +1,287 @@ +// Dynamic proxy for custom services (#249) +// Loads enabled custom services and proxies requests to upstream APIs +import { Router } from 'express'; +import https from 'https'; +import http from 'http'; +import { readOnlyEnforce } from '../lib/middleware.js'; +import { injectAuth, assertNotPrivateUrl, buildPinnedUrl } from '../services/customServiceService.js'; +import { getCustomServiceAccount, getCustomService } from '../lib/db.js'; + +/** + * Perform an HTTP(S) request using a custom agent (for DNS-pinned HTTPS). + * Returns a fetch-like Response object with .status, .headers.get(), .json(), .text(). + */ +function fetchWithAgent(url, options, agent) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const mod = parsed.protocol === 'https:' ? https : http; + const reqOptions = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: options.method || 'GET', + headers: options.headers || {}, + agent, + signal: options.signal + }; + + const req = mod.request(reqOptions, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ + status: res.statusCode, + headers: { + get: (name) => res.headers[name.toLowerCase()] || null + }, + json: () => JSON.parse(raw), + text: () => raw + }); + }); + res.on('error', reject); + }); + + req.on('error', reject); + + if (options.body) { + req.write(options.body); + } + req.end(); + }); +} + +const router = Router(); + +/** + * Match a request path against an endpoint definition path pattern + * e.g. /devices/{deviceId}/status matches /devices/abc123/status + * Returns extracted path params or null if no match + */ +function matchEndpointPath(pattern, requestPath) { + const patternParts = pattern.split('/').filter(Boolean); + const requestParts = requestPath.split('/').filter(Boolean); + if (patternParts.length !== requestParts.length) return null; + + const params = {}; + for (let i = 0; i < patternParts.length; i++) { + const pp = patternParts[i]; + if (pp.startsWith('{') && pp.endsWith('}')) { + params[pp.slice(1, -1)] = requestParts[i]; + } else if (pp !== requestParts[i]) { + return null; + } + } + return params; +} + +/** + * Simple JSON Schema validation (top-level properties only) + * Returns array of error strings, empty if valid + */ +function validateSchema(data, schema, prefix) { + const errors = []; + if (!schema || schema.type !== 'object' || !schema.properties) return errors; + + const required = schema.required || []; + for (const field of required) { + if (data[field] === undefined || data[field] === null) { + errors.push({ path: `${prefix}.${field}`, message: 'is required' }); + } + } + + for (const [key, propSchema] of Object.entries(schema.properties)) { + const val = data[key]; + if (val === undefined) continue; + + if (propSchema.type === 'string' && typeof val !== 'string') { + errors.push({ path: `${prefix}.${key}`, message: 'must be string' }); + } else if (propSchema.type === 'integer' && !Number.isInteger(Number(val))) { + errors.push({ path: `${prefix}.${key}`, message: 'must be integer' }); + } else if (propSchema.type === 'number' && isNaN(Number(val))) { + errors.push({ path: `${prefix}.${key}`, message: 'must be number' }); + } else if (propSchema.type === 'boolean' && typeof val !== 'boolean') { + errors.push({ path: `${prefix}.${key}`, message: 'must be boolean' }); + } + + if (propSchema.enum && !propSchema.enum.includes(val)) { + errors.push({ path: `${prefix}.${key}`, message: `must be one of: ${propSchema.enum.join(', ')}` }); + } + } + return errors; +} + +/** + * Build the upstream URL by substituting path params into the endpoint path + */ +function buildUpstreamUrl(baseUrl, endpointPath, pathParams, queryParams) { + let path = endpointPath; + for (const [key, val] of Object.entries(pathParams)) { + path = path.replace(`{${key}}`, encodeURIComponent(val)); + } + const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'); + if (queryParams) { + for (const [key, val] of Object.entries(queryParams)) { + if (val !== undefined && val !== null) { + url.searchParams.set(key, val); + } + } + } + return url.toString(); +} + +// Design decisions for custom proxy routes: +// +// 1. No writeProxy / approval queue: Custom services intentionally bypass the write +// approval queue used by built-in services. POST/PUT/DELETE go directly upstream. +// Rationale: custom services are explicitly configured by the admin, who controls +// which endpoints are exposed and to whom. The admin takes responsibility for the +// operations they define. readOnlyEnforce is still respected as a global safety net. +// +// 2. No serviceAccessCheck: Built-in service access checks are not applied here. +// Custom services are user-defined, not built-in — they have their own access +// control via account-level enabled/disabled flags and endpoint whitelisting. +// +// Main handler: /api/custom/{serviceName}/{accountName}/... +// readOnlyEnforce is applied here rather than in index.js to avoid import conflicts +router.all('/:serviceName/:accountName/*', readOnlyEnforce, async (req, res) => { + const { serviceName, accountName } = req.params; + const restPath = '/' + req.params[0]; // everything after accountName + + // Load service definition + const service = getCustomService(serviceName); + if (!service || !service.enabled) { + return res.status(404).json({ error: `Custom service '${serviceName}' not found or disabled` }); + } + + // Load account credentials + const account = getCustomServiceAccount(serviceName, accountName); + if (!account || !account.enabled) { + return res.status(404).json({ error: `Account '${accountName}' not found for service '${serviceName}'` }); + } + + // Find matching endpoint + const method = req.method.toUpperCase(); + let matchedEndpoint = null; + let pathParams = {}; + + for (const ep of service.endpoints) { + if (ep.method.toUpperCase() !== method) continue; + const params = matchEndpointPath(ep.path, restPath); + if (params) { + matchedEndpoint = ep; + pathParams = params; + break; + } + } + + if (!matchedEndpoint) { + return res.status(404).json({ + error: 'No matching endpoint found', + service: serviceName, + method, + path: restPath, + available: service.endpoints.map(e => `${e.method} ${e.path}`) + }); + } + + // Validate path params + if (matchedEndpoint.pathParams) { + const errors = validateSchema(pathParams, matchedEndpoint.pathParams, 'path'); + if (errors.length > 0) { + return res.status(400).json({ error: 'validation_failed', details: errors }); + } + } + + // Validate query params + if (matchedEndpoint.queryParams) { + const errors = validateSchema(req.query, matchedEndpoint.queryParams, 'query'); + if (errors.length > 0) { + return res.status(400).json({ error: 'validation_failed', details: errors }); + } + } + + // Validate body + if (matchedEndpoint.bodySchema && ['POST', 'PUT', 'PATCH'].includes(method)) { + const errors = validateSchema(req.body || {}, matchedEndpoint.bodySchema, 'body'); + if (errors.length > 0) { + return res.status(400).json({ error: 'validation_failed', details: errors }); + } + } + + // Build upstream URL + const query = { ...req.query }; + const headers = { 'Content-Type': 'application/json' }; + + // Inject auth + injectAuth(headers, query, service.auth_config, account.credentials); + + const upstreamUrl = buildUpstreamUrl(service.base_url, matchedEndpoint.path, pathParams, query); + + // SSRF protection: resolve DNS and check for private IPs + // For HTTP: use DNS-pinned URL to prevent rebinding attacks + // For HTTPS: use pinned agent with custom lookup (fixes #340 TOCTOU gap) + let fetchUrl; + let fetchAgent = null; + try { + const { address, hostname, pinnable, agent } = await assertNotPrivateUrl(upstreamUrl); + if (pinnable) { + fetchUrl = buildPinnedUrl(upstreamUrl, address); + headers['Host'] = hostname; + } else { + fetchUrl = upstreamUrl; + fetchAgent = agent; // https.Agent with pinned DNS lookup + } + } catch (err) { + return res.status(403).json({ error: 'ssrf_blocked', message: err.message }); + } + + // Proxy request + try { + const fetchOptions = { method, headers }; + if (['POST', 'PUT', 'PATCH'].includes(method) && req.body) { + fetchOptions.body = JSON.stringify(req.body); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + fetchOptions.signal = controller.signal; + + // Node's fetch doesn't support the agent option directly. + // We use a manual https.request when a pinned agent is needed. + let upstream; + if (fetchAgent) { + upstream = await fetchWithAgent(fetchUrl, fetchOptions, fetchAgent); + } else { + upstream = await fetch(fetchUrl, fetchOptions); + } + clearTimeout(timeout); + + const contentType = upstream.headers.get('content-type') || ''; + let body; + if (contentType.includes('application/json')) { + body = await upstream.json(); + } else { + body = await upstream.text(); + } + + res.status(upstream.status).json({ + _custom_service: serviceName, + _endpoint: matchedEndpoint.name, + status: upstream.status, + data: body + }); + } catch (err) { + // Log full error server-side for debugging; return generic message to client + // to avoid leaking internal network details (hostnames, IPs, ports) from fetch failures + console.error(`[custom-proxy] upstream error for ${serviceName}/${matchedEndpoint.name}:`, err); + res.status(502).json({ + error: 'upstream_error', + message: 'The upstream service request failed', + service: serviceName, + endpoint: matchedEndpoint.name + }); + } +}); + +export default router; diff --git a/src/routes/readme.js b/src/routes/readme.js index 33644a33..898d7350 100644 --- a/src/routes/readme.js +++ b/src/routes/readme.js @@ -1,6 +1,7 @@ import { Router } from 'express'; import { getAccountsByService, getMessagingMode, checkServiceAccess } from '../lib/db.js'; import SERVICE_REGISTRY from '../lib/serviceRegistry.js'; +import { listCustomServices } from '../services/customServiceService.js'; const router = Router(); @@ -48,6 +49,23 @@ router.get('/', (req, res) => { } } + // Add custom services + try { + const customServices = listCustomServices(); + for (const svc of customServices) { + if (!svc.enabled) continue; + services[svc.name] = { + description: svc.description || svc.display_name, + docs: svc.docs_url || undefined, + category: svc.category || 'custom', + accounts: ['(see custom service accounts)'], + examples: (svc.endpoints || []).map(ep => `${ep.method} /api/custom/${svc.name}/{accountName}${ep.path}`) + }; + } + } catch { + // Custom services table may not exist yet + } + res.json({ name: 'agentgate', description: 'API gateway for personal data with human-in-the-loop write approval. Read requests (GET) execute immediately. Write requests (POST/PUT/DELETE) are queued for human approval before execution.', diff --git a/src/routes/ui/custom-services.js b/src/routes/ui/custom-services.js new file mode 100644 index 00000000..c6045b5b --- /dev/null +++ b/src/routes/ui/custom-services.js @@ -0,0 +1,127 @@ +// Admin UI routes for custom services (#249) +import { Router } from 'express'; +import { + createCustomService, + getCustomService, + listCustomServices, + updateCustomService, + deleteCustomService, + createAccount, + listAccounts, + updateAccount, + deleteAccount, + testConnection +} from '../../services/customServiceService.js'; + +const router = Router(); + +// ---- Service CRUD (JSON API) ---- + +// List all custom services +router.get('/api', (req, res) => { + try { + const services = listCustomServices(); + res.json({ services }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get single service +router.get('/api/:name', (req, res) => { + try { + const service = getCustomService(req.params.name); + if (!service) return res.status(404).json({ error: 'Service not found' }); + const accounts = listAccounts(req.params.name); + res.json({ service, accounts }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Create service +router.post('/api', (req, res) => { + try { + const result = createCustomService(req.body); + res.status(201).json(result); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// Update service +router.put('/api/:name', (req, res) => { + try { + updateCustomService(req.params.name, req.body); + const updated = getCustomService(req.params.name); + res.json(updated); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// Delete service +router.delete('/api/:name', (req, res) => { + try { + deleteCustomService(req.params.name); + res.json({ success: true }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// ---- Account CRUD ---- + +// List accounts for a service +router.get('/api/:name/accounts', (req, res) => { + try { + const accounts = listAccounts(req.params.name); + res.json({ accounts }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Create account +router.post('/api/:name/accounts', (req, res) => { + try { + const { accountName, credentials } = req.body; + const result = createAccount(req.params.name, accountName, credentials || {}); + res.status(201).json(result); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// Update account +router.put('/api/:name/accounts/:acct', (req, res) => { + try { + updateAccount(req.params.name, req.params.acct, req.body); + res.json({ success: true }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// Delete account +router.delete('/api/:name/accounts/:acct', (req, res) => { + try { + deleteAccount(req.params.name, req.params.acct); + res.json({ success: true }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// ---- Test connection ---- +router.post('/api/:name/test', async (req, res) => { + try { + const { accountName } = req.body; + const result = await testConnection(req.params.name, accountName); + res.json(result); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +export default router; diff --git a/src/routes/ui/index.js b/src/routes/ui/index.js index 93cd5d31..0ae4f632 100644 --- a/src/routes/ui/index.js +++ b/src/routes/ui/index.js @@ -14,6 +14,7 @@ import accessRouter from './access.js'; import llmRouter from './llm.js'; import serviceDetailRouter from './service-detail.js'; import webhooksRouter from './webhooks.js'; +import customServicesRouter from './custom-services.js'; // Create the main UI router const router = Router(); @@ -51,6 +52,9 @@ router.use('/services', serviceDetailRouter); // Webhook management: /webhooks, /webhooks/add, /webhooks/:id router.use('/', webhooksRouter); +// Custom REST API services: /custom-services, /custom-services/api/* +router.use('/custom-services', customServicesRouter); + // Settings page: /settings // Also handles POST routes: /hsync/*, /messaging/*, /queue/settings/* router.use('/', settingsRouter); diff --git a/src/services/customServiceService.js b/src/services/customServiceService.js new file mode 100644 index 00000000..aa17076c --- /dev/null +++ b/src/services/customServiceService.js @@ -0,0 +1,309 @@ +// Custom service business logic layer (#249) +import { lookup as dnsLookup } from 'dns/promises'; +import https from 'https'; +import http from 'http'; + +import { + createCustomService as dbCreate, + getCustomService as dbGet, + listCustomServices as dbList, + updateCustomService as dbUpdate, + deleteCustomService as dbDelete, + createCustomServiceAccount as dbCreateAccount, + getCustomServiceAccount as dbGetAccount, + listCustomServiceAccounts as dbListAccounts, + updateCustomServiceAccount as dbUpdateAccount, + deleteCustomServiceAccount as dbDeleteAccount, + listEnabledCustomServices +} from '../lib/db.js'; +import SERVICE_REGISTRY from '../lib/serviceRegistry.js'; + +// Validate service name: URL-safe, starts with letter +const NAME_RE = /^[a-z][a-z0-9_-]*$/; + +/** + * Make an HTTP(S) request using a custom agent (for DNS-pinned HTTPS). + * Returns { status, statusText }. + */ +function pinnedRequest(url, options, agent) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const mod = parsed.protocol === 'https:' ? https : http; + const req = mod.request({ + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: options.method || 'GET', + headers: options.headers || {}, + agent, + signal: options.signal + }, (res) => { + res.resume(); + res.on('end', () => resolve({ status: res.statusCode, statusText: res.statusMessage })); + res.on('error', reject); + }); + req.on('error', reject); + req.end(); + }); +} + +/** + * Validate that a base URL is a valid HTTP(S) URL. + */ +function validateBaseUrl(url) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error('Invalid base URL'); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Base URL must use http or https protocol'); + } +} + +/** + * Check if an IP address is private/internal. + */ +function isPrivateIP(ip) { + // Handle IPv4-mapped IPv6 (::ffff:x.x.x.x) + if (ip.startsWith('::ffff:')) { + const mapped = ip.slice(7); + if (mapped.includes('.')) { + return isPrivateIP(mapped); + } + } + + // IPv4 private ranges + const parts = ip.split('.').map(Number); + if (parts.length === 4 && parts.every(p => p >= 0 && p <= 255)) { + if (parts[0] === 10) return true; // 10.0.0.0/8 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12 + if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16 + if (parts[0] === 127) return true; // 127.0.0.0/8 + if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16 link-local + if (parts[0] === 0) return true; // 0.0.0.0/8 + if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // 100.64.0.0/10 CGNAT + if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return true; // 198.18.0.0/15 benchmarking + if (parts[0] >= 240) return true; // 240.0.0.0/4 reserved + if (parts[0] === 255 && parts[1] === 255 && parts[2] === 255 && parts[3] === 255) return true; // broadcast + } + + // IPv6 + if (ip === '::1' || ip === '::') return true; + if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // fc00::/7 unique local + if (ip.startsWith('fe80')) return true; // fe80::/10 link-local + if (ip.startsWith('2001:db8')) return true; // 2001:db8::/32 documentation + return false; +} + +/** + * Resolve a URL's hostname and verify it doesn't point to a private/internal IP (SSRF protection). + * + * For HTTP URLs: returns { address, hostname, pinnable: true } — callers can use the + * resolved IP directly to prevent DNS rebinding (TOCTOU). + * + * For HTTPS URLs: returns { address, hostname, pinnable: false, agent } — an https.Agent + * with a custom `lookup` function that returns the pre-resolved IP. This pins DNS for the + * actual connection while preserving TLS hostname validation via SNI. Eliminates the + * TOCTOU gap without breaking certificate validation. Fixes #340. + */ +export async function assertNotPrivateUrl(url) { + const parsed = new URL(url); + const hostname = parsed.hostname; + const isHttps = parsed.protocol === 'https:'; + + // Check if hostname is already an IP + if (isPrivateIP(hostname)) { + throw new Error('Connections to private/internal addresses are not allowed'); + } + // Resolve DNS + try { + const result = await dnsLookup(hostname); + if (isPrivateIP(result.address)) { + throw new Error('Connections to private/internal addresses are not allowed'); + } + if (isHttps) { + // Create a single-use agent that pins DNS to the resolved IP. + // TLS SNI uses the original hostname (Node sets servername automatically), + // so certificate validation works correctly against the hostname, not the IP. + const family = result.family || 4; + const pinnedAddress = result.address; + const agent = new https.Agent({ + lookup: (_hostname, _opts, cb) => cb(null, pinnedAddress, family), + maxSockets: 1, + keepAlive: false + }); + return { address: result.address, hostname, pinnable: false, agent }; + } + return { address: result.address, hostname, pinnable: true, agent: null }; + } catch (err) { + if (err.message.includes('private/internal')) throw err; + throw new Error(`DNS resolution failed for ${hostname}: ${err.message}`); + } +} + +/** + * Build a DNS-pinned URL by replacing the hostname with the resolved IP. + * Only use for HTTP (not HTTPS) — see assertNotPrivateUrl docs. + */ +export function buildPinnedUrl(originalUrl, resolvedIp) { + const parsed = new URL(originalUrl); + return `${parsed.protocol}//${resolvedIp}${parsed.port ? ':' + parsed.port : ''}${parsed.pathname}${parsed.search}`; +} + +/** + * Create a custom service definition + */ +export function createCustomService(data) { + if (!data.name || !NAME_RE.test(data.name)) { + throw new Error('Service name must match ^[a-z][a-z0-9_-]*$'); + } + if (!data.baseUrl) { + throw new Error('Base URL is required'); + } + validateBaseUrl(data.baseUrl); + if (!data.displayName) { + throw new Error('Display name is required'); + } + if (!data.authConfig || !data.authConfig.type) { + throw new Error('Auth config with type is required'); + } + // Check for name collision with built-in services (from registry) + const builtinNames = Object.keys(SERVICE_REGISTRY); + if (builtinNames.includes(data.name)) { + throw new Error(`Service name '${data.name}' conflicts with a built-in service`); + } + const existing = dbGet(data.name); + if (existing) { + throw new Error(`Custom service '${data.name}' already exists`); + } + return dbCreate(data); +} + +export function getCustomService(name) { + return dbGet(name); +} + +export function listCustomServices() { + return dbList(); +} + +export function updateCustomService(name, updates) { + const existing = dbGet(name); + if (!existing) { + throw new Error(`Custom service '${name}' not found`); + } + if (updates.baseUrl) { + validateBaseUrl(updates.baseUrl); + } + return dbUpdate(name, updates); +} + +export function deleteCustomService(name) { + return dbDelete(name); +} + +// Account CRUD +export function createAccount(serviceName, accountName, credentials) { + if (!accountName || !accountName.trim()) { + throw new Error('Account name is required'); + } + return dbCreateAccount(serviceName, accountName, credentials); +} + +export function getAccount(serviceName, accountName) { + return dbGetAccount(serviceName, accountName); +} + +export function listAccounts(serviceName) { + return dbListAccounts(serviceName); +} + +export function updateAccount(serviceName, accountName, updates) { + return dbUpdateAccount(serviceName, accountName, updates); +} + +export function deleteAccount(serviceName, accountName) { + return dbDeleteAccount(serviceName, accountName); +} + +/** + * Get all enabled custom services with their accounts (for route registration) + */ +export function getEnabledServices() { + return listEnabledCustomServices(); +} + +/** + * Test connection to a custom service by making a GET request to baseUrl + */ +export async function testConnection(serviceName, accountName) { + const service = dbGet(serviceName); + if (!service) throw new Error('Service not found'); + + let account = null; + if (accountName) { + account = dbGetAccount(serviceName, accountName); + if (!account) throw new Error('Account not found'); + } + + const url = service.base_url; + + // SSRF protection: resolve DNS and check for private IPs + const { address, hostname, pinnable, agent } = await assertNotPrivateUrl(url); + + // For HTTP, use DNS-pinned URL; for HTTPS, use original URL with pinned agent (#340) + const fetchUrl = pinnable ? buildPinnedUrl(url, address) : url; + const headers = {}; + if (pinnable) { + headers['Host'] = hostname; + } + + // Inject auth if account provided + if (account) { + injectAuth(headers, {}, service.auth_config, account.credentials); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + if (agent) { + const resp = await pinnedRequest(fetchUrl, { method: 'GET', headers, signal: controller.signal }, agent); + return { ok: resp.status >= 200 && resp.status < 300, status: resp.status, statusText: resp.statusText }; + } + const resp = await fetch(fetchUrl, { method: 'GET', headers, signal: controller.signal }); + return { ok: resp.ok, status: resp.status, statusText: resp.statusText }; + } catch (err) { + return { ok: false, status: 0, statusText: err.message }; + } finally { + clearTimeout(timeout); + if (agent) agent.destroy(); + } +} + +/** + * Inject auth credentials into headers/query based on auth config + */ +export function injectAuth(headers, query, authConfig, credentials) { + if (!authConfig || !credentials) return; + const type = authConfig.type; + + if (type === 'bearer') { + headers['Authorization'] = `Bearer ${credentials.token}`; + } else if (type === 'basic') { + const encoded = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + headers['Authorization'] = `Basic ${encoded}`; + } else if (type === 'api-key') { + const injection = authConfig.injection || 'header'; + if (injection === 'header') { + headers[authConfig.headerName || 'X-API-Key'] = credentials.apiKey; + } else if (injection === 'query') { + query[authConfig.paramName || 'api_key'] = credentials.apiKey; + } + } else if (type === 'custom-header') { + headers[credentials.headerName || authConfig.headerName] = credentials.headerValue; + } else if (type === 'query-param') { + query[credentials.paramName || authConfig.paramName] = credentials.paramValue; + } +} diff --git a/tests/ui.test.js b/tests/ui.test.js index cfc876ea..3acc7d3a 100644 --- a/tests/ui.test.js +++ b/tests/ui.test.js @@ -48,6 +48,7 @@ jest.unstable_mockModule('../src/lib/db.js', () => ({ listUnnotifiedEntries: jest.fn(() => []), deleteQueueEntry: jest.fn(), clearQueueByStatus: jest.fn(), + clearAutoApprovedEntries: jest.fn(), clearCompletedQueue: jest.fn(), getPendingQueueCount: jest.fn(() => 0), getQueueCounts: jest.fn(() => ({ pending: 0, approved: 0, rejected: 0, completed: 0 })), @@ -100,6 +101,11 @@ jest.unstable_mockModule('../src/lib/db.js', () => ({ deleteBroadcast: jest.fn(), getBroadcast: jest.fn(), + // Sidecar Secret + setSidecarSecret: jest.fn(), + getSidecarSecretHash: jest.fn(() => null), + clearSidecarSecret: jest.fn(), + // Queue visibility getSharedQueueVisibility: jest.fn(() => false), setSharedQueueVisibility: jest.fn(), @@ -164,7 +170,20 @@ jest.unstable_mockModule('../src/lib/db.js', () => ({ deleteMcpSessionsForAgent: jest.fn(() => ({ changes: 0 })), deleteStaleMcpSessions: jest.fn(() => ({ changes: 0 })), getMcpSessionCounts: jest.fn(() => ({ total: 0, byAgent: {} })), - getMcpSessionCount: jest.fn(() => 0) + getMcpSessionCount: jest.fn(() => 0), + + // Custom services + createCustomService: jest.fn(), + getCustomService: jest.fn(), + listCustomServices: jest.fn(() => []), + updateCustomService: jest.fn(), + deleteCustomService: jest.fn(), + createCustomServiceAccount: jest.fn(), + getCustomServiceAccount: jest.fn(), + listCustomServiceAccounts: jest.fn(() => []), + updateCustomServiceAccount: jest.fn(), + deleteCustomServiceAccount: jest.fn(), + listEnabledCustomServices: jest.fn(() => []) })); // Mock hsyncManager