import { createHash } from "node:crypto"; import { db, decryptIntegrationSecret, schema, type IntegrationDefinition, type IntegrationOperation, type OrgIntegration, } from "@superlog/db"; import { and, eq } from "drizzle-orm"; import { logger } from "./logger.js"; const log = logger.child({ scope: "integrations" }); export const REVYL_INTEGRATION: IntegrationDefinition = { slug: "revyl", name: "Revyl", description: "Trigger and inspect Revyl test runs and workflows; author new regression tests from YAML.", base_url: "https://backend.revyl.ai", required_secrets: [ { name: "REVYL_API_KEY", description: "Revyl bearer token (Settings → API Keys in Revyl).", }, ], default_headers: { Authorization: "Bearer {{secrets.REVYL_API_KEY}}", "X-Revyl-Client": "superlog-agent run", "Content-Type": "application/json", }, operations: [ { name: "revyl_run_test", description: "Trigger an async run of an existing Revyl test by id. Returns a task_id; poll with revyl_get_test_run.", method: "POST", path: "/api/v1/execution/api/execute_test_id_async", input_schema: { type: "object", required: ["test_id"], additionalProperties: false, properties: { test_id: { type: "string", format: "uuid" }, variable_overrides: { type: "object", additionalProperties: { type: "string" }, description: "Runtime variable overrides; highest priority. Use to parameterize a reproduction.", }, retries: { type: "integer", minimum: 0, maximum: 3 }, }, }, body_template: { test_id: "{{input.test_id}}", variable_overrides: "{{input.variable_overrides?}}", retries: "{{input.retries?}}", source: "api", }, response_filter: ["status", "message", "id", "task_id"], }, { name: "revyl_get_test_run", description: "Retrieve current status and result of a previously triggered test run.", method: "GET", path: "/api/v1/tests/get_test_execution_task", docs_only: true, input_schema: { type: "object", required: ["task_id"], additionalProperties: false, properties: { task_id: { type: "string" } }, }, query_template: { task_id: "{{input.task_id}}" }, }, { name: "revyl_cancel_test_run", description: "Cancel a running test execution.", method: "POST", path: "/api/v1/execution/tests/status/cancel/{task_id}", input_schema: { type: "object", required: ["task_id"], additionalProperties: false, properties: { task_id: { type: "string" } }, }, path_template: { task_id: "{{input.task_id}}" }, }, { name: "revyl_run_workflow", description: "Trigger an async run of a Revyl workflow (a sequence of tests).", method: "POST", path: "/api/v1/execution/api/execute_workflow_id_async", input_schema: { type: "object", required: ["workflow_id"], additionalProperties: false, properties: { workflow_id: { type: "string", format: "uuid" } }, }, body_template: { workflow_id: "{{input.workflow_id}}" }, response_filter: ["status", "message", "id", "task_id"], }, { name: "revyl_get_workflow_run", description: "Retrieve current status of a previously triggered workflow run.", method: "GET", path: "/api/v1/workflows/status/status/{task_id}", docs_only: true, input_schema: { type: "object", required: ["task_id"], additionalProperties: false, properties: { task_id: { type: "string" } }, }, path_template: { task_id: "{{input.task_id}}" }, }, { name: "revyl_workflow_history", description: "List recent runs of a workflow. Use to correlate against an incident time window.", method: "GET", path: "/api/v1/workflows/history/{workflow_id}", docs_only: true, input_schema: { type: "object", required: ["workflow_id"], additionalProperties: false, properties: { workflow_id: { type: "string", format: "uuid" }, limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, }, }, path_template: { workflow_id: "{{input.workflow_id}}" }, query_template: { limit: "{{input.limit?}}" }, }, { name: "revyl_validate_yaml", description: "Pre-flight a YAML test definition before creating it. Returns is_valid + per-line messages.", method: "POST", path: "/api/v1/tests/yaml/validate-yaml", input_schema: { type: "object", required: ["yaml_content"], additionalProperties: false, properties: { yaml_content: { type: "string", minLength: 1 }, platform: { type: "string" }, }, }, body_template: { yaml_content: "{{input.yaml_content}}", validation_type: "full_test", platform: "{{input.platform?}}", }, }, { name: "revyl_create_test_from_yaml", description: "Create a new Revyl test from a YAML definition. Use AFTER revyl_validate_yaml passes. The worker auto-tags the test with superlog_incident_id and superlog_session_id.", method: "POST", path: "/api/v1/tests/yaml/from-yaml", rate_limit_per_session: 3, input_schema: { type: "object", required: ["yaml_content"], additionalProperties: false, properties: { yaml_content: { type: "string", minLength: 1 }, }, }, body_template: { yaml_content: "{{input.yaml_content}}", metadata_overrides: { superlog_incident_id: "{{context.incident_id}}", superlog_session_id: "{{context.session_id}}", }, }, response_filter: ["success", "test_id", "blocks_count", "errors", "error"], }, ], }; export const INTEGRATIONS_BY_SLUG: Record = { [REVYL_INTEGRATION.slug]: REVYL_INTEGRATION, }; export type ResolvedIntegration = { row: OrgIntegration; definition: IntegrationDefinition; secrets: Record; }; export async function loadEnabledIntegrationsForOrg( orgId: string, ): Promise { const rows = await db .select() .from(schema.orgIntegrations) .where(and(eq(schema.orgIntegrations.orgId, orgId), eq(schema.orgIntegrations.enabled, true))); const resolved: ResolvedIntegration[] = []; for (const row of rows) { const definition = INTEGRATIONS_BY_SLUG[row.slug]; if (!definition) { log.warn({ orgId, slug: row.slug }, "enabled integration has unknown slug; skipping"); continue; } const secretRows = await db .select() .from(schema.orgIntegrationSecrets) .where(eq(schema.orgIntegrationSecrets.orgIntegrationId, row.id)); const secrets: Record = {}; for (const s of secretRows) { try { secrets[s.secretName] = decryptIntegrationSecret({ ciphertext: s.ciphertext, nonce: s.nonce, keyVersion: s.keyVersion, }); } catch (err) { log.error( { err, orgIntegrationId: row.id, secretName: s.secretName }, "integration secret decryption failed; skipping integration", ); continue; } } const missing = definition.required_secrets .map((spec) => spec.name) .filter((name) => !(name in secrets)); if (missing.length > 0) { log.warn( { orgIntegrationId: row.id, slug: row.slug, missing }, "integration missing required secrets; skipping", ); continue; } resolved.push({ row, definition, secrets }); } return resolved; } export function hashIntegrationSet(integrations: ResolvedIntegration[]): string { if (integrations.length === 0) return "none"; const fingerprint = integrations .map((i) => i.definition.operations.map((op) => `${op.name}:${op.method}:${op.path}`).sort().join("|"), ) .sort() .join("||"); return createHash("sha256").update(fingerprint).digest("hex").slice(0, 16); } export function buildCustomToolParams(integrations: ResolvedIntegration[]): Array<{ type: "custom"; name: string; description: string; input_schema: { type: "object"; properties?: Record; required?: string[] }; }> { const tools: ReturnType = []; for (const integration of integrations) { for (const op of integration.definition.operations) { tools.push({ type: "custom", name: op.name, description: truncateDescription(op.description), input_schema: sanitizeCustomToolInputSchema(op.input_schema), }); } } return tools; } const TOP_LEVEL_ALLOWED = new Set(["type", "properties", "required", "description"]); const NESTED_ALLOWED = new Set([ "type", "description", "enum", "items", "properties", "required", ]); function sanitizeCustomToolInputSchema(schema: unknown): { type: "object"; properties?: Record; required?: string[]; } { if (!isPlainObject(schema)) return { type: "object" }; const out: Record = { type: "object" }; for (const [k, v] of Object.entries(schema)) { if (!TOP_LEVEL_ALLOWED.has(k)) continue; if (k === "properties" && isPlainObject(v)) { const props: Record = {}; for (const [propName, propSchema] of Object.entries(v)) { props[propName] = sanitizeNested(propSchema); } out.properties = props; } else { out[k] = v; } } return out as { type: "object"; properties?: Record; required?: string[] }; } function sanitizeNested(node: unknown): unknown { if (!isPlainObject(node)) return node; const out: Record = {}; for (const [k, v] of Object.entries(node)) { if (!NESTED_ALLOWED.has(k)) continue; if (k === "properties" && isPlainObject(v)) { const props: Record = {}; for (const [propName, propSchema] of Object.entries(v)) { props[propName] = sanitizeNested(propSchema); } out.properties = props; } else if (k === "items") { out.items = sanitizeNested(v); } else { out[k] = v; } } return out; } function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } function truncateDescription(s: string): string { return s.length <= 1024 ? s : s.slice(0, 1021) + "..."; } type ContextVars = { incident_id: string; session_id: string }; export async function executeIntegrationOperation( integration: ResolvedIntegration, op: IntegrationOperation, input: Record, context: ContextVars, ): Promise { const sub = (s: string) => substitute(s, { input, secrets: integration.secrets, context }); const url = new URL( integration.definition.base_url + renderPath(op.path, op.path_template, input), ); if (op.query_template) { for (const [k, v] of Object.entries(op.query_template)) { const rendered = sub(v); if (rendered !== "" && rendered !== "undefined") url.searchParams.set(k, rendered); } } const headers: Record = {}; for (const [k, v] of Object.entries(integration.definition.default_headers)) { headers[k] = sub(v); } let body: string | undefined; if (op.body_template && op.method !== "GET") { body = JSON.stringify( renderTemplate(op.body_template, { input, secrets: integration.secrets, context }), ); } const res = await fetch(url.toString(), { method: op.method, headers, body }); const text = await res.text(); if (!res.ok) { throw new Error(`${op.method} ${op.path} failed: ${res.status} ${truncate(text, 500)}`); } if (!text) return null; try { return JSON.parse(text); } catch { return text; } } function renderPath( path: string, template: Record | undefined, input: Record, ): string { if (!template) return path; let out = path; for (const [k, v] of Object.entries(template)) { const rendered = substitute(v, { input, secrets: {}, context: { incident_id: "", session_id: "" }, }); out = out.replace(`{${k}}`, encodeURIComponent(rendered)); } return out; } function renderTemplate( value: unknown, vars: { input: Record; secrets: Record; context: ContextVars }, ): unknown { if (typeof value === "string") { const optional = value.endsWith("?}}"); const ref = value.match(/^\{\{([^}]+?)\??\}\}$/); if (ref) { const path = ref[1] ?? ""; const resolved = resolvePath(path, vars); if (optional && (resolved === undefined || resolved === null)) return undefined; return resolved ?? null; } return substitute(value, vars); } if (Array.isArray(value)) return value.map((v) => renderTemplate(v, vars)); if (value && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { const rendered = renderTemplate(v, vars); if (rendered !== undefined) out[k] = rendered; } return out; } return value; } function resolvePath( path: string, vars: { input: Record; secrets: Record; context: ContextVars }, ): unknown { const parts = path.split("."); let cur: unknown = vars as unknown as Record; for (const p of parts) { if (cur && typeof cur === "object" && p in (cur as Record)) { cur = (cur as Record)[p]; } else { return undefined; } } return cur; } function substitute( s: string, vars: { input: Record; secrets: Record; context: ContextVars }, ): string { return s.replace(/\{\{([^}]+?)\??\}\}/g, (_, path: string) => { const v = resolvePath(path, vars); return v == null ? "" : String(v); }); } export function validateIntegrationInput( input: unknown, schema: Record, ): string | null { if (!input || typeof input !== "object" || Array.isArray(input)) { return "input must be an object"; } const required = (schema.required as string[] | undefined) ?? []; for (const key of required) { if (!(key in input)) return `missing required field: ${key}`; } const properties = schema.properties as Record | undefined; if (properties) { for (const [key, value] of Object.entries(input as Record)) { const propSchema = properties[key]; if (!propSchema) { if (schema.additionalProperties === false) return `unknown field: ${key}`; continue; } if (propSchema.type && !matchesType(value, propSchema.type)) { return `field ${key} expected ${propSchema.type}, got ${typeof value}`; } } } return null; } function matchesType(value: unknown, type: string): boolean { switch (type) { case "string": return typeof value === "string"; case "integer": return typeof value === "number" && Number.isInteger(value); case "number": return typeof value === "number"; case "boolean": return typeof value === "boolean"; case "object": return value !== null && typeof value === "object" && !Array.isArray(value); case "array": return Array.isArray(value); default: return true; } } export function filterIntegrationResponse(value: unknown, allowed: string[] | undefined): unknown { if (!allowed || !value || typeof value !== "object" || Array.isArray(value)) return value; const out: Record = {}; for (const key of allowed) { if (key in (value as Record)) { out[key] = (value as Record)[key]; } } return out; } function truncate(s: string, max: number): string { return s.length <= max ? s : s.slice(0, max - 3) + "..."; }