@lunora/notify 1.0.0-alpha.6 → 1.0.0-alpha.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -75,14 +75,56 @@ type RegisterInput = {
75
75
  };
76
76
  /** Filter narrowing which stored subscriptions a `list`/`broadcast` targets. */
77
77
  interface SubscriptionFilter {
78
+ /**
79
+ * Keyset pagination cursor: return only rows with `id` strictly GREATER
80
+ * than this value, ordered ascending by `id`. `id` is a stable,
81
+ * content-derived hash (see `webPushId`/`fcmId`), so ordering by it is
82
+ * immune to concurrent inserts/deletes elsewhere in the table — a page
83
+ * already walked never re-delivers or skips a row when another device
84
+ * registers mid-broadcast (the reviewer-flagged "stable under concurrent
85
+ * registers" property). `broadcastPage`/`broadcast` set this internally to
86
+ * walk pages; a direct `list()` caller may also page through results with
87
+ * it.
88
+ *
89
+ * OPTIONAL for a reason: `SubscriptionStore` is implementable outside this
90
+ * package. An external store that does not support cursoring may ignore
91
+ * `after` entirely and keep returning its (from-the-top) unpaged result —
92
+ * `broadcastPage` defensively re-filters whatever the store returns down
93
+ * to `id > after` itself, so a non-cursoring store can never cause a
94
+ * double-send or an infinite page-walk (each page's result only ever
95
+ * contains ids the previous page didn't already deliver), but it also
96
+ * cannot deliver the FULL matched audience beyond whatever the store's own
97
+ * (unpaged) response window happens to contain — implement `after`
98
+ * (ordered ascending by `id`, exclusive) to get real, complete pagination
99
+ * over a large audience.
100
+ */
101
+ after?: string;
78
102
  /** Restrict to a delivery kind. */
79
103
  kind?: SubscriptionKind;
80
104
  /**
81
105
  * Cap the number of rows returned (a `LIMIT`). Applied server-side by the
82
106
  * store, so a large audience never materializes wholesale in the isolate.
83
107
  * A non-positive/absent value means "no cap"; a fractional value is truncated.
84
- * `broadcast` deliberately leaves this unset (it must reach every matched
85
- * device); admin/list reads set it to bound the page.
108
+ *
109
+ * For `list`/admin reads this bounds the returned page as before. For
110
+ * `broadcast`, this is now an OVERALL cap on the total number of
111
+ * subscriptions reached across every internally-walked page (still
112
+ * deliberately left unset by default — it must reach every matched
113
+ * device); the PER-PAGE batch size is a separate, independent knob (see
114
+ * `defineNotify`'s `broadcastPageSize`, default 250) so a caller
115
+ * that sets `limit` to bound the audience doesn't also have to reason
116
+ * about page sizing.
117
+ *
118
+ * The non-positive sentinel means something different at each layer: at
119
+ * the STORE layer (`list`, and the `d1-store`/`memory-store` fetch-size
120
+ * hint) a non-positive `limit` means "no cap" — fetch everything. At the
121
+ * `broadcast`/`broadcastPage` layer, where `limit` is an AUDIENCE cap, a
122
+ * non-positive value instead means "no deliveries" — `broadcast({ limit: 0
123
+ * })` reaches nobody, not everybody. This asymmetry is deliberate: the two
124
+ * layers answer different questions ("how many rows to fetch" vs. "how
125
+ * many recipients to reach"), and unifying them would either break `list`
126
+ * callers relying on "no cap" or reintroduce the over-delivery this
127
+ * distinction fixes (see `broadcastPage`'s doc comment).
86
128
  */
87
129
  limit?: number;
88
130
  /** Restrict to a single owning user. */
@@ -96,13 +138,69 @@ interface SubscriptionFilter {
96
138
  interface SubscriptionStore {
97
139
  /** Remove a subscription by id (idempotent). */
98
140
  delete: (id: string) => Promise<void>;
141
+ /**
142
+ * Remove a subscription by id ONLY if it is owned by `userId`, and report
143
+ * whether it was.
144
+ *
145
+ * Separate from {@link SubscriptionStore.delete} because the caller-facing
146
+ * `unregister` must not be a read followed by a write: between a `get` that
147
+ * checks the owner and a `delete` that acts on it, a re-registration can
148
+ * replace the row, so the check passes for one owner and the removal lands on
149
+ * another's subscription.
150
+ *
151
+ * **The predicate and the removal must be ONE operation.** A store that
152
+ * cannot do that atomically should say so in its own documentation rather
153
+ * than implement this as a get-then-delete, which reintroduces the race this
154
+ * method exists to remove. Both shipped stores manage it: the in-memory one
155
+ * because a `Map` check-and-delete has no await between the two, and the D1
156
+ * one with a single `DELETE … WHERE id = ? AND user_id = ? RETURNING id`.
157
+ *
158
+ * `userId` is `null` for an anonymous subscription, and matches only a row
159
+ * that is itself unowned.
160
+ * @param id The subscription id.
161
+ * @param userId The owner the row must carry, or `null` for unowned.
162
+ * @returns `true` when a row was removed.
163
+ */
164
+ deleteOwned: (id: string, userId: string | null) => Promise<boolean>;
99
165
  /** Read a subscription by id, or `undefined`. */
100
166
  get: (id: string) => Promise<StoredSubscription | undefined>;
101
- /** List subscriptions, optionally filtered. */
167
+ /**
168
+ * List subscriptions, optionally filtered. When `filter.after` is set,
169
+ * results are keyset-paginated: only rows with `id` strictly greater than
170
+ * `filter.after` are returned, ordered ascending by `id`. Implementing
171
+ * `after` is OPTIONAL (see {@link SubscriptionFilter.after}) — a store
172
+ * that ignores it may keep returning its unpaged result.
173
+ */
102
174
  list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
103
175
  /** Record the latest delivery outcome for a subscription (best-effort). */
104
176
  markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
105
- /** Insert or update a subscription (upsert by id). */
177
+ /**
178
+ * Insert or update a subscription (upsert by id), refusing to move an
179
+ * existing row to a DIFFERENT owner.
180
+ *
181
+ * The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
182
+ * carries, and for the same reason: the id is derived from the endpoint or the
183
+ * FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
184
+ * caller who could guess or observe another user's endpoint re-register it
185
+ * under their own `userId` with keys of their choosing — the victim's device
186
+ * then fails every send (an encryption failure is not a gone signal, so it is
187
+ * never pruned either) and the attacker can `unregister` it as their own. The
188
+ * `unregister` guard alone closed exactly half of that (CWE-639).
189
+ *
190
+ * A row with no owner is claimable (the device signed in), and a row the caller
191
+ * already owns is theirs to refresh — that is the routine service-worker
192
+ * re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
193
+ * no-op: unlike `unregister` there is nothing safe to return, since a caller
194
+ * gets the stored record back and it would be someone else's delivery keys.
195
+ * A shared device that legitimately changes hands is handled by its current
196
+ * owner calling `unregister` first.
197
+ *
198
+ * **The predicate and the write must be ONE operation**, as for `deleteOwned`:
199
+ * between a read that checks the owner and the write that acts on it, another
200
+ * registration can replace the row. The D1 store puts the predicate in the
201
+ * `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
202
+ * between the two.
203
+ */
106
204
  put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
107
205
  }
108
206
  /** Per-recipient outcome from a fan-out `broadcast`. */
@@ -127,6 +225,18 @@ interface BroadcastResult {
127
225
  /** Total subscriptions attempted. */
128
226
  total: number;
129
227
  }
228
+ /**
229
+ * Result of `broadcastPage` — one bounded page of a fan-out, plus the cursor
230
+ * to fetch the next page. `nextCursor` is `undefined` when this was the last
231
+ * page (or the store doesn't support cursoring — see
232
+ * {@link SubscriptionFilter.after}'s documented unpaged fallback).
233
+ */
234
+ interface BroadcastPageResult {
235
+ /** Cursor for the next page (pass as `filter.after`), or `undefined` when done. */
236
+ nextCursor?: string;
237
+ /** The delivery outcome for just this page. */
238
+ result: BroadcastResult;
239
+ }
130
240
  /**
131
241
  * The compact, stable delivery-status vocabulary emitted on notify observability
132
242
  * signals — the `status` dimension on the `notify.send` metric and the failure
@@ -134,7 +244,7 @@ interface BroadcastResult {
134
244
  *
135
245
  * - `accepted` — the provider took the message (a `Receipt.successful` send).
136
246
  * - `failed` — a provider error; the log line carries the `error` text.
137
- * - `gone` — the endpoint is unregistered (404/410, FCM `UNREGISTERED`) and pruned; push-only.
247
+ * - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
138
248
  *
139
249
  * Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
140
250
  * send attempt: a `delivered`/`opened` status would be a lie for these channels.
@@ -179,24 +289,90 @@ interface LunoraPush {
179
289
  /**
180
290
  * Fan-out a push to every stored subscription matching `filter` (default: all).
181
291
  * Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
182
- * the push service reports as gone (HTTP 404/410, FCM `UNREGISTERED`). The `to`
292
+ * the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
183
293
  * target is derived from each subscription, so it is omitted from the payload.
294
+ *
295
+ * Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
296
+ * keyset-paginated on the subscription `id`) so the audience ROWS are never
297
+ * materialized wholesale in the isolate — see `defineNotify`'s
298
+ * `broadcastPageSize`. The returned `outcomes` ARE whole-audience, though: one
299
+ * `{ id, status }` per recipient accumulates across every page, so this call is
300
+ * bounded in rows held but not in outcomes reported. It also processes the WHOLE
301
+ * matched audience in one request/queue message; use {@link LunoraPush.broadcastPage}
302
+ * directly (as `runPushBroadcastPage` does) to bound a single queue message —
303
+ * and its result — to one page.
184
304
  */
185
305
  broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
306
+ /**
307
+ * Fan-out a push to ONE bounded page of stored subscriptions matching
308
+ * `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
309
+ * 250, capped by `filter.limit` when set). Same delivery semantics as
310
+ * {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
311
+ * scoped to a single page; returns the page's own {@link BroadcastResult}
312
+ * plus a `nextCursor` to fetch the next page (`undefined` when done).
313
+ * Backs `runPushBroadcastPage` so one queue message does bounded work
314
+ * regardless of audience size — most app code should call
315
+ * {@link LunoraPush.broadcast} instead.
316
+ */
317
+ broadcastPage: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastPageResult>;
186
318
  /**
187
319
  * List stored subscriptions (optionally filtered), with the delivery
188
320
  * **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
189
321
  * FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
190
- * a device, so they never cross the app-facing facade; the raw rows are
322
+ * a device, so no READ on this facade returns them; the raw rows are otherwise
191
323
  * reachable only through the internal `SubscriptionStore`.
324
+ *
325
+ * {@link LunoraPush.register} is the one exception, and deliberately so: it
326
+ * echoes back the record the caller just supplied, so it discloses nothing
327
+ * the caller did not already hold and never another device's row.
192
328
  */
193
329
  list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
194
- /** Register (upsert) a device subscription and return the stored record. */
330
+ /**
331
+ * Register (upsert) a device subscription and return the stored record (the
332
+ * caller's own row, secrets included).
333
+ *
334
+ * Owner-scoped, exactly as {@link LunoraPush.unregister} is: registering an
335
+ * endpoint that is already another user's row is REFUSED (`FORBIDDEN`) rather
336
+ * than re-owning it. Pass `ctx.auth?.userId` so the check has something to
337
+ * separate; an app that registers every device anonymously gets no separation
338
+ * from it (every row is unowned, and unowned rows stay claimable). A device
339
+ * that legitimately changes hands — one browser profile, two accounts —
340
+ * unregisters as its current owner first.
341
+ */
195
342
  register: (input: RegisterInput) => Promise<StoredSubscription>;
196
343
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
197
344
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
198
- /** Remove a subscription by id (idempotent). */
199
- unregister: (id: string) => Promise<void>;
345
+ /**
346
+ * Remove ONE of `owner`'s subscriptions by id (idempotent).
347
+ *
348
+ * `owner` is not optional, and the removal happens only when the stored row
349
+ * carries that same owner. A subscription id is derived from the endpoint
350
+ * (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
351
+ * intended call is a mutation forwarding `subscribeToPush`'s
352
+ * `replacedEndpoint` after a VAPID rotation, and nothing about that argument
353
+ * proves the browser sending it ever held the subscription it names.
354
+ * Deleting by id alone let any caller that could guess or observe another
355
+ * user's endpoint silence that device's notifications (CWE-639).
356
+ *
357
+ * A row belonging to someone else is left alone SILENTLY rather than
358
+ * refused, so the call cannot be used to probe which endpoints exist — the
359
+ * same answer, and the same absence of a write, as an id that was never
360
+ * registered.
361
+ *
362
+ * `{ userId: null }` (or `undefined`, which normalises to it) addresses the
363
+ * anonymous rows — those registered with no `userId`. An app that registers
364
+ * every device anonymously therefore gets no separation from this check;
365
+ * pass `ctx.auth?.userId` and register with it to get any.
366
+ */
367
+ unregister: (id: string, owner: PushOwner) => Promise<void>;
368
+ }
369
+ /** Who a {@link LunoraPush.unregister} call is acting as. */
370
+ interface PushOwner {
371
+ /**
372
+ * The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
373
+ * an anonymous registration. Required — see {@link LunoraPush.unregister}.
374
+ */
375
+ userId: string | null | undefined;
200
376
  }
201
377
  /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
202
378
  type PushContent = Omit<PushPayload, "to">;
@@ -207,7 +383,7 @@ type PushContent = Omit<PushPayload, "to">;
207
383
  * single-channel convenience senders for the edge-safe channels.
208
384
  */
209
385
  interface LunoraNotify {
210
- /** Send an outbound webhook. */
386
+ /** Post to a chat channel (Slack/Discord/Teams/Telegram). */
211
387
  chat: (payload: ChatPayload) => Promise<Receipt>;
212
388
  /** Deliver an in-app inbox notification. */
213
389
  inApp: (payload: InAppPayload) => Promise<Receipt>;
@@ -215,7 +391,7 @@ interface LunoraNotify {
215
391
  push: LunoraPush;
216
392
  /** Deliver a multi-channel message (one payload per channel). */
217
393
  send: (message: NotificationMessage) => Promise<Receipt[]>;
218
- /** Post to a chat channel (Slack/Discord/Teams/Telegram). */
394
+ /** Send an outbound webhook. */
219
395
  webhook: (payload: WebhookPayload) => Promise<Receipt>;
220
396
  }
221
397
  /**
@@ -243,11 +419,32 @@ interface NotifyConfig {
243
419
  * wildcards) to hard-pin the boundary and close DNS rebinding.
244
420
  */
245
421
  allowedPushOrigins?: string[];
422
+ /**
423
+ * Page size for `push.broadcast`'s internal keyset pagination over the
424
+ * subscription store (default 250, minimum 1). Each page is fetched,
425
+ * delivered, and counted before the next page's store round trip, so a huge
426
+ * audience is never materialized wholesale in the isolate. Also the
427
+ * per-message bound `push.broadcastPage` (and `runPushBroadcastPage`) uses.
428
+ *
429
+ * Declared here, and not only on `createNotify`'s third argument, because
430
+ * this file is the only handle an app has: the sole production constructor is
431
+ * codegen's fixed `createNotify(definition, env, { log, metrics })`, so a knob
432
+ * that lives only on those options is unsettable by every Lunora app —
433
+ * while {@link SubscriptionFilter.limit}'s own docs point at it as the way to
434
+ * size pages.
435
+ */
436
+ broadcastPageSize?: number;
246
437
  /**
247
438
  * Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
248
439
  * provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
249
440
  */
250
441
  chat?: (env: NotifyEnv) => unknown;
442
+ /**
443
+ * Max concurrent sends during a `push.broadcast` (default 10, minimum 1).
444
+ * Same reasoning as {@link NotifyConfig.broadcastPageSize}: this is where an
445
+ * app can reach it.
446
+ */
447
+ concurrency?: number;
251
448
  /** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
252
449
  fcm?: FcmConfig | FcmConfigFactory;
253
450
  /** Optional in-app inbox provider factory. Edge-safe. */
@@ -333,7 +530,21 @@ declare const defineNotify: (config: NotifyConfig) => NotifyDefinition;
333
530
  declare const isNotifyDefinition: (value: unknown) => value is NotifyDefinition;
334
531
  /** Options for {@link createNotify}. */
335
532
  interface CreateNotifyOptions {
336
- /** Max concurrent sends during a `broadcast` (default 10). */
533
+ /**
534
+ * Page size for `push.broadcast`'s internal keyset pagination over the
535
+ * subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
536
+ *
537
+ * A test/tuning seam only. **Apps set `broadcastPageSize` on `defineNotify`
538
+ * instead** — the sole production call is codegen's fixed
539
+ * `createNotify(definition, env, { log, metrics })`, so nothing an app writes
540
+ * reaches this object. Set here it wins over the definition's value.
541
+ */
542
+ broadcastPageSize?: number;
543
+ /**
544
+ * Max concurrent sends during a `broadcast` (default 10). Test/tuning seam;
545
+ * apps set `concurrency` on `defineNotify` — see
546
+ * {@link CreateNotifyOptions.broadcastPageSize}.
547
+ */
337
548
  concurrency?: number;
338
549
  /**
339
550
  * Override the assembled `@visulima/notification` engine. Advanced/testing
@@ -377,6 +588,11 @@ declare const createNotify: (definition: NotifyDefinition, env: NotifyEnv, optio
377
588
  };
378
589
  /** Options for {@link routingPushProvider}. */
379
590
  interface RoutingPushOptions {
591
+ /**
592
+ * The definition's exact-origin allowlist, when configured. Its presence
593
+ * disables the send-time rebinding re-check (see {@link assertPushTargetResolvable}).
594
+ */
595
+ allowedPushOrigins?: string[];
380
596
  fcm?: Provider<unknown, PushPayload>;
381
597
  webPush?: Provider<unknown, PushPayload>;
382
598
  }
@@ -390,6 +606,8 @@ interface RoutingPushOptions {
390
606
  declare const routingPushProvider: (options: RoutingPushOptions) => Provider<unknown, PushPayload>;
391
607
  /** A resolved, ready-to-wire set of channel configs (edge-safe channels only). */
392
608
  interface ResolvedProviders {
609
+ /** The definition's `allowedPushOrigins`, threaded to the push router's send-time SSRF guard. */
610
+ allowedPushOrigins?: string[];
393
611
  chat?: Provider;
394
612
  fcm?: FcmConfig;
395
613
  inApp?: Provider;
@@ -407,53 +625,147 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
407
625
  * A broadcast job body — the JSON-serialisable payload enqueued for off-request
408
626
  * fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
409
627
  * `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
628
+ * `filter.after`, when set, resumes a broadcast partway through (see
629
+ * {@link runPushBroadcastPage}'s continuation semantics).
410
630
  */
411
631
  interface PushBroadcastJob {
412
- /** Subscription filter (which devices/users to target). */
632
+ /** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
413
633
  filter?: SubscriptionFilter;
414
634
  /** The push payload to deliver (the `to` target is derived per subscription). */
415
635
  payload: PushContent;
636
+ /**
637
+ * Redeliver to exactly these subscription ids instead of walking a page —
638
+ * an earlier page's {@link PushBroadcastPageOutcome.failedIds}. Set by the
639
+ * consumer when it re-enqueues a page's transient failures; `filter` is
640
+ * ignored on such a job. See {@link runPushBroadcastPage}.
641
+ */
642
+ retryIds?: string[];
416
643
  /** Discriminator so a shared queue can multiplex message kinds. */
417
644
  type: "lunora.push.broadcast";
418
645
  }
419
- /** The structural slice of a `@lunora/queue` producer (`ctx.queues.&lt;name>`) used here. */
646
+ /**
647
+ * One page's outcome plus the ids that need redelivering.
648
+ *
649
+ * The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
650
+ * `failedIds` redelivers the recipients this page missed. Acking a message while
651
+ * ignoring either silently drops part of the audience.
652
+ */
653
+ interface PushBroadcastPageOutcome {
654
+ /**
655
+ * Subscriptions that failed transiently on this run (gone/pruned devices are
656
+ * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
657
+ * as `retryIds` to redeliver to just them.
658
+ */
659
+ failedIds: string[];
660
+ /**
661
+ * The filter for the CONTINUATION job, or `undefined` when the broadcast is
662
+ * finished (no further pages, or `filter.limit` is spent). Enqueue it
663
+ * verbatim — it carries the next page's cursor AND, when the job set
664
+ * `filter.limit`, the REMAINING budget.
665
+ *
666
+ * This replaces the raw `nextCursor` the runner used to return. Rebuilding
667
+ * the filter at the call site (`{ ...job.filter, after: nextCursor }`)
668
+ * forwarded the ORIGINAL `limit` to every message, so a `limit` documented
669
+ * as an overall audience cap (see {@link SubscriptionFilter.limit}, which
670
+ * `broadcast` honours as one) became a per-message cap and the walk reached
671
+ * the entire audience anyway.
672
+ */
673
+ nextFilter?: SubscriptionFilter;
674
+ /** This page's delivery result. */
675
+ result: BroadcastResult;
676
+ }
677
+ /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
420
678
  interface QueueProducerLike {
421
679
  send: (body: PushBroadcastJob) => Promise<void>;
422
680
  }
423
681
  /**
424
682
  * Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
425
- * queue instead of blocking the request. Pair with {@link runPushBroadcastJob} in
426
- * the queue consumer.
683
+ * queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
684
+ * the queue consumer — see its doc comment for how a large audience continues
685
+ * across MULTIPLE messages (one bounded page per message), not one.
427
686
  *
428
687
  * ```ts
429
- * // in a mutation/action:
430
- * await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
688
+ * // lunora/notify-fanout.ts an INTERNAL ACTION, because that is where
689
+ * // `ctx.push` and `ctx.queues` exist.
690
+ * import { internalAction, v } from "./_generated/server";
691
+ * import { enqueuePushBroadcast, runPushBroadcastPage } from "@lunora/notify";
431
692
  *
432
- * // in lunora/queues.ts consumer:
433
- * export const push = defineQueue({ async handler(batch, ctx) {
434
- * for (const message of batch.messages) await runPushBroadcastJob(ctx.push, message.body);
435
- * }});
693
+ * export const deliverPage = internalAction
694
+ * .input({ job: v.any() })
695
+ * .action(async ({ args: { job }, ctx }) => {
696
+ * const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
697
+ *
698
+ * if (nextFilter !== undefined) {
699
+ * // More pages remain — enqueue the continuation. Each message still
700
+ * // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
701
+ * // it carries the cursor and the remaining `limit` budget.
702
+ * await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
703
+ * }
704
+ *
705
+ * if (failedIds.length > 0) {
706
+ * // Redeliver ONLY the recipients that failed — never the whole page.
707
+ * await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
708
+ * }
709
+ * });
710
+ *
711
+ * // lunora/queues.ts — the consumer itself has NO ctx.push / ctx.queues: a
712
+ * // `QueueRunContext` is exactly `{ env, log, run }` (handler signature
713
+ * // `(context, batch)`, in that order). It hands each message to the action above.
714
+ * export const push = defineQueue<PushBroadcastJob>({
715
+ * handler: async (context, batch) => {
716
+ * for (const message of batch.messages) {
717
+ * await message.run(internal.notifyFanout.deliverPage, { job: message.body });
718
+ * message.ack();
719
+ * }
720
+ * },
721
+ * });
722
+ *
723
+ * // in a mutation/action, to start it:
724
+ * await enqueuePushBroadcast(ctx.queues.push, { payload: { body: "…", title: "New drop" } });
436
725
  * ```
437
726
  */
438
727
  declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
439
728
  /**
440
- * Run an enqueued broadcast job on the consumer side, delivering through the push
441
- * facade (which reuses the engine's retry + circuit-breaker middleware and prunes
442
- * gone subscriptions).
729
+ * Run ONE bounded page of an enqueued broadcast job on the consumer side,
730
+ * delivering through the push facade's {@link LunoraPush.broadcastPage} (which
731
+ * reuses the engine's retry + circuit-breaker middleware and prunes gone
732
+ * subscriptions).
733
+ *
734
+ * RETRY / CONTINUATION SEMANTICS:
443
735
  *
444
- * RETRY SEMANTICS: retry is gated on `failed` the count of TRANSIENT delivery
445
- * errors (a provider 5xx / network fault worth another attempt). When at least one
446
- * recipient `failed`, the job is RE-THROWN so the queue does NOT ack it and its
447
- * normal retry/backoff (and, on exhaustion, dead-letter) applies. A broadcast with
448
- * zero `failed` resolves and is acked this includes the all-`pruned` case (every
449
- * device had unsubscribed: `sent:0`, `failed:0`, `pruned:N`), which is a SUCCESSFUL
450
- * prune, not a failure, so throwing on it would spuriously retry and pressure the
451
- * DLQ; and the empty audience (zero `total`), which has nothing to retry. Note a
452
- * retry re-runs the WHOLE broadcast, re-sending to the already-delivered recipients
453
- * (broadcast is not idempotent) the accepted cost of getting the transiently
454
- * failed ones redelivered.
736
+ * - A job processes exactly ONE bounded page (see `defineNotify`'s
737
+ * `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
738
+ * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
739
+ * so per-message work is bounded regardless of total audience size.
740
+ * - A page NEVER throws for a partial failure. Throwing discarded the page's
741
+ * continuation, which is the only way the broadcast advances: one device that
742
+ * fails permanently (a rotated VAPID keypair leaves a stale device answering
743
+ * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
744
+ * every already-delivered recipient on each retry, dead-letter, and leave
745
+ * every LATER page unreached. The page's `nextFilter` and its `failedIds`
746
+ * both come back instead.
747
+ * - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
748
+ * `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
749
+ * do it itself — it has no `@lunora/queue` dependency (the seam stays
750
+ * structural) and no reference to the producer that enqueued this message. See
751
+ * the consumer example on {@link enqueuePushBroadcast}.
752
+ * - `job.filter.limit` is spent across messages, not re-granted to each one:
753
+ * `nextFilter` carries the REMAINING budget and is `undefined` once it runs
754
+ * out, so `limit` caps the whole audience here exactly as it does on
755
+ * {@link LunoraPush.broadcast}.
756
+ * - A `retryIds` job redelivers to exactly those ids and throws only while ALL
757
+ * of them still fail, so the queue's backoff/dead-letter bounds a device that
758
+ * never recovers. Once any recipient recovers the run resolves and reports the
759
+ * rest in `failedIds`, so the narrower retry never re-sends to a device this
760
+ * message already reached. A recipient that turns out to be GONE — a 404/410
761
+ * receipt, or an id whose row no longer exists at all — counts as `pruned` here
762
+ * too, never as a failure: there is nothing left to redeliver to, so retrying it
763
+ * could only burn the queue's budget and dead-letter an unsubscribe.
764
+ * - Gone subscriptions (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) are pruned by the page and
765
+ * never appear in `failedIds` — an all-`pruned` page is a success, not a
766
+ * failure, as is an empty page.
455
767
  */
456
- declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<BroadcastResult>;
768
+ declare const runPushBroadcastPage: (push: LunoraPush, job: PushBroadcastJob) => Promise<PushBroadcastPageOutcome>;
457
769
  /**
458
770
  * The minimal structural slice of Cloudflare's `D1Database` this store uses. A
459
771
  * structural type (rather than importing `@cloudflare/workers-types`) keeps the
@@ -505,7 +817,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
505
817
  */
506
818
  declare const memorySubscriptionStore: () => SubscriptionStore;
507
819
  /**
508
- * Stable store id for a web-push endpoint.
820
+ * Stable store id for a web-push endpoint — `fnv1a64Hex` of the (long) endpoint,
821
+ * so re-registering the same device upserts rather than duplicates.
822
+ *
823
+ * The digest comes from the canonical `shared/fnv1a`, not a local copy. This id
824
+ * is a PERSISTED primary key: a digest that drifts in one copy silently re-keys
825
+ * every existing subscription — the old row goes dark and the device
826
+ * re-registers as a duplicate — so the implementation must have exactly one
827
+ * home. `shared/fnv1a`'s is bit-verified against a BigInt reference in
828
+ * `packages/replica/__tests__/apply-diff.test.ts`.
829
+ *
830
+ * Widened from the previous 32-bit FNV-1a (8 hex): at 100K devices a 32-bit key
831
+ * collides with ~68% probability (birthday bound), and a collision silently
832
+ * overwrites another device's row under the store's `ON CONFLICT(id) DO UPDATE` —
833
+ * so the wrong user gets the push and the victim goes dark. 64 bits drops that to
834
+ * negligible at any realistic device count.
509
835
  *
510
836
  * The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
511
837
  * the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
@@ -526,9 +852,10 @@ interface NormalizeOptions {
526
852
  *
527
853
  * When unset, the default posture applies: `https:` scheme + a host the
528
854
  * {@link assertPushEndpoint} STRING classifier does not flag as
529
- * private/loopback. That classifier does NOT resolve DNS, so a public hostname
530
- * resolving to a private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT
531
- * blocked by it set this allowlist to close that gap.
855
+ * private/loopback, plus a resolved-address re-check at send time. Setting
856
+ * this allowlist replaces both with an exact-origin match — the hard
857
+ * guarantee, and the only one that also covers an internal push service you
858
+ * deliberately want to reach.
532
859
  */
533
860
  allowedPushOrigins?: string[];
534
861
  }
@@ -536,7 +863,8 @@ interface NormalizeOptions {
536
863
  * Normalise a `register(...)` input into a {@link StoredSubscription}. Validates
537
864
  * the shape (a web-push subscription needs `endpoint` + `keys.{p256dh,auth}`; an
538
865
  * FCM entry needs a non-empty `token`), enforces the anti-SSRF endpoint boundary
539
- * (see {@link assertPushEndpoint}), and stamps `createdAt`/`lastSeenAt`.
866
+ * (see {@link assertPushEndpoint}), validates `metadata` (see
867
+ * {@link validateMetadata}), and stamps `createdAt`/`lastSeenAt`.
540
868
  */
541
869
  declare const normalizeRegisterInput: (input: RegisterInput, now?: number, options?: NormalizeOptions) => StoredSubscription;
542
870
  /**
@@ -550,11 +878,97 @@ declare const targetOf: (subscription: StoredSubscription) => string;
550
878
  * (the browser/device unsubscribed) and should be pruned — as opposed to a
551
879
  * transient failure worth retrying.
552
880
  *
553
- * Gates on STRUCTURED signals first: a Web Push `HTTP 404/410` status or an FCM
554
- * `UNREGISTERED`/`NOT_REGISTERED` code, both of which the providers surface in
555
- * their failure receipts. The free-text {@link GONE_TEXT_FALLBACK} is a tightened
556
- * last resort only, so a transient error that happens to contain `expired`
557
- * (a cert/session expiry) can never permanently drop a valid subscription.
881
+ * Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
882
+ * answer one for a dead endpoint/token, though FCM's is usually replaced by its
883
+ * error body before it reaches here) or, for FCM only, an
884
+ * `UNREGISTERED`/`NOT_REGISTERED` code or the `NOT_FOUND` prose that is the one
885
+ * signal its provider actually forwards (see {@link FCM_GONE_PATTERN}). The
886
+ * free-text {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a
887
+ * transient error that happens to contain `expired` (a cert/session expiry) can
888
+ * never permanently drop a valid subscription.
889
+ *
890
+ * `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
891
+ * The web-push provider echoes the push service's response body into
892
+ * `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
893
+ * registered" matched the FCM-only codes and permanently deleted a live
894
+ * subscription. Omit `kind` (the third-party/unknown-provider case) to test
895
+ * every pattern, as before.
896
+ */
897
+ declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
898
+ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
899
+ /**
900
+ * `@lunora/notify`
901
+ *
902
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
903
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
904
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
905
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
906
+ * → `ctx.flags`).
907
+ *
908
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
909
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
910
+ * deliberately **not** on the edge facade — route heavy fan-out through
911
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
912
+ *
913
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
914
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
915
+ * @packageDocumentation
916
+ */
917
+ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushOwner, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
918
+ /**
919
+ * `@lunora/notify`
920
+ *
921
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
922
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
923
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
924
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
925
+ * → `ctx.flags`).
926
+ *
927
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
928
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
929
+ * deliberately **not** on the edge facade — route heavy fan-out through
930
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
931
+ *
932
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
933
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
934
+ * @packageDocumentation
935
+ */
936
+ WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast,
937
+ /**
938
+ * `@lunora/notify`
939
+ *
940
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
941
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
942
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
943
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
944
+ * → `ctx.flags`).
945
+ *
946
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
947
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
948
+ * deliberately **not** on the edge facade — route heavy fan-out through
949
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
950
+ *
951
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
952
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
953
+ * @packageDocumentation
954
+ */
955
+ fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastPage, targetOf,
956
+ /**
957
+ * `@lunora/notify`
958
+ *
959
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
960
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
961
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
962
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
963
+ * → `ctx.flags`).
964
+ *
965
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
966
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
967
+ * deliberately **not** on the edge facade — route heavy fan-out through
968
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
969
+ *
970
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
971
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
972
+ * @packageDocumentation
558
973
  */
559
- declare const isGoneError: (message: string | undefined) => boolean;
560
- export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions, FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore, WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast, fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf, webPushFromEnv, webPushId };
974
+ webPushFromEnv, webPushId };