/** * 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'; /** 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 declare function createD1TenantResolver(opts: CreateD1TenantResolverOptions): D1TenantResolver; /** * 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 declare function readEnvelopePhoneNumberId(envelope: InboundEnvelope | undefined | null): string | null; //# sourceMappingURL=tenant_resolver.d.ts.map