/** * D1-backed tenant resolution for `MultiTenantAgentRegistry`. * * The registry takes `resolveTenantId` / `enumerateTenants` as callbacks, * and `mountMultiTenantWebhook` takes `anyTenantForVerify`. In practice * every multi-tenant app writes the same three functions over the same * shape: a `tenants` table with an `id` and a `phone_number_id`, fronted by * a KV cache because the lookup runs on every inbound message. * * This factory returns all three, wired together: * * const tenants = createD1TenantResolver({ db: (env) => env.DB, kv: (env) => env.KV }); * * const registry = new MultiTenantAgentRegistry({ * resolveTenantId: tenants.resolveTenantId, * enumerateTenants: tenants.enumerateTenants, * buildAgent, * }); * * mountMultiTenantWebhook(registry, app, '/wa', { * anyTenantForVerify: tenants.anyTenantForVerify, * }); * * After Embedded Signup writes a new `phone_number_id`, call * `tenants.invalidate(env, phoneNumberId)` so the KV cache doesn't serve a * stale miss for the next hour. * * Column and table names are configurable but validated as bare * identifiers — they're interpolated into SQL, which is not parameterizable * for identifiers. */ import type { InboundEnvelope } from '../types.js'; /** Minimum `expirationTtl` Cloudflare KV accepts. */ const KV_MIN_TTL_SECONDS = 60; /** The slice of D1 this resolver needs. Raw `D1Database` satisfies it. */ export interface TenantResolverDb { prepare(query: string): { bind(...values: unknown[]): { first(): Promise; all(): Promise<{ results?: T[] }>; }; first(): Promise; all(): Promise<{ results?: T[] }>; }; } /** The slice of KV this resolver needs. */ export interface TenantResolverKV { get(key: string): Promise; put(key: string, value: string, options?: { expirationTtl?: number }): Promise; delete(key: string): Promise; } export interface CreateD1TenantResolverOptions { /** D1 binding, or an accessor that pulls it off the worker env. */ db: TenantResolverDb | ((env: never) => TenantResolverDb); /** * KV binding (or accessor) used to cache `phone_number_id → tenantId`. * Omit to hit D1 on every message — fine for a handful of tenants. */ kv?: TenantResolverKV | ((env: never) => TenantResolverKV | undefined); /** Table holding tenants. Default `tenants`. */ tableName?: string; /** Primary key column. Default `id`. */ idColumn?: string; /** Column holding Meta's phone number id. Default `phone_number_id`. */ phoneNumberIdColumn?: string; /** Cache TTL in seconds. Default 3600. Clamped to KV's 60s floor. */ cacheTtlSeconds?: number; /** KV key prefix. Default `wa:phone`. */ keyPrefix?: string; /** * Also cache misses, for `negativeCacheTtlSeconds` seconds. Off by * default: a number that isn't in your table yet usually means signup is * mid-flight, and caching that costs you the first real message. Turn it * on if unknown-number floods are burning D1 reads (and layer * `honoRateLimit` in front regardless). */ negativeCacheTtlSeconds?: number; } export interface D1TenantResolver { /** Registry callback — envelope → tenantId. */ resolveTenantId: (env: unknown, envelope: InboundEnvelope) => Promise; /** Registry callback — every tenant with a connected number. */ enumerateTenants: (env: unknown) => Promise; /** `mountMultiTenantWebhook` callback — any tenant, for the GET challenge. */ anyTenantForVerify: (env: unknown) => Promise; /** Direct lookup, for webhooks that carry a `phone_number_id` but no envelope. */ resolveByPhoneNumberId: (env: unknown, phoneNumberId: string) => Promise; /** Drop the cached mapping for a number. Call after signup / disconnect. */ invalidate: (env: unknown, phoneNumberId: string) => Promise; } export function createD1TenantResolver(opts: CreateD1TenantResolverOptions): D1TenantResolver { if (!opts?.db) throw new Error('createD1TenantResolver: db required'); const { tableName = 'tenants', idColumn = 'id', phoneNumberIdColumn = 'phone_number_id', cacheTtlSeconds = 3600, keyPrefix = 'wa:phone', negativeCacheTtlSeconds = 0, } = opts; for (const [name, value] of Object.entries({ tableName, idColumn, phoneNumberIdColumn })) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { throw new Error(`createD1TenantResolver: ${name} must be a bare identifier ([A-Za-z_][A-Za-z0-9_]*)`); } } const selectByPhone = `SELECT ${idColumn} AS id FROM ${tableName} WHERE ${phoneNumberIdColumn} = ? LIMIT 1`; const selectConnected = `SELECT ${idColumn} AS id FROM ${tableName} WHERE ${phoneNumberIdColumn} IS NOT NULL AND ${phoneNumberIdColumn} != ''`; const selectAny = `SELECT ${idColumn} AS id FROM ${tableName} LIMIT 1`; const dbFor = (env: unknown): TenantResolverDb => typeof opts.db === 'function' ? (opts.db as (e: unknown) => TenantResolverDb)(env) : opts.db; const kvFor = (env: unknown): TenantResolverKV | undefined => { if (!opts.kv) return undefined; return typeof opts.kv === 'function' ? (opts.kv as (e: unknown) => TenantResolverKV | undefined)(env) : opts.kv; }; const cacheKey = (phoneNumberId: string) => `${keyPrefix}:${phoneNumberId}`; const ttl = (seconds: number) => Math.max(KV_MIN_TTL_SECONDS, Math.floor(seconds)); async function resolveByPhoneNumberId(env: unknown, phoneNumberId: string): Promise { if (!phoneNumberId) return null; const kv = kvFor(env); if (kv) { try { const cached = await kv.get(cacheKey(phoneNumberId)); // Empty string is the negative-cache marker: a known miss. if (cached !== null) return cached === '' ? null : cached; } catch (e) { console.log(`[createD1TenantResolver] KV read failed for ${phoneNumberId}: ${errMsg(e)}`); } } let tenantId: string | null = null; try { const row = await dbFor(env).prepare(selectByPhone).bind(phoneNumberId).first<{ id: string | null }>(); tenantId = row?.id ?? null; } catch (e) { console.error(`[createD1TenantResolver] D1 read failed for ${phoneNumberId}: ${errMsg(e)}`); // Don't cache an infrastructure failure as a miss. return null; } if (kv && (tenantId !== null || negativeCacheTtlSeconds > 0)) { try { await kv.put(cacheKey(phoneNumberId), tenantId ?? '', { expirationTtl: ttl(tenantId !== null ? cacheTtlSeconds : negativeCacheTtlSeconds), }); } catch (e) { console.log(`[createD1TenantResolver] KV write failed for ${phoneNumberId}: ${errMsg(e)}`); } } return tenantId; } return { resolveByPhoneNumberId, resolveTenantId: async (env, envelope) => { const phoneNumberId = readEnvelopePhoneNumberId(envelope); if (!phoneNumberId) return null; return await resolveByPhoneNumberId(env, phoneNumberId); }, enumerateTenants: async (env) => { try { const rows = await dbFor(env).prepare(selectConnected).all<{ id: string | null }>(); return (rows.results ?? []).map((r) => r.id).filter((id): id is string => typeof id === 'string' && id !== ''); } catch (e) { console.error(`[createD1TenantResolver] enumerateTenants failed: ${errMsg(e)}`); return []; } }, anyTenantForVerify: async (env) => { try { const row = await dbFor(env).prepare(selectAny).first<{ id: string | null }>(); return row?.id ?? null; } catch (e) { console.error(`[createD1TenantResolver] anyTenantForVerify failed: ${errMsg(e)}`); return null; } }, invalidate: async (env, phoneNumberId) => { const kv = kvFor(env); if (!kv || !phoneNumberId) return; try { await kv.delete(cacheKey(phoneNumberId)); } catch (e) { console.log(`[createD1TenantResolver] KV delete failed for ${phoneNumberId}: ${errMsg(e)}`); } }, }; } /** * Read `metadata.phone_number_id` off an inbound envelope. Meta puts it on * every change value — messages, statuses, and coexistence alike — but the * typed envelope doesn't model it, so this does the narrowing in one place. */ export function readEnvelopePhoneNumberId(envelope: InboundEnvelope | undefined | null): string | null { for (const entry of envelope?.entry ?? []) { for (const change of entry?.changes ?? []) { const value = change?.value as { metadata?: { phone_number_id?: unknown } } | undefined; const id = value?.metadata?.phone_number_id; if (typeof id === 'string' && id) return id; } } return null; } function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); }