import { ColumnBuilder } from '@voltro/database'; import { Context } from 'effect'; import { DataStore } from '@voltro/database'; import { FieldDefinitions } from '@voltro/database'; import { Schema } from 'effect'; import { Table } from '@voltro/database'; import { VoltroPlugin } from '@voltro/protocol'; export declare interface BroadcastResult { /** How many topic subscribers the broadcast fanned out to. */ readonly recipients: number; /** The per-recipient `send` outcomes, in subscriber order. */ readonly results: ReadonlyArray; } export declare const buildNotificationService: (deps: { readonly channels: ReadonlyArray; readonly store: NotificationStore; readonly now?: () => string; /** Digest rollup window in ms. When > 0, sends whose category is NOT forced to * a specific `channels:` list coalesce into one digest per subject per window. * 0 (default) disables digesting — every send goes out immediately. */ readonly digestWindowMs?: number; }) => NotificationServiceShape; /** A delivery channel. `id` is the channel key prefs target (e.g. 'email', * 'slack', 'sms', 'push', 'inApp'). * * A MULTI-ENDPOINT channel (web push, mobile push) additionally implements * `deliverDetailed`: per-endpoint outcomes, each endpoint isolated — one dead * registration never aborts the others, and the delivery log says per * endpoint what happened (the per-CHANNEL record the old push loop wrote * could not). The service prefers it over `deliver` when present. */ export declare interface Channel { readonly id: string; readonly deliver: (msg: ChannelMessage) => Promise; readonly deliverDetailed?: (msg: ChannelMessage) => Promise>; } /** A built notification to deliver on one channel. */ export declare interface ChannelMessage { readonly to: string; readonly category: string; readonly title: string; readonly body: string; readonly data?: Record; readonly tenantId?: string | null; } /** Per-subject preference: which channels are on for a category. A missing * entry = default-on for every configured channel. */ export declare interface ChannelPreference { readonly subjectId: string; readonly category: string; readonly channel: string; readonly enabled: boolean; } /** Logs to the console — dev default + a safe fallback. */ export declare const consoleChannel: () => Channel; /** Bring-your-own channel (push/APNs/FCM/anything). */ export declare const customChannel: (id: string, deliver: (msg: ChannelMessage) => Promise) => Channel; /** * Durable NotificationStore over the framework DataStore. Persists the in-app * inbox, per-subject channel preferences, and the delivery log in three * plugin-contributed tables (`notification_inbox` / `notification_preferences` * / `notification_deliveries`). `notificationsPlugin` binds this automatically * once the app's store exists (unless an explicit `store` was passed), so the * inbox/unreadCount/markRead routes work on real, migrated data. */ export declare const dataStoreNotificationStore: (store: DataStore) => NotificationStore; export declare const dataStorePushSubscriptionStore: (store: DataStore) => PushSubscriptionStore; /** * The receiver side — what a browser does with the body above. Shipped (not * test-only) because the mock-push-endpoint test IS the product's proof: a test * that asserts "some bytes arrived" proves transport, this proves the bytes * decrypt with the subscriber's keys and nothing else. */ export declare const decryptWebPushPayload: (body: Buffer, receiver: { readonly uaPrivate: Buffer; readonly authSecret: Buffer; }) => Buffer; export declare const deliveriesTable: Table<"_voltro_notification_deliveries", FieldDefinitions<{ readonly id: ColumnBuilder; readonly recipient: ColumnBuilder; readonly category: ColumnBuilder; readonly channel: ColumnBuilder; readonly status: ColumnBuilder; readonly error: ColumnBuilder; readonly at: ColumnBuilder; readonly endpoint: ColumnBuilder; readonly clickToken: ColumnBuilder; readonly clickedAt: ColumnBuilder; }>, true, "byDeliveriesAt" | "byDeliveriesClickToken">; /** Per-channel delivery outcome (for the delivery log). A multi-endpoint * channel writes ONE record per endpoint (`endpoint` set); single-target * channels write one per channel as before. */ export declare interface DeliveryRecord { readonly id: string; readonly to: string; readonly category: string; readonly channel: string; readonly status: 'sent' | 'failed' | 'skipped'; readonly error?: string; readonly at: string; /** The device token / push endpoint this record is about, on multi-endpoint * channels. Absent on single-target channels. */ readonly endpoint?: string | null; /** Click-report capability (web push). Never sent to a dashboard reader. */ readonly clickToken?: string | null; /** Set when the recipient clicked the delivered notification. */ readonly clickedAt?: string | null; } /** Email channel — the app supplies the sender (compose with * `@voltro/plugin-mail`'s `MailService.send`). Keeps the mail dep out of here. */ export declare const emailChannel: (send: (msg: { to: string; subject: string; html: string; text: string; }) => Promise) => Channel; /** Test seam: fixed ephemeral key + salt reproduce RFC 8291 Appendix A. */ declare interface EncryptOverrides { readonly asPrivate?: Buffer; readonly salt?: Buffer; } /** * Encrypt one payload for one subscription — the complete `aes128gcm` body * (header ‖ ciphertext) a push service accepts, per RFC 8291. Single record * (web-push payloads are capped ~4 KB by every push service, so multi-record * framing buys nothing). */ export declare const encryptWebPushPayload: (keys: PushSubscriptionKeys, payload: Buffer, overrides?: EncryptOverrides) => Buffer; /** One endpoint's (device token's / browser subscription's) outcome inside a * multi-endpoint channel delivery. `pruned` marks a registration the channel * itself removed (the push service said it is gone). */ declare interface EndpointDelivery { readonly endpoint: string; readonly status: 'sent' | 'failed'; readonly error?: string; readonly pruned?: boolean; /** Capability for the notification-click report — stored on the delivery * record, carried in the push payload, matched constant-time. */ readonly clickToken?: string; } export declare const endpointHashOf: (endpoint: string) => string; /** Mint a fresh P-256 private scalar, base64url — what the dev secret mint and * `voltro secret generate` produce for `VOLTRO_VAPID_PRIVATE_KEY`. */ export declare const generateVapidPrivateKey: () => string; /** One notification held for later delivery — a digest window rollup or a * quiet-hours deferral. `flushAt` is the ISO instant it becomes deliverable * (the window boundary / the digest flush tick). `kind` marks WHY it was held * so the flush can coalesce digest rows but deliver quiet-hours rows as-is. */ export declare interface HeldNotification { readonly id: string; readonly kind: 'digest' | 'quiet'; readonly subjectId: string; readonly send: SendInput; readonly flushAt: string; } export declare const heldTable: Table<"_voltro_notification_held", FieldDefinitions<{ readonly id: ColumnBuilder; readonly kind: ColumnBuilder; readonly subjectId: ColumnBuilder; readonly send: ColumnBuilder, "json", boolean>; readonly flushAt: ColumnBuilder; }>, true, "byHeldFlushAt" | "byHeldSubjectKind">; /** The in-app channel — delivers by writing to the inbox store. */ export declare const inAppChannel: (store: NotificationStore) => Channel; /** An in-app inbox item. */ export declare interface InboxItem { readonly id: string; readonly subjectId: string; readonly category: string; readonly title: string; readonly body: string; readonly data: Record; readonly readAt: string | null; /** When the user cleared it out of the inbox. A different state from read. */ readonly archivedAt: string | null; readonly createdAt: string; readonly tenantId: string | null; } export declare const inboxTable: Table<"_voltro_notification_inbox", FieldDefinitions<{ readonly id: ColumnBuilder; readonly subjectId: ColumnBuilder; readonly category: ColumnBuilder; readonly title: ColumnBuilder; readonly body: ColumnBuilder; readonly data: ColumnBuilder, "json", boolean>; readonly readAt: ColumnBuilder; /** * ARCHIVED is a different state from READ, and for a user the more important * one: it is what empties the inbox. * * Reported as the reason an app could not adopt this plugin. `readAt` covers * read and `status` on the delivery table is the delivery outcome * ('sent' | 'failed' | 'skipped') — neither is an archive, and the word did * not appear anywhere in this plugin's types. An inbox a user cannot clear is * one they stop opening. */ readonly archivedAt: ColumnBuilder; readonly createdAt: ColumnBuilder; readonly tenantId: ColumnBuilder; }>, true, "byInboxSubject" | "byInboxSubjectCreatedAt" | "byInboxSubjectUnread">; /** True if `at` is inside the subject's DND window (handles a window that WRAPS * midnight, e.g. 22:00→08:00). An empty window (start === end) is never active. */ export declare const inQuietHours: (qh: QuietHours, at: Date) => boolean; /** * WHO an inbox belongs to. * * `subject.id` is the framework's answer and it is not always the app's. A * reporter's addressing unit is the EMPLOYEE: a shift change, an absence * request or a task reminder is addressed to a person, and a person does not * necessarily have an auth user. Their numbers, on 14 670 rows: * * employee WITH a userId 4 677 representable * employee WITHOUT one 668 NOT — 4 people, 516 of those rows read, * most recent 2026-06-19 * * The 668 are live traffic, read by someone, and unaddressable under a * subject-only model. Worse is what comes after a migration: every producer * would resolve employee → userId and silently deliver NOTHING for anyone * without an auth user — the exact failure this plugin's own docstring warns * about, one level up and structural rather than accidental. * * So the app supplies it, the same seam `auth.resolveScopes` already offers for * this shape. Absent keeps today's behaviour exactly. * * **It is ASYNC, and the sync-only first version was the defect.** The seam was * built for an app whose addressing unit is its own, and an app that has one * keeps it in a TABLE — the three examples in the option's own docstring * (employee, member, contact) are all rows. So the resolver reads the store, * so it returns a promise, so the call written in our docstring did not * typecheck for the only readers it was written for. A resolver that happens to * be sync still satisfies the type; nothing about that case changed. * * NOT cached here on purpose. A per-connection cache is the obvious next step * and it is the app's to make, not ours: the first call decides the answer for * the life of the connection, so a member created a second after connect * resolves to the fallback until reconnect. An app that knows its own * invalidation can memoise inside the resolver; the framework cannot know it. */ export declare const makeSubjectId: (resolve: NotificationsPluginOptions["resolveSubjectId"]) => (ctx: { request: { subject: { id?: string | null; }; }; }) => Promise; export declare const memoryNotificationStore: () => NotificationStore; export declare const memoryPushSubscriptionStore: () => PushSubscriptionStore; /** The minute-of-day (0–1439) `at` falls on in the IANA `tz`. Uses `Intl` so no * date-math library is needed; an unknown zone falls back to UTC. */ export declare const minuteOfDayInZone: (at: Date, tz: string) => number; export declare class NotificationService extends NotificationService_base { } declare const NotificationService_base: Context.TagClass; export declare interface NotificationServiceShape { readonly send: (input: SendInput) => Promise; readonly inbox: (subjectId: string, opts?: { unreadOnly?: boolean; limit?: number; }) => Promise>; readonly unreadCount: (subjectId: string) => Promise; readonly markRead: (id: string, subjectId: string) => Promise; /** Undo a read. An inbox without the way back is a one-way ratchet. */ readonly markUnread: (id: string, subjectId: string) => Promise; /** Read everything; returns how many rows changed. */ readonly markAllRead: (subjectId: string) => Promise; /** Archive / unarchive. A different state from read — and the one that * empties the inbox, which is why it matters more to a user. */ readonly setArchived: (id: string, subjectId: string, archived: boolean) => Promise; readonly getPreferences: (subjectId: string) => Promise>; readonly setPreference: (pref: ChannelPreference) => Promise; readonly subscribe: (topic: string, subjectId: string, tenantId?: string | null) => Promise; readonly unsubscribe: (topic: string, subjectId: string) => Promise; readonly broadcast: (topic: string, input: Omit) => Promise; readonly setQuietHours: (qh: QuietHours) => Promise; readonly clearQuietHours: (subjectId: string) => Promise; readonly getQuietHours: (subjectId: string) => Promise; readonly flushDue: (now?: Date) => Promise; } export declare const notificationsPlugin: (options?: NotificationsPluginOptions) => VoltroPlugin; export declare interface NotificationsPluginOptions { /** * Resolve WHO the caller's inbox belongs to, when that is not `subject.id`. * * The addressing unit is the app's, not the framework's. If yours is an * employee, a member or a contact — something that need not have an auth user * — return its id here and the whole surface (`inbox`, `unreadCount`, * `markRead`, `archive`, preferences, quiet hours) follows. * * notificationsPlugin({ resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx) }) * * **May be async, and usually has to be.** An app that HAS its own addressing * unit almost always stores it in a table — an employee, a member, a contact * are rows, not token claims. If the mapping were in the token there would be * no need for this seam at all: `subject.id` would already be the right id. * The sync-only signature this shipped with made the call in the line above — * our own docstring — fail to typecheck for exactly the apps it was built for. * * Absent keeps `subject.id`, so nothing changes for an app whose units line * up. Returning `undefined` for one caller falls back the same way rather * than failing the read. * * ── Before reaching for this ──────────────────────────────────────────── * * **The subject is whatever signs in. If your addressing unit is not that, * you are addressing something nobody can read.** * * Written by the team that adopted this option and then reversed it, and it * corrects a justification the docs used to carry: rows belonging to people * with no auth user, which employee-keying would "reach" and `subject.id` * would not. The measurement was right and the conclusion was backwards — an * inbox belongs to whoever can OPEN it, and only an account can. Keying by * employee did not deliver those rows; it made them look addressed and * charged a translation on every read path and every push. * * So ask "can the thing I am addressing sign in?" before "what is our * addressing unit". If it cannot, translate at the SENDING seam instead — * once, where the producer knows both ids — and leave the inbox on the * account. This option stays right where the sign-in identity genuinely IS * your own id, which is a different situation from a second identity some * accounts happen to map to. */ readonly resolveSubjectId?: (ctx: { readonly request: { readonly subject: { readonly id?: string | null; }; }; }) => string | undefined | Promise; /** Delivery channels. The in-app inbox channel is added automatically unless * you pass your own `inApp` channel. Default `[consoleChannel()]` + in-app. */ readonly channels?: ReadonlyArray; /** Inbox / prefs / delivery-log store. Default in-memory (swap for a durable * custom store in production). */ readonly store?: NotificationStore; /** Digest/batching rollup window in ms. When > 0, multiple sends to the same * subject within the window coalesce into ONE digest delivery (flushed on the * boundary by the scheduled flush). A send that forces its own `channels:` * bypasses the digest. 0 (default) = every send delivers immediately. */ readonly digestWindowMs?: number; /** How often the scheduled flush drains due digest windows + quiet-hours * deferrals. Default 30s. Only runs once the store is bound at boot. */ readonly flushIntervalMs?: number; /** * Namespace for this plugin's rpc tags + inspect endpoints. Default `notifications`. * * Set it when your app already publishes under that name — an exact tag * collision is fatal at codegen, and this is the way out. Orthogonal to * `name` below: `alias` REPLACES the namespace, `name` distinguishes two * installations within it. * * The dashboard panel follows: the plugin's inspect endpoints keep a mount * under its CANONICAL name alongside the aliased one, so aliasing does not * take the panel away. The one case it cannot cover is two installs of this * plugin — one canonical name, two panels — which get no shared mount at * all, on purpose. Read `inspectSlug` from `/_voltro/inspect/plugins` to * reach a specific install. */ readonly alias?: string; /** * Contribute this plugin's tables via `extendSchema.tables`. Default `true`. * * Set `false` when your app ALREADY declares equivalent tables and you want to * keep them — the seam this exists for. The plugin then contributes no DDL and * the declarative differ never proposes its tables; everything else (routes, * inspect, interceptors) is unchanged. * * **What you take over, exactly:** all six tables this plugin writes BY NAME — * `_voltro_notification_inbox`, `_voltro_notification_preferences`, * `_voltro_notification_deliveries`, `_voltro_notification_topic_subscriptions`, * `_voltro_notification_quiet_hours`, `_voltro_notification_held`. This is the * 14 670-row overlap the seam was reported for; note it does NOT solve an rpc * name collision — `alias` is the field for that. * * It is not offered on every plugin, and the omissions are deliberate rather * than unfinished: a `tables: false` that quietly disables a table carrying an * AUTHORIZATION or SAFETY decision is a security regression shipped as an * ergonomics feature. The SAML replay cache, SCIM provisioning state, * billing's usage counters and cdc-out's outbox are EXAMPLES, not the whole * list — read "not offered on that plugin" as the answer, never "so every * other plugin's tables are safe to take over". Those plugins need a named * store seam first, not a boolean. * * `plugin-storage` is the case that reads like an oversight and is not one. * `_voltro_storage_grants` decides who may read and write an object, so it * belongs to the paragraph above — but the field would not reach it in any * case: storage's four tables are contributed as FRAMEWORK tables * (`cli/src/frameworkTables.ts`, `when: 'always'`), not through `extendSchema`, * so there is nothing for a `tables: false` to switch off. An app that wants * to own the grant table needs a grant-store seam, and that seam does not * exist yet. */ readonly tables?: boolean; /** * Discriminator for a SECOND installation of this plugin, when one app runs * two (`@voltro/plugin-notifications#analytics`). Not a rename — for that use * `alias`. */ readonly name?: string; } export declare interface NotificationStore { readonly addInbox: (item: Omit & { id: string; createdAt: string; }) => Promise; readonly listInbox: (subjectId: string, opts?: { unreadOnly?: boolean; limit?: number; }) => Promise>; readonly markRead: (id: string, subjectId: string) => Promise; /** Clear the read stamp — an inbox needs the way back, not just forward. */ readonly markUnread: (id: string, subjectId: string) => Promise; /** Read EVERYTHING for this subject. Returns how many rows changed. */ readonly markAllRead: (subjectId: string) => Promise; /** Archive / unarchive — a different state from read, and the one that * actually empties the inbox. */ readonly setArchived: (id: string, subjectId: string, archived: boolean) => Promise; readonly unreadCount: (subjectId: string) => Promise; readonly getPreferences: (subjectId: string) => Promise>; readonly setPreference: (pref: ChannelPreference) => Promise; readonly recordDelivery: (record: DeliveryRecord) => Promise; readonly listDeliveries: (opts?: { limit?: number; }) => Promise>; /** Stamp `clickedAt` on the delivery record carrying this click token. * Constant-time token match happens in the caller; this is the lookup + * write. Returns false when no record carries the token. */ readonly markClicked: (clickToken: string) => Promise; readonly subscribeTopic: (sub: TopicSubscription) => Promise; readonly unsubscribeTopic: (topic: string, subjectId: string) => Promise; readonly listTopicSubscribers: (topic: string) => Promise>; readonly getQuietHours: (subjectId: string) => Promise; readonly setQuietHours: (qh: QuietHours) => Promise; readonly clearQuietHours: (subjectId: string) => Promise; readonly enqueueHeld: (held: HeldNotification) => Promise; /** Pending digest holds for a subject whose window has NOT yet elapsed — the * coalescing check: if any exist, this send joins the open window instead of * opening a new one. */ readonly pendingDigest: (subjectId: string) => Promise>; /** Every held notification now due (`flushAt <= now`), oldest-first — the * scheduled flush drains these and clears them. */ readonly dueHeld: (now: string) => Promise>; readonly clearHeld: (ids: ReadonlyArray) => Promise; } export declare const preferencesTable: Table<"_voltro_notification_preferences", FieldDefinitions<{ readonly id: ColumnBuilder; readonly subjectId: ColumnBuilder; readonly category: ColumnBuilder; readonly channel: ColumnBuilder; readonly enabled: ColumnBuilder; }>, true, never>; /** A first-class push channel (APNs / FCM shape) — the built-in alternative to * hand-rolling `customChannel` for mobile push. You supply two seams: * * - `tokensFor(subjectId)` → the subject's device tokens (from your own * device-registration table); a subject with no tokens is a no-op deliver. * - `transport(payload)` → hand ONE formatted `PushPayload` to APNs / FCM / * Expo. Throw `PushTokenRejected({ token, reason })` on an unregistered / * invalid token so the fan-out records the delivery `failed` and your app * can prune the token. The push AUTH secret (APNs key / FCM server key) * lives in YOUR transport closure — it never enters this package and is * never logged. * * `deliver` formats the message into a `PushPayload` per token and sends each; * a rejected token surfaces as a `PushTokenRejected` (naming the token, not the * secret). Default `id` is `'push'`. */ export declare const pushChannel: (opts: { readonly id?: string; readonly tokensFor: (subjectId: string) => Promise>; readonly transport: (payload: PushPayload) => Promise; /** Called when the transport threw `PushTokenRejected` for one token — the * app's prune hook (this channel's registry is the APP's table, so the app * deletes; web push, whose registry is the plugin's own, prunes itself). */ readonly onTokenRejected?: (token: string, reason: string) => Promise; }) => Channel; /** The provider-agnostic push payload `pushChannel` builds per device token — * the shape an APNs `aps` / FCM `notification` transport consumes. */ export declare interface PushPayload { readonly token: string; readonly title: string; readonly body: string; /** Best-effort unread/badge count if the app supplies it via `msg.data.badge`. */ readonly badge?: number; /** The message `data` bag, forwarded as the push data/custom section. */ readonly data: Record; } /** A browser push subscription's crypto material, as `PushSubscription.toJSON()` * hands it to the page: base64url `p256dh` (the UA public key) + `auth`. */ export declare interface PushSubscriptionKeys { readonly p256dh: string; readonly auth: string; } export declare interface PushSubscriptionRow { readonly subjectId: string; readonly endpoint: string; readonly endpointHash: string; readonly p256dh: string; readonly auth: string; readonly ua: string | null; readonly tenantId: string | null; } export declare const pushSubscriptionsTable: Table<"_voltro_notification_push_subscriptions", FieldDefinitions<{ readonly id: ColumnBuilder; readonly subjectId: ColumnBuilder; /** sha256 hex of `endpoint` — the unique key (endpoint URLs can exceed the * unique-index byte ceiling on mssql/mysql, so the hash carries the * constraint and the URL rides as plain text). */ readonly endpointHash: ColumnBuilder; readonly endpoint: ColumnBuilder; /** Browser subscription crypto material (`PushSubscription.toJSON().keys`). */ readonly p256dh: ColumnBuilder; readonly auth: ColumnBuilder; readonly ua: ColumnBuilder; readonly createdAt: ColumnBuilder; readonly tenantId: ColumnBuilder; }>, true, "byPushSubject">; export declare interface PushSubscriptionStore { /** Idempotent per endpoint: a re-subscribe (same browser, possibly a new * logged-in subject) takes the row over — the endpoint belongs to the * browser profile, and the latest authenticated subject is its owner. */ readonly upsert: (row: PushSubscriptionRow) => Promise; readonly listForSubject: (subjectId: string) => Promise>; /** Unsubscribe — scoped to the CALLING subject so one subject cannot detach * another's browser. */ readonly removeForSubject: (endpointHash: string, subjectId: string) => Promise; /** Prune — unconditional by hash: the push service said the endpoint is gone, * whoever it belonged to. */ readonly removeByHash: (endpointHash: string) => Promise; readonly countForSubject: (subjectId: string) => Promise; } /** A push transport rejected a device token (unregistered / invalid / expired — * the APNs `Unregistered` / FCM `UNREGISTERED` class). Thrown by `pushChannel`'s * `deliver` so the fan-out records the delivery `failed` with the token that was * rejected — the app can prune it from its device-token table. `token` carries * the rejected token itself; a push AUTH secret (APNs key / FCM server key) is * never part of this error and is never logged. */ export declare class PushTokenRejected extends PushTokenRejected_base { } declare const PushTokenRejected_base: Schema.TaggedErrorClass; } & { token: typeof Schema.String; reason: typeof Schema.String; }>; /** A per-subject Do-Not-Disturb window, expressed in minutes-of-day in the * subject's `tz` (an IANA zone). A window may WRAP midnight (`startMinute` > * `endMinute`, e.g. 22:00→08:00). `policy` decides what a send during the * window does: `hold` (default — deferred and delivered after the window) or * `drop` (silently discarded). */ export declare interface QuietHours { readonly subjectId: string; /** Minutes past local midnight the DND window opens (0–1439). */ readonly startMinute: number; /** Minutes past local midnight the DND window closes (0–1439). */ readonly endMinute: number; /** IANA timezone the window is evaluated in (e.g. 'Europe/Berlin'). Default 'UTC'. */ readonly tz?: string; /** What a send during the window does. Default 'hold'. */ readonly policy?: 'hold' | 'drop'; } /** The next instant the DND window CLOSES, at or after `at` — where a * held-during-quiet-hours notification becomes deliverable. */ export declare const quietHoursEnd: (qh: QuietHours, at: Date) => Date; export declare const quietHoursTable: Table<"_voltro_notification_quiet_hours", FieldDefinitions<{ readonly id: ColumnBuilder; readonly subjectId: ColumnBuilder; readonly startMinute: ColumnBuilder; readonly endMinute: ColumnBuilder; readonly tz: ColumnBuilder; readonly policy: ColumnBuilder; }>, true, never>; /** Test seam. */ export declare const resetWebPushStoreForTest: () => void; /** * Resolve which channel ids to deliver on. Order: caller-requested `channels` * (intersected with configured) ELSE all configured; then drop any the * subject has turned OFF for this category. A missing preference = on. */ export declare const resolveChannels: (configured: ReadonlyArray, category: string, requested: ReadonlyArray | undefined, prefs: ReadonlyArray, subjectId: string) => ReadonlyArray; /** What a caller asks to send. `channels` overrides the default routing. */ export declare interface SendInput { readonly to: string; readonly category: string; readonly title: string; readonly body: string; readonly data?: Record; readonly channels?: ReadonlyArray; readonly tenantId?: string | null; } export declare interface SendResult { readonly delivered: ReadonlyArray; readonly failed: ReadonlyArray<{ channel: string; error: string; }>; readonly skipped: ReadonlyArray; /** Set when the send did NOT go out immediately: `digest` (joined/opened a * rollup window), `quiet-held` (deferred past a DND window), or `quiet-drop` * (discarded per the `drop` policy). Absent on an immediate delivery. */ readonly held?: 'digest' | 'quiet-held' | 'quiet-drop'; } /** POST one encrypted payload to one subscription's push service. */ export declare const sendWebPush: (target: WebPushTarget, payload: Buffer, options: WebPushSendOptions) => Promise; /** SMS via a generic REST sender (Twilio etc.) — app supplies the send fn. */ export declare const smsChannel: (send: (to: string, body: string) => Promise) => Channel; /** A subscription of a subject to a broadcast topic. `broadcast(topic, …)` fans * a single send out to every subscriber of that topic. */ export declare interface TopicSubscription { readonly topic: string; readonly subjectId: string; readonly tenantId?: string | null; } export declare const topicSubscriptionsTable: Table<"_voltro_notification_topic_subscriptions", FieldDefinitions<{ readonly id: ColumnBuilder; readonly topic: ColumnBuilder; readonly subjectId: ColumnBuilder; readonly tenantId: ColumnBuilder; }>, true, "byTopic">; /** * The `Authorization: vapid t=, k=` header value for one push-service * origin (RFC 8292). `aud` is the ENDPOINT's origin (scheme+host), `exp` 12h * out (the spec ceiling is 24h), `sub` the operator contact when configured — * push services use it to reach whoever is sending, so it is worth setting. */ export declare const vapidAuthorization: (endpoint: string, privateKeyB64url: string, contact?: string, nowMs?: number) => string; /** The browser-facing application server key (65-byte uncompressed point, * base64url) derived from the private scalar. Throws on a malformed key — * a wrong VAPID key must fail the boot, not the first delivery. */ export declare const vapidPublicKeyFor: (privateKeyB64url: string) => string; /** POST the message to an incoming webhook (Slack / Discord / Teams / generic). * `format` shapes the JSON body; default Slack-style `{ text }`. */ export declare const webhookChannel: (opts: { readonly id?: string; readonly url: string; readonly format?: (msg: ChannelMessage) => unknown; readonly headers?: Record; }) => Channel; /** * The Web Push channel. Delivery is per endpoint and isolated — the fan-out * records one `EndpointDelivery` per registered endpoint, prunes on `gone`, * and never lets one endpoint's failure abort another's send. */ export declare const webPushChannel: (options?: WebPushChannelOptions) => Channel; export declare interface WebPushChannelOptions { /** Override the channel id (prefs key). Default `webPush`. */ readonly id?: string; /** The VAPID private scalar. Default: `VOLTRO_VAPID_PRIVATE_KEY` from the * environment, read at delivery time (env files load after config). */ readonly privateKey?: string; /** VAPID `sub` contact (`mailto:`/https) push services may use to reach the * operator. Strongly recommended for production. */ readonly contact?: string; /** Push-service retention (TTL header), seconds. Default 24h. */ readonly ttl?: number; /** Test seam — injected into `sendWebPush`. */ readonly fetchImpl?: typeof fetch; } export declare type WebPushOutcome = { readonly kind: 'delivered'; readonly status: number; } /** 404/410 — the subscription no longer exists at the push service. The ONE * signal a registry may prune on. */ | { readonly kind: 'gone'; readonly status: number; } | { readonly kind: 'failed'; readonly status: number; readonly detail: string; }; /** The browser-facing application server key for the configured private key. */ export declare const webPushPublicKey: (explicit?: string) => string; export declare interface WebPushSendOptions { readonly privateKey: string; /** VAPID `sub` claim — `mailto:` or https URL the push service can reach the * operator at. */ readonly contact?: string; /** Push-service retention for an undeliverable message, seconds. Default 24h. */ readonly ttl?: number; /** Injectable for the mock-endpoint tests. */ readonly fetchImpl?: typeof fetch; } export declare interface WebPushTarget { readonly endpoint: string; readonly keys: PushSubscriptionKeys; } export { }