/* RFC-0168 / RFC-0176 / RFC-0181: Integration orchestration — adapter registries, fan-out functions, inbound authentication, and legacy delivery substrate types. Separated from the barrel (index.ts) so type-only consumers don't transitively import adapter implementations. Do not import astro:env — the caller injects the secrets bag. Do not re-export sibling module types — that is the barrel's job. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import { telegramChannelAdapter, whatsappChannelAdapter } from "./adapters.ts"; import { lagebildDestinationAdapter } from "./lagebild-destination-adapter.ts"; import type { CrmAdapter, DestinationAdapter, DestinationKind, IntegrationChannelAdapter, IntegrationEvent, IntegrationSecrets, Lead, LeadMessage, EXECUTION_MODES, } from "./port.ts"; import { DESTINATION_KINDS, eventToLeadMessage } from "./port.ts"; import { LifecycleEventPayloadSchema } from "./lifecycle.ts"; import { z } from "zod"; // @ai-invariant: CHANNEL_ADAPTERS and CRM_ADAPTERS are closed registries. // Adding a channel/CRM = add one adapter entry here. The adapter self-enables // by secret presence — no per-channel code in routes. Never log the secrets // bag; log only adapter id + error message. /** Closed channel adapter registry (RFC-0168). Add a channel = add an entry here. * RFC-0181: email is no longer a fetch channel adapter — it is sent via Cloudflare * Email Routing (send_email binding) in the delivery callback. */ export const CHANNEL_ADAPTERS: readonly IntegrationChannelAdapter[] = [ telegramChannelAdapter, whatsappChannelAdapter, ]; /** Closed CRM adapter registry (RFC-0168). Empty — Lagebild is the sole CRM via DESTINATION_ADAPTERS. */ export const CRM_ADAPTERS: readonly CrmAdapter[] = []; /** * Closed catalog of configurable channel adapter ids (RFC-0168). `"null"` is the * safe no-op binding (explicitly declared "no channel"). Used by * integration.config.validate to reject unknown adapter ids in system.md. */ export const CHANNEL_ADAPTER_IDS: readonly string[] = [ ...CHANNEL_ADAPTERS.map((adapter) => adapter.id), "null", ]; /** Closed catalog of configurable CRM adapter ids (RFC-0168). `"null"` = no CRM. */ export const CRM_ADAPTER_IDS: readonly string[] = [ ...CRM_ADAPTERS.map((adapter) => adapter.id), "null", ]; /** * Adapter id → required env secret names, across channels + CRM (RFC-0168). The * `"null"` adapter requires none. Used by integration.secrets.validate to assert a * configured adapter's secrets are present in the generated env schema. */ export const INTEGRATION_ADAPTER_SECRETS: Readonly> = { null: [], ...Object.fromEntries(CHANNEL_ADAPTERS.map((adapter) => [adapter.id, adapter.requiredSecrets])), ...Object.fromEntries(CRM_ADAPTERS.map((adapter) => [adapter.id, adapter.requiredSecrets])), }; function hasAllSecrets(secrets: IntegrationSecrets, keys: readonly string[]): boolean { return keys.every((key) => Boolean(secrets[key]?.trim())); } export interface DeliverResult { delivered: string[]; failed: string[]; /** Channels left dormant because their required secrets are absent. */ skipped: string[]; } /** Fan out to every channel whose required secrets are present. Best-effort. */ export async function deliverLead( message: LeadMessage, secrets: IntegrationSecrets, ): Promise { const delivered: string[] = []; const failed: string[] = []; const skipped: string[] = []; for (const adapter of CHANNEL_ADAPTERS) { if (!hasAllSecrets(secrets, adapter.requiredSecrets)) { skipped.push(adapter.id); continue; } try { await adapter.deliver(message, secrets); delivered.push(adapter.id); } catch (err) { // Never log the secrets bag; log only the adapter id + error message. console.warn(`[integration] channel "${adapter.id}" failed:`, (err as Error).message); failed.push(adapter.id); } } return { delivered, failed, skipped }; } /** Readiness of one adapter: ready when every required secret is present. */ export interface AdapterReadiness { id: string; kind: "channel" | "crm"; ready: boolean; /** Required secret keys that are absent (gentle, actionable hint — names only). */ missing: string[]; } /** * Pure, framework-agnostic readiness audit shared across the ecosystem (RFC-0168): * the send-message handler uses it for a gentle server-side notice, and * `integration.secrets.audit` uses it for the build-time advisory. Reports only * secret NAMES, never values. Pass `ids` to restrict the audit to the adapters a * site actually intends (e.g. its system.md `integrations:` block). */ export function auditIntegrationReadiness( secrets: IntegrationSecrets, ids?: { channels?: readonly string[]; crm?: readonly string[] }, ): AdapterReadiness[] { const wantChannel = (id: string) => !ids?.channels || ids.channels.includes(id); const wantCrm = (id: string) => !ids?.crm || ids.crm.includes(id); const report: AdapterReadiness[] = []; for (const adapter of CHANNEL_ADAPTERS) { if (!wantChannel(adapter.id)) continue; const missing = adapter.requiredSecrets.filter((key) => !secrets[key]?.trim()); report.push({ id: adapter.id, kind: "channel", ready: missing.length === 0, missing }); } for (const adapter of CRM_ADAPTERS) { if (!wantCrm(adapter.id)) continue; const missing = adapter.requiredSecrets.filter((key) => !secrets[key]?.trim()); report.push({ id: adapter.id, kind: "crm", ready: missing.length === 0, missing }); } return report; } // --------------------------------------------------------------------------- // RFC-0176: source-agnostic destination hub // --------------------------------------------------------------------------- // @ai-invariant: DESTINATION_ADAPTERS is a closed registry. Every destination // adapter self-enables by secret presence. When BOTH a buffer adapter AND a // direct adapter are active for the same (kind, vendor), // integration.config.validate reports "multiple-active-executors". /** Closed registry of gogol-adapter destination implementations (RFC-0176). */ export const DESTINATION_ADAPTERS: readonly DestinationAdapter[] = [lagebildDestinationAdapter]; /** * Externally-implemented destination vendors whose adapters live in separate packages * (RFC-0186/0190). These vendors are valid `gogol-adapter` targets but cannot be * registered in `DESTINATION_ADAPTERS` because their adapter packages depend on * `@warpgogol/werkstatt-site/integration` (circular-dep guard). The adapter is injected at runtime via * `extraAdapters`; the validator checks vendor + secrets against these catalogs. */ export const EXTERNAL_DESTINATION_VENDORS: Readonly> = { crm: [], calendar: [], email: [], scheduler: [], }; /** Per `(kind, vendor)` → required secret names for externally-implemented adapters. */ export const EXTERNAL_DESTINATION_SECRETS: Readonly> = {}; /** Closed per-kind vendor catalog, derived from the registry + external vendors (RFC-0176/0186). */ export const DESTINATION_VENDORS_BY_KIND: Readonly> = Object.fromEntries( DESTINATION_KINDS.map((kind) => [ kind, [ ...new Set(DESTINATION_ADAPTERS.filter((a) => a.kind === kind).map((a) => a.vendor)), ...(EXTERNAL_DESTINATION_VENDORS[kind] ?? []), ], ]), ) as unknown as Record; /** Per `(kind, vendor)` → required secret names (gogol-adapter mode only). */ export const DESTINATION_ADAPTER_SECRETS: Readonly> = { ...Object.fromEntries( DESTINATION_ADAPTERS.map((a) => [`${a.kind}:${a.vendor}`, a.requiredSecrets]), ), ...EXTERNAL_DESTINATION_SECRETS, }; /** A configured destination from system.md integrations.destinations[]. */ export interface DestinationConfig { kind: DestinationKind; vendor: string; mode: (typeof EXECUTION_MODES)[number]; } export interface RouteResult { /** `${kind}:${vendor}` ids routed successfully. */ routed: string[]; /** `${kind}:${vendor}` ids that threw. */ failed: string[]; /** Skipped because dedup saw this eventId+destination, or secrets absent. */ skipped: string[]; } /** * Route an IntegrationEvent to its configured `gogol-adapter` destinations, * running on the client's site with the client's tokens. `vendor-native` * destinations are NOT executed here (an upstream vendor runs them). * * Pure + injectable: pass `seen` (a dedup set keyed by `${eventId}:${kind}:${vendor}`) * so a queue redelivery never double-writes. Best-effort per destination. */ export async function routeEvent( event: IntegrationEvent, destinations: readonly DestinationConfig[], secrets: IntegrationSecrets, seen?: { has(key: string): boolean | Promise; add(key: string): unknown }, /** Optional adapter registry override — defaults to DESTINATION_ADAPTERS. */ adapters?: readonly DestinationAdapter[], ): Promise { const registry = adapters ?? DESTINATION_ADAPTERS; const routed: string[] = []; const failed: string[] = []; const skipped: string[] = []; for (const dest of destinations) { const id = `${dest.kind}:${dest.vendor}`; if (dest.mode !== "gogol-adapter") continue; // vendor-native: executed upstream const adapter = registry.find((a) => a.kind === dest.kind && a.vendor === dest.vendor); if (!adapter || !hasAllSecrets(secrets, adapter.requiredSecrets)) { skipped.push(id); continue; } const dedupKey = `${event.eventId}:${id}`; if (seen && (await seen.has(dedupKey))) { skipped.push(id); continue; } try { await adapter.route(event, secrets); seen?.add(dedupKey); routed.push(id); } catch (err) { console.warn(`[integration] destination "${id}" failed:`, (err as Error).message); failed.push(id); } } return { routed, failed, skipped }; } // @ai-invariant: authenticateInbound uses constant-time comparison. Must remain // fail-closed: an unconfigured inbound route (secret unset) authenticates nothing. // IntegrationEventSchema is the validation gate for ALL inbound events. /** Zod schema for an inbound IntegrationEvent (RFC-0176). Rejects malformed posts. */ export const IntegrationEventSchema = z.object({ eventId: z.string().min(1), kind: z.enum(["lead", "message", "appointment"]), source: z.string().min(1), locale: z.string().min(2), occurredAt: z.string().min(1), contact: z .object({ name: z.string().optional(), email: z.string().optional(), phone: z.string().optional(), }) .optional(), payload: z.record(z.string(), z.unknown()).default({}), // RFC-0191: optional typed lifecycle payload (Stripe billing events). Additive — a // funnel/lead event without it still validates. lifecycle: LifecycleEventPayloadSchema.optional(), }); /** * Constant-time-ish equality for the inbound webhook secret. Authenticates a * source POST to /api/integration-inbound via a bearer token or X-Integration-Secret * header against INTEGRATION_INBOUND_SECRET. Returns false when the secret is unset * (fail-closed: an unconfigured inbound route authenticates nothing). */ export function authenticateInbound(headers: Headers, secret: string | undefined): boolean { if (!secret) return false; const provided = headers.get("x-integration-secret") ?? headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? ""; if (provided.length !== secret.length) return false; let mismatch = 0; for (let i = 0; i < secret.length; i += 1) { mismatch |= provided.charCodeAt(i) ^ secret.charCodeAt(i); } return mismatch === 0; } /** * Route an event to every registered gogol-adapter destination whose required * secrets are present (self-enabling, mirroring the channel fan-out). Used by the * inbound handler and the queue consumer when no explicit destinations list is * supplied. `seen` is the optional dedup set (RFC-0176). * * `extraAdapters` lets a specific site's delivery callback inject additional * DestinationAdapters without modifying the closed shared registry. They are self-enabling (skip if secrets absent). */ export function routeEventToReady( event: IntegrationEvent, secrets: IntegrationSecrets, seen?: { has(key: string): boolean | Promise; add(key: string): unknown }, extraAdapters?: readonly DestinationAdapter[], ): Promise { const allAdapters = extraAdapters ? [...DESTINATION_ADAPTERS, ...extraAdapters] : DESTINATION_ADAPTERS; const destinations: DestinationConfig[] = allAdapters.map((a) => ({ kind: a.kind, vendor: a.vendor, mode: "gogol-adapter", })); return routeEvent(event, destinations, secrets, seen, allAdapters); } /** Combined result of a unified event delivery (channels + destinations). RFC-0181. */ export interface DeliverEventResult { channels: DeliverResult; destinations: RouteResult; } /** * RFC-0181: the standardized fan-out for ANY source (send-message form, chat widget). * Runs the event through the channel adapters (Telegram/WhatsApp) AND the gogol-adapter * destinations (CRM), with the client's own tokens. Email is NOT here — it is sent via * Cloudflare Email Routing (send_email binding) in the callback route. `seen` dedups the * destination side on redelivery. Best-effort per sink; the caller decides ack vs retry. * `extraAdapters` lets a specific site inject additional DestinationAdapters * without modifying the closed shared registry. */ export async function deliverEvent( event: IntegrationEvent, secrets: IntegrationSecrets, seen?: { has(key: string): boolean | Promise; add(key: string): unknown }, extraAdapters?: readonly DestinationAdapter[], ): Promise { const channels = await deliverLead(eventToLeadMessage(event), secrets); const destinations = await routeEventToReady(event, secrets, seen, extraAdapters); return { channels, destinations }; } /* * LEGACY delivery substrate (RFC-0176, Cloudflare Queues + KV). * * SUPERSEDED for the EU path by the QStash + Upstash Redis substrate in ./qstash.ts * (RFC-0181): Cloudflare Queues/KV cannot be pinned to the EU, and the per-site * consumer Worker was retired. `cloudflare.residency.validate` now fails the build if a * wrangler.jsonc declares queues/KV. These primitives are kept only for the structural * contracts + the unit test; production routes publish to QStash and fan out via * /api/integration-route. Do NOT wire these into a live delivery path. * See docs/specs/integration-delivery.md (authoritative). */ /** Minimal structural type for a Cloudflare Queue binding (no hard dependency). */ export interface QueueBinding { send(message: T): Promise; } /** Minimal structural type for a Cloudflare KV namespace used for dedup. */ export interface KvDedupStore { get(key: string): Promise; put(key: string, value: string, options?: { expirationTtl?: number }): Promise; } /** Wrap a KV namespace as a dedup `seen` set with a short TTL (in-flight only). */ export function kvDedup(kv: KvDedupStore, ttlSeconds = 86400) { return { async has(key: string): Promise { return (await kv.get(key)) !== null; }, add(key: string): void { void kv.put(key, "1", { expirationTtl: ttlSeconds }); }, }; } /** * LEGACY (RFC-0176, see the banner above). Enqueue an event onto a Cloudflare Queue. * The live EU path no longer uses this — sources publish to QStash (./qstash.ts, * RFC-0181). Retained for the structural contract + unit test only. */ export async function enqueueEvent(queue: QueueBinding, event: IntegrationEvent): Promise { await queue.send(event); } /** * Process a batch of queued events (the Cloudflare `queue()` consumer body). * Dedups via KV, routes to ready gogol-adapter destinations, and ACKs each * message; a thrown route lets the platform retry that message (bounded → DLQ). */ export async function consumeIntegrationBatch( events: readonly IntegrationEvent[], secrets: IntegrationSecrets, dedup?: { has(key: string): boolean | Promise; add(key: string): unknown }, ): Promise { const results: RouteResult[] = []; for (const event of events) { results.push(await routeEventToReady(event, secrets, dedup)); } return results; } export async function upsertLead(lead: Lead, secrets: IntegrationSecrets): Promise { for (const adapter of CRM_ADAPTERS) { if (!hasAllSecrets(secrets, adapter.requiredSecrets)) continue; try { const result = await adapter.upsertLead(lead, secrets); return result?.id ?? null; } catch (err) { console.warn(`[integration] crm "${adapter.id}" failed:`, (err as Error).message); } } return null; }