/** * Per-user consent tracking + pipeline-level gate. * * Where it sits: between "user opted in to chat with the bot" (handled by * `LeadStore.optIn`) and "user opted in to AI / data retention / marketing * messages specifically". Most apps need both — the lead opt-in is a chat * boundary, the consent is a feature boundary. * * const consents = new ConsentStore({ db: agent.db }); * * // Grant on a button tap from the welcome flow * agent.button('consent_ai_processing', async ({ user, reply, inbound }) => { * await consents.grant(user.whatsapp, 'ai_processing', { evidence: inbound.wamid }); * await reply.text('Obrigado! Pode mandar suas perguntas.'); * }); * * // Block the AI turn when the user hasn't consented yet * agent.pipeline.before('llm', consentGate({ store: consents, type: 'ai_processing' })); * * Schema flexibility: * * - `tableName` + `columnMap` follow the same pattern as `EscalationStore` * — apps with their own richer table (psico has `consents` with * `tenant_id` NOT NULL FK + `patient_id` FK + `granted` boolean + * `revoked_at` audit log) point the store at their schema. Identifier * names are validated at construction (`SAFE_IDENT`). * - `omitColumns: ['whatsapp']` lets apps route via a different identity * column (psico stores `patient_id`); pass the identifier via * `extraColumns` instead. * * Storage interface: * * - `has(whatsapp, type)` → boolean * - `grant(whatsapp, type, opts?)` → void * - `revoke(whatsapp, type)` → void * - `list(whatsapp)` → ConsentRow[] * * The pipeline gate (`consentGate(...)`) short-circuits the turn with a * configurable action (default `silent`). When the gate fires it can also * emit a `consent_blocked` event into the framework event stream — opt-in * via the optional `emit` callback. */ import { sql } from 'drizzle-orm'; import { type DB } from '../db/client.js'; import type { PipelineContext, PipelineStep } from '../pipeline/types.js'; export interface ConsentRow { whatsapp: string; type: string; grantedAt: string; revokedAt: string | null; tenantId: string | null; evidence: string | null; } export type ConsentField = 'whatsapp' | 'type' | 'grantedAt' | 'revokedAt' | 'tenantId' | 'evidence'; export declare const DEFAULT_CONSENT_COLUMNS: Readonly>; export interface ConsentStoreOptions { /** * D1 binding OR a pre-built Drizzle client (any schema). v0.7+: * normalized internally. */ db: D1Database | DB; /** Physical table name. Default `'user_consents'`. */ tableName?: string; /** Per-field column-name override (same pattern as `EscalationStore`). */ columnMap?: Partial>; /** Logical fields to skip from INSERT entirely. */ omitColumns?: ReadonlyArray; /** Allowlist of extra column names accepted from `grant({ extraColumns })`. */ allowedExtraColumns?: ReadonlyArray; /** * Default tenantId to write when none is supplied to `grant()`. Useful * when a per-tenant agent factory builds one ConsentStore per tenant. */ defaultTenantId?: string | null; } export interface GrantOptions { evidence?: string | null; tenantId?: string | null; extraColumns?: Record; } /** * Extra-predicate hook surfaced on `has` / `list` / `revoke`. The callback * receives the resolved column map so app code can reference the correct * physical names; return an `SQL` fragment (or array of fragments) that * gets AND-ed into the WHERE clause. * * Closes the psico migration gap from v0.6: psico's `consents` table has * `patient_id NOT NULL FK` instead of a `whatsapp` column, so consent * lookup needs to join through `patients`. With `whereExtra`: * * await store.has('', 'ai_processing', { * tenantId, * whereExtra: (cols) => sql`${sql.raw(cols.tenantId)} = ${tenantId} AND * EXISTS (SELECT 1 FROM patients p WHERE p.id = patient_id AND p.whatsapp = ${whatsapp})`, * }); * * Identifier safety inside the fragment is the caller's responsibility — * the framework already trusts the columnMap allowlist; this is the same * trust boundary. */ export type ConsentWhereExtra = (cols: Readonly>) => ReturnType | ReturnType[]; export interface ConsentLookupOptions { tenantId?: string | null; whereExtra?: ConsentWhereExtra; } export declare class ConsentStore { readonly db: DB; readonly tableName: string; readonly columns: Readonly>; readonly omitColumns: ReadonlySet; readonly allowedExtraColumns: ReadonlySet; readonly defaultTenantId: string | null; constructor({ db, tableName, columnMap, omitColumns, allowedExtraColumns, defaultTenantId, }: ConsentStoreOptions); /** * @returns true when a non-revoked consent of `type` exists for `whatsapp`. * * Multi-tenant: when `defaultTenantId` is set the lookup is scoped to it. * Pass an explicit `tenantId` to override. */ has(whatsapp: string, type: string, opts?: ConsentLookupOptions | string | null): Promise; /** * Normalize the third argument across the v0.6 (`tenantId?: string | null`) * and v0.7 (`opts?: ConsentLookupOptions`) signatures. Both forms are * accepted so existing callers see no break. */ private normalizeLookupOpts; /** * Insert (or refresh) a consent. Idempotent at the primary-key level — * an existing non-revoked row stays as-is; an existing revoked row gets * its `revoked_at` cleared so the user can re-grant. */ grant(whatsapp: string, type: string, opts?: GrantOptions): Promise; /** * Mark an existing consent revoked. Idempotent — a non-existent or * already-revoked row results in 0 affected rows; the call still succeeds. */ revoke(whatsapp: string, type: string, opts?: ConsentLookupOptions | string | null): Promise; /** All consents (revoked + active) for one user. */ list(whatsapp: string, opts?: ConsentLookupOptions | string | null): Promise; private selectList; } export type ConsentGateAction = 'silent' | 'escalate' | 'reply'; export interface ConsentGateOptions { store: ConsentStore; /** Consent type to check (e.g. 'ai_processing'). */ type: string; /** * Pipeline action when the user hasn't granted consent. Default `'silent'` * — produces no reply and stops the pipeline. */ action?: ConsentGateAction; /** * Reply text used when `action === 'reply'`. Required in that case. * Ignored otherwise. */ reply?: string; /** * Reason tag emitted on the short-circuit decision. Default `'consent_required'`. * Surfaces in `agent_decision` events for funnel analysis. */ reason?: string; /** Step name shown in pipeline-error reports. Default `'consent_gate'`. */ stepName?: string; /** * Optional callback invoked when the gate fires. Useful for observability * (emit `consent_blocked` to Analytics Engine). Errors here are caught * + logged so the gate stays resilient. */ onBlocked?: (ctx: PipelineContext) => void | Promise; } /** * Build a pipeline step that short-circuits the turn when the user has * NOT granted the required consent. Insert before the LLM step: * * pipeline.before('llm', consentGate({ store, type: 'ai_processing' })); * * The gate reads `ctx.whatsapp` (and `ctx.tenantId` if your pipeline carries * it). Apps that route via a different identity column point their * ConsentStore at the right schema via columnMap — the gate doesn't care * about the physical layout. */ export declare function consentGate(opts: ConsentGateOptions): PipelineStep; //# sourceMappingURL=consent_store.d.ts.map