/** * Daily spend guards for voice — per-number and global caps. * * Framework-free and storage-free: the host supplies a `VoiceLimitsStore` * implementation against whatever database it already runs, so this module * needs no DB client and talker takes on no new dependency. The store type is * structural, so any object with a matching `incrementAndGet` satisfies it. * * One shared counter covers a whole voice round-trip: a caller reserves once, * before transcription starts, so transcribe + reply cost a single unit. */ export interface VoiceLimitsConfig { perNumberDailyLimit: number; globalDailyLimit: number; } /** * The key used for the global counter - there is no per-number key at that * scope. An implementation constant for this module's own `VoiceLimitsStore` * calls, exported for its tests and not from the package root. */ export declare const GLOBAL_LIMIT_KEY = "global"; export declare const DEFAULT_PER_NUMBER_DAILY_LIMIT = 100; export declare const DEFAULT_GLOBAL_DAILY_LIMIT = 500; /** The environment variables this module understands, all optional. */ export interface VoiceLimitsEnv { VOICE_LIMIT_PER_NUMBER?: string; VOICE_LIMIT_GLOBAL?: string; } /** Which cap blocked the request, or null when it is allowed. */ export type VoiceLimitReason = "per-number" | "global" | null; export interface VoiceLimitCheck { allowed: boolean; reason: VoiceLimitReason; } /** * Structural dependency — a host implements this against its own storage. * * Must increment and read back atomically. A deployment running more than one * instance would race a read-then-write from this process against another's. */ export interface VoiceLimitsStore { /** Increments the counter for (scope, key, day) and returns the count after incrementing. */ incrementAndGet(scope: "number" | "global", key: string, day: string): Promise; } export interface VoiceLimiterDeps { store: VoiceLimitsStore; /** Clock, injectable so day-rollover tests do not depend on real time. */ now?: () => number; } export interface VoiceLimiter { /** * Reserves one unit for `phoneNumber`, subject to both daily caps. * * Call this exactly once per logical request: the increment is permanent and * unconditional (there is no release path), so a duplicate call — or a retry * after a transient failure — permanently burns a second unit of quota for * one request never delivered. Blocked calls still count: a call that trips * the per-number cap still consumes one of that number's daily units, and * (by design, see below) a call that trips the global cap after passing the * per-number check also consumes one of that number's units, for nothing. * * The per-number counter increments before the global one, so a number * blocked at its own cap never touches the shared global budget. The mirror * case is accepted, not fixed: once the global cap is exhausted for the day, * every further caller still burns one of their own per-number units on the * way to being told no. This favors the more common cost risk (one number * hammering the endpoint) over the rarer one (the global cap saturating and * clipping everyone's personal quota). * * A store failure propagates rather than resolving to allowed-or-blocked. * This is the one place the module deliberately does not follow the * null-on-failure shape of its siblings: silently allowing would let an * outage in the host's counter store uncap spend, and silently blocking * would be indistinguishable from a real cap hit. The host decides — but on * a webhook path, catch it, because an unhandled rejection there is a 500 * and the response budget is measured in seconds. */ checkAndReserve(phoneNumber: string): Promise; } /** * The UTC calendar day counters roll over on, e.g. "2026-08-01". An internal * day-bucketing helper, exported for this module's tests and not from the * package root. */ export declare function utcDayKey(now: number): string; /** * A configured non-negative integer wins over the default; anything else falls * back. An internal helper behind `resolveVoiceLimitsConfig`, exported for * this module's tests and not from the package root. */ export declare function pickDailyLimit(value: string | undefined, fallback: number): number; /** * Resolve limits from an environment-shaped object plus explicit overrides. * * The env object is passed in rather than read from `process.env`, keeping * this module free of ambient state; a host that wants env-driven limits calls * `resolveVoiceLimitsConfig(process.env)` itself. */ export declare function resolveVoiceLimitsConfig(env?: VoiceLimitsEnv, overrides?: Partial): VoiceLimitsConfig; /** * Build a limiter. Call `checkAndReserve` exactly once per inbound voice note, * BEFORE transcription starts, so a single unit covers transcribe + reply. */ export declare function createVoiceLimiter(config: VoiceLimitsConfig, deps: VoiceLimiterDeps): VoiceLimiter;