import { env } from "cloudflare:workers"; import { all, first, run } from "../db"; import { decryptSecret, encryptSecret, randomId } from "../crypto"; import { badRequest } from "../errors"; import { assertBelowCap } from "../limits"; import { DEFAULT_FILTERS } from "~/lib/integrations"; import type { GithubMirrorDirection, IntegrationFilters, IntegrationKind, LinearStateMapping, SlackChannelRoute, } from "~/lib/integrations"; import type { RepoRow } from "~/lib/types"; export type IntegrationRow = { id: string; scope_type: "repo" | "org"; scope_id: string; kind: IntegrationKind; name: string; config: string; events: string; delivery_mode: "instant" | "digest"; digest_minutes: number; last_digest_at: number; credentials_ciphertext: string | null; credentials_iv: string | null; active: number; created_by: string | null; created_at: number; updated_at: number; }; export type IntegrationConfig = { filters: IntegrationFilters; slackChannels?: SlackChannelRoute[]; slackTeam?: string; linearMapping?: LinearStateMapping; linearTeamKeys?: string[]; linearOrganization?: string; githubInstallationId?: number; githubRepo?: string; /** Repos the installing GitHub user could access through the installation, captured at install time. */ githubAllowedRepos?: string[]; /** Subset of `githubAllowedRepos` where the installing GitHub user had push (or admin) at install time. */ githubPushRepos?: string[]; /** GitHub login of the installer, re-checked against the repo's collaborator permission before every push mirror. */ githubLogin?: string; githubMirror?: GithubMirrorDirection; /** Admin opt-in: let the GitHub → gitfor mirror rewrite unprotected (or force-push-allowed) branches. */ githubForceMirror?: boolean; githubRelayWebhooks?: boolean; }; /** Everything secret about an integration. Only ever stored AES-GCM encrypted. */ export type IntegrationCredentials = { signingSecret: string; webhookUrl?: string; botToken?: string; accessToken?: string; refreshToken?: string; inboundSecret?: string; }; function secretsKey(): string { if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY is not configured on this instance"); return env.SECRETS_KEY; } export async function sealCredentials( credentials: IntegrationCredentials, ): Promise<{ ciphertext: string; iv: string }> { return encryptSecret(secretsKey(), JSON.stringify(credentials)); } export async function openCredentials(row: IntegrationRow): Promise { if (!row.credentials_ciphertext || !row.credentials_iv) return { signingSecret: "" }; return JSON.parse(await decryptSecret(secretsKey(), row.credentials_ciphertext, row.credentials_iv)); } export function parseConfig(row: Pick): IntegrationConfig { const parsed = JSON.parse(row.config || "{}") as Partial; return { ...parsed, filters: { ...DEFAULT_FILTERS, ...parsed.filters } }; } export function parseEvents(row: Pick): string[] { const parsed: unknown = JSON.parse(row.events || "[]"); return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []; } export async function loadIntegration(id: string): Promise { return first("SELECT * FROM integrations WHERE id = ?", id); } const MAX_INTEGRATIONS_PER_EVENT = 60; const MAX_INTEGRATIONS_PER_SCOPE = 25; /** Active integrations that apply to a repo: its own plus those installed on the owning org. */ export async function integrationsForRepo(repo: Pick): Promise { return all( `SELECT * FROM integrations WHERE active = 1 AND ((scope_type = 'repo' AND scope_id = ?) OR (scope_type = 'org' AND scope_id = ?)) ORDER BY created_at LIMIT ?`, repo.id, repo.owner_id, MAX_INTEGRATIONS_PER_EVENT, ); } export async function insertIntegration(input: { scopeType: "repo" | "org"; scopeId: string; kind: IntegrationKind; name: string; config: IntegrationConfig; events: string[]; credentials: IntegrationCredentials; createdBy: string; deliveryMode?: "instant" | "digest"; digestMinutes?: number; }): Promise { const count = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM integrations WHERE scope_type = ? AND scope_id = ?", input.scopeType, input.scopeId, ); assertBelowCap(count?.count ?? 0, MAX_INTEGRATIONS_PER_SCOPE, "integrations"); const id = randomId(); const sealed = await sealCredentials(input.credentials); const now = Date.now(); await run( `INSERT INTO integrations (id, scope_type, scope_id, kind, name, config, events, delivery_mode, digest_minutes, last_digest_at, credentials_ciphertext, credentials_iv, active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, id, input.scopeType, input.scopeId, input.kind, input.name, JSON.stringify(input.config), JSON.stringify(input.events), input.deliveryMode ?? "instant", input.digestMinutes ?? 60, now, sealed.ciphertext, sealed.iv, input.createdBy, now, now, ); return id; } export async function updateCredentials(id: string, credentials: IntegrationCredentials): Promise { const sealed = await sealCredentials(credentials); await run( "UPDATE integrations SET credentials_ciphertext = ?, credentials_iv = ?, updated_at = ? WHERE id = ?", sealed.ciphertext, sealed.iv, Date.now(), id, ); } export async function updateConfig(id: string, config: IntegrationConfig): Promise { await run("UPDATE integrations SET config = ?, updated_at = ? WHERE id = ?", JSON.stringify(config), Date.now(), id); } const MAX_STORED_PAYLOAD = 64 * 1024; const MAX_STORED_RESPONSE = 4096; export async function recordDelivery(input: { id?: string; integrationId: string; direction?: "outbound" | "inbound"; event: string; repoId: string | null; status: "pending" | "success" | "failed" | "dead"; statusCode?: number | null; error?: string | null; payload: unknown; responseBody?: string | null; durationMs?: number | null; attempt?: number; /** Final outcome: duplicates of this delivery are never sent again. Never unset once set. */ terminal?: boolean; }): Promise { const id = input.id ?? randomId(); const now = Date.now(); const full = JSON.stringify(input.payload); const payload = full.length <= MAX_STORED_PAYLOAD ? full : JSON.stringify({ truncated: true, event: input.event }); await run( `INSERT INTO integration_deliveries (id, integration_id, direction, event, repo_id, status, status_code, error, payload, response_body, duration_ms, attempt, terminal, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, status_code = excluded.status_code, error = excluded.error, response_body = excluded.response_body, duration_ms = excluded.duration_ms, attempt = excluded.attempt, terminal = MAX(terminal, excluded.terminal), updated_at = excluded.updated_at`, id, input.integrationId, input.direction ?? "outbound", input.event, input.repoId, input.status, input.statusCode ?? null, input.error?.slice(0, MAX_STORED_RESPONSE) ?? null, payload, input.responseBody?.slice(0, MAX_STORED_RESPONSE) ?? null, input.durationMs ?? null, input.attempt ?? 0, input.terminal ? 1 : 0, now, now, ); return id; }