/** * Multi-tenant Agent routing. * * Wraps the "envelope → tenantId → Agent" routing pattern that BSP-style * apps already write by hand. The single-tenant `Agent` + `mountWebhook` * API is unchanged; this is a sibling, opt-in. * * const registry = new MultiTenantAgentRegistry({ * resolveTenantId: async (env, envelope) => { * const phoneNumberId = envelope.entry?.[0]?.changes?.[0]?.value?.metadata?.phone_number_id; * if (!phoneNumberId) return null; * return await env.KV.get(`wa:phone:${phoneNumberId}`); * }, * buildAgent: async (env, tenantId) => { * const tenant = await loadTenantConfig(env, tenantId); * return new Agent({ * whatsapp: { endpoint: tenant.metaEndpoint, token: tenant.metaToken, appSecret: tenant.appSecret }, * db: env.DB, * ai: buildAI(tenant), * mode: tenant.agentMode, * escalationStore: tenant.escalationStore, * tenantId, * }); * }, * }); * * mountMultiTenantWebhook(registry, app, '/wa'); * * Per-isolate `Map` agent cache by default (no TTL — isolate lifetime * covers the cache lifetime, and tenant config rarely changes mid-isolate). * Pass a custom `agentCache` for LRU / TTL semantics. * * Queue table is shared across tenants — `message_queue.message_id` is * globally unique because Meta's wamids are globally unique. No * per-tenant schema migration needed. */ import type { Agent } from '../agent.js'; import type { InboundEnvelope } from '../types.js'; import type { AgentReviewQueue } from '../review/review_queue.js'; import type { CoexistenceHandler } from '../webhook/coexistence.js'; export interface AgentCache { get(tenantId: string): Agent | null; set(tenantId: string, agent: Agent): void; /** * Drop a cached Agent so the next turn rebuilds it (v0.19). Optional for * backwards compatibility with caches written against the v0.6 shape — * `registry.invalidate` reports `false` when the cache can't evict. */ delete?(tenantId: string): boolean; } export interface MultiTenantAgentRegistryOptions { /** * Resolve the inbound envelope to a tenantId. Return `null` when the * envelope is for an unknown tenant (typically: a `phone_number_id` * not present in your tenant store). `onUnknownTenant` fires in that * case so apps can log / alert / emit. * * Receives the bot env so KV/D1 lookups are easy. */ resolveTenantId: (env: unknown, envelope: InboundEnvelope) => Promise | string | null; /** * Build a fully-configured Agent for `tenantId`. Called once per cache * miss; the result is stashed in `agentCache`. Synchronous returns are * accepted for in-memory configs. */ buildAgent: (env: unknown, tenantId: string) => Promise | Agent; /** * Per-isolate Agent cache. Default: in-memory Map, no TTL — the isolate * lifetime covers the lifetime of any practical tenant config. Pass * `null` to disable caching (rebuild every turn — useful for tests). */ agentCache?: AgentCache | null; /** * Called when `resolveTenantId` returns null. Default: console.warn. * Use to emit telemetry, count unknown-number hits, etc. */ onUnknownTenant?: (env: unknown, envelope: InboundEnvelope) => void | Promise; /** * Enumerate every tenantId the bot serves. Used by `drainAll` (v0.7+) * to schedule per-tenant queue drains from a single cron trigger. The * scheduled cron handler typically delegates to this so the registry * can iterate without each app re-implementing the lookup. * * enumerateTenants: async (env) => { * const r = await env.DB.prepare('SELECT id FROM tenants').all<{ id: string }>(); * return (r.results ?? []).map(t => t.id); * } * * Apps that paginate (hundreds of tenants) extend this signature later * — v0.7 ships the simple version. */ enumerateTenants?: (env: unknown) => Promise | string[]; } /** * In-memory `AgentCache` — the default. Cache is per-isolate and never * evicts; Cloudflare's isolate-recycling policy bounds the size. */ export declare class MemoryAgentCache implements AgentCache { private readonly map; get(tenantId: string): Agent | null; set(tenantId: string, agent: Agent): void; /** * Evict one tenant (v0.19). Returns true when something was cached. * Call after the tenant's config changes — policy edit, mode switch, * reconnected number — otherwise the stale Agent lives as long as the * isolate does. */ delete(tenantId: string): boolean; /** Test helper — clears all cached agents. */ clear(): void; /** Number of cached agents. */ get size(): number; } export declare class MultiTenantAgentRegistry { readonly resolveTenantId: MultiTenantAgentRegistryOptions['resolveTenantId']; readonly buildAgent: MultiTenantAgentRegistryOptions['buildAgent']; readonly agentCache: AgentCache | null; readonly onUnknownTenant: (env: unknown, envelope: InboundEnvelope) => void | Promise; readonly enumerateTenants: ((env: unknown) => Promise | string[]) | null; constructor(opts: MultiTenantAgentRegistryOptions); /** * Iterate every tenant from `enumerateTenants`, resolve each Agent * (cache-friendly), and schedule `drain() + queue.cleanup()` via * `waitUntil`. Designed for `Worker.scheduled(event, env, ctx)`: * * scheduled: (event, env, ctx) => * registry.drainAll(env, (p) => ctx.waitUntil(p)) * * `enumerateTenants` MUST be configured at construction or this throws — * the registry has no other way to know which tenants exist. Apps that * paginate (hundreds of tenants) should override per-batch in v0.8. * * Per-tenant failures are caught and logged so one bad tenant doesn't * stop the cron from draining the rest. The return value reports how * many drains were scheduled, useful for cron-handler logs / metrics. */ drainAll(env: unknown, waitUntil: (p: Promise) => void): Promise<{ scheduled: number; }>; /** * Generalization of `drainAll`: iterate every tenant from * `enumerateTenants`, resolve each Agent (cache-friendly), and schedule * the user-supplied `fn(agent, tenantId)` via `waitUntil`. Use for any * per-tenant cron — Broadcast, ReEngagement, content generation, slot * delivery — that the framework doesn't already wrap. * * await registry.forEachTenant(env, ctx.waitUntil, async (agent, tid) => { * await new Broadcast({ client: agent.client, db: agent.db, channel: 'devotional' }) * .run({ send: ... }); * }); * * Like `drainAll`, this requires `enumerateTenants` at construction and * catches per-tenant failures so one bad tenant can't stop the cron. * * Returns `{ scheduled, errored }` — `scheduled` counts agents the * waitUntil callback was invoked for; `errored` counts tenants where * `agentFor` failed before the fn was reached. The fn's own throws are * caught inside the waitUntil closure and counted toward neither — they * surface in the console log. */ forEachTenant(env: unknown, waitUntil: (p: Promise) => void, fn: (agent: Agent, tenantId: string) => Promise | void): Promise<{ scheduled: number; errored: number; }>; /** * Cron helper for `assisted` mode: pick up every approved-but-not-yet-sent * row from the review queue and dispatch via the right tenant's Agent. * The reviewQueue is single (shared across tenants) — its `tenant_id` * column scopes rows, and this helper groups approved rows per tenant * before scheduling the per-tenant sends. * * scheduled: async (_event, env, ctx) => { * await registry.dispatchApprovedReviews(env, reviewQueue, (p) => ctx.waitUntil(p)); * } * * Per-row send failures are caught + logged so one stuck row doesn't * block the cron. Returns `{ scheduled }` for handler-side metrics. * * Apps without multi-tenant routing can use the same pattern via a * single-Agent cron — call `queue.list({ status: 'approved' })` and * dispatch via `agent.client.sendText` directly. */ dispatchApprovedReviews(env: unknown, reviewQueue: AgentReviewQueue, waitUntil: (p: Promise) => void): Promise<{ scheduled: number; }>; /** * Resolve + cache the Agent for the given tenantId. * * Returns the freshly-built or cached Agent. Failures from `buildAgent` * propagate — callers should catch + log them. */ agentFor(env: unknown, tenantId: string): Promise; /** * Evict a tenant's cached Agent (v0.19) so the next turn rebuilds it from * current config. Call this whenever tenant config changes underneath the * isolate — policy edits from your dashboard, an agent-mode switch, a * reconnected WhatsApp number. * * Returns false when nothing was evicted: no cache configured, the cache * predates the optional `delete` method, or the tenant wasn't cached. */ invalidate(tenantId: string): boolean; /** * Resolve the tenant from an envelope and return the corresponding Agent. * Returns `null` when the tenant can't be resolved — caller can decide * whether to reject the request, queue it for later, or silently drop. */ agentForEnvelope(env: unknown, envelope: InboundEnvelope): Promise; /** * Full inbound flow: resolve tenant → build Agent → enqueue + schedule * a drain. Returns `{ ok, tenantId, enqueued }` for the caller's * response. * * Use the Hono helper `mountMultiTenantWebhook` instead when possible; * this is the bare-fetch escape hatch. */ handleEnvelope(env: unknown, envelope: InboundEnvelope, waitUntil: (p: Promise) => void): Promise<{ ok: boolean; tenantId: string | null; enqueued: boolean; }>; } interface HonoLike { get(path: string, handler: (c: HonoContextLike) => unknown): unknown; post(path: string, handler: (c: HonoContextLike) => unknown): unknown; } interface HonoContextLike { req: { query(name: string): string | undefined; header(name: string): string | undefined; arrayBuffer(): Promise; }; executionCtx: { waitUntil(promise: Promise): void; }; env: { DB: D1Database; [k: string]: unknown; }; text(body: string): unknown; json(body: unknown, status?: number): unknown; } export interface MountMultiTenantWebhookOptions { /** * Resolve any tenant whose Agent's `verifyChallenge` should answer the * GET handshake from Meta. Meta's verify token is App-global by default, * so any tenant's Agent suffices — pick the first one you can find. * * Receives the env so KV/D1 lookups work. Return `null` when the app * has no tenants yet (returns 503). */ anyTenantForVerify: (env: unknown) => Promise | string | null; /** * Handle coexistence changes (v0.19) — `smb_message_echoes`, `history`, * `smb_app_state_sync`. Runs after signature verification and before the * ordinary messages are enqueued; coexistence changes are stripped from * what reaches `Agent.enqueue`. * * onCoexistence: async ({ echoes }) => { await handoff.markFromEchoes(echoes); } * * Without this option coexistence changes are dropped (the previous * behavior, since `extractInbound` can't read them). Throws are caught * and logged — Meta retries webhooks it doesn't get a 200 for, and a * failed echo isn't worth replaying the whole envelope. */ onCoexistence?: CoexistenceHandler; } /** * Hono integration — multi-tenant variant of `mountWebhook`. * * mountMultiTenantWebhook(registry, app, '/wa', { * anyTenantForVerify: async (env) => firstTenantId(env), * }); * * - `GET /webhook` resolves *any* onboarded tenant (the verify token is * App-global) and delegates challenge-answer to its Agent. Apps with no * tenants yet get a 503. * - `POST /webhook` resolves the tenant from the envelope, verifies the * signature against THAT tenant's Agent (per-tenant `appSecret`), then * enqueues. Unknown tenants return 404 + fire `onUnknownTenant`. * * **Layer `honoRateLimit` BEFORE this** so unknown-number floods don't burn * tenant-resolution KV lookups. */ export declare function mountMultiTenantWebhook(registry: MultiTenantAgentRegistry, app: HonoLike, base: string | undefined, opts: MountMultiTenantWebhookOptions): void; export {}; //# sourceMappingURL=registry.d.ts.map