@lunora/notify 1.0.0-alpha.8 → 1.0.0-alpha.81

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
@@ -15,6 +15,15 @@ type NotifyEnv = Record<string, unknown>;
15
15
  type SubscriptionKind = "fcm" | "web-push";
16
16
  /** The last-known delivery outcome recorded on a subscription. */
17
17
  type SubscriptionStatus = "expired" | "failed" | "ok";
18
+ /** The delivery outcomes of one broadcast page, grouped for {@link SubscriptionStore.applyOutcomes}. */
19
+ interface SubscriptionOutcomes {
20
+ failed: {
21
+ error?: string;
22
+ id: string;
23
+ }[];
24
+ gone: string[];
25
+ ok: string[];
26
+ }
18
27
  /**
19
28
  * A registered device/browser subscription. Web Push carries a W3C Push API
20
29
  * `endpoint` + `keys`; FCM carries a device registration `token`. `id` is a
@@ -111,9 +120,20 @@ interface SubscriptionFilter {
111
120
  * subscriptions reached across every internally-walked page (still
112
121
  * deliberately left unset by default — it must reach every matched
113
122
  * device); the PER-PAGE batch size is a separate, independent knob (see
114
- * `CreateNotifyOptions`'s `broadcastPageSize`, default 250) so a caller
123
+ * `defineNotify`'s `broadcastPageSize`, default 250) so a caller
115
124
  * that sets `limit` to bound the audience doesn't also have to reason
116
125
  * about page sizing.
126
+ *
127
+ * The non-positive sentinel means something different at each layer: at
128
+ * the STORE layer (`list`, and the `d1-store`/`memory-store` fetch-size
129
+ * hint) a non-positive `limit` means "no cap" — fetch everything. At the
130
+ * `broadcast`/`broadcastPage` layer, where `limit` is an AUDIENCE cap, a
131
+ * non-positive value instead means "no deliveries" — `broadcast({ limit: 0
132
+ * })` reaches nobody, not everybody. This asymmetry is deliberate: the two
133
+ * layers answer different questions ("how many rows to fetch" vs. "how
134
+ * many recipients to reach"), and unifying them would either break `list`
135
+ * callers relying on "no cap" or reintroduce the over-delivery this
136
+ * distinction fixes (see `broadcastPage`'s doc comment).
117
137
  */
118
138
  limit?: number;
119
139
  /** Restrict to a single owning user. */
@@ -125,8 +145,39 @@ interface SubscriptionFilter {
125
145
  * and a D1-backed store (durable, edge-safe).
126
146
  */
127
147
  interface SubscriptionStore {
148
+ /**
149
+ * Apply a whole broadcast page's delivery outcomes at once (best-effort):
150
+ * `ok` ids are marked ok, `failed` ids marked failed with their error, `gone`
151
+ * ids deleted. OPTIONAL — when absent, `broadcast` calls `markStatus` /
152
+ * `delete` per id.
153
+ */
154
+ applyOutcomes?: (outcomes: SubscriptionOutcomes) => Promise<void>;
128
155
  /** Remove a subscription by id (idempotent). */
129
156
  delete: (id: string) => Promise<void>;
157
+ /**
158
+ * Remove a subscription by id ONLY if it is owned by `userId`, and report
159
+ * whether it was.
160
+ *
161
+ * Separate from {@link SubscriptionStore.delete} because the caller-facing
162
+ * `unregister` must not be a read followed by a write: between a `get` that
163
+ * checks the owner and a `delete` that acts on it, a re-registration can
164
+ * replace the row, so the check passes for one owner and the removal lands on
165
+ * another's subscription.
166
+ *
167
+ * **The predicate and the removal must be ONE operation.** A store that
168
+ * cannot do that atomically should say so in its own documentation rather
169
+ * than implement this as a get-then-delete, which reintroduces the race this
170
+ * method exists to remove. Both shipped stores manage it: the in-memory one
171
+ * because a `Map` check-and-delete has no await between the two, and the D1
172
+ * one with a single `DELETE … WHERE id = ? AND user_id = ? RETURNING id`.
173
+ *
174
+ * `userId` is `null` for an anonymous subscription, and matches only a row
175
+ * that is itself unowned.
176
+ * @param id The subscription id.
177
+ * @param userId The owner the row must carry, or `null` for unowned.
178
+ * @returns `true` when a row was removed.
179
+ */
180
+ deleteOwned: (id: string, userId: string | null) => Promise<boolean>;
130
181
  /** Read a subscription by id, or `undefined`. */
131
182
  get: (id: string) => Promise<StoredSubscription | undefined>;
132
183
  /**
@@ -139,7 +190,33 @@ interface SubscriptionStore {
139
190
  list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
140
191
  /** Record the latest delivery outcome for a subscription (best-effort). */
141
192
  markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
142
- /** Insert or update a subscription (upsert by id). */
193
+ /**
194
+ * Insert or update a subscription (upsert by id), refusing to move an
195
+ * existing row to a DIFFERENT owner.
196
+ *
197
+ * The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
198
+ * carries, and for the same reason: the id is derived from the endpoint or the
199
+ * FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
200
+ * caller who could guess or observe another user's endpoint re-register it
201
+ * under their own `userId` with keys of their choosing — the victim's device
202
+ * then fails every send (an encryption failure is not a gone signal, so it is
203
+ * never pruned either) and the attacker can `unregister` it as their own. The
204
+ * `unregister` guard alone closed exactly half of that (CWE-639).
205
+ *
206
+ * A row with no owner is claimable (the device signed in), and a row the caller
207
+ * already owns is theirs to refresh — that is the routine service-worker
208
+ * re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
209
+ * no-op: unlike `unregister` there is nothing safe to return, since a caller
210
+ * gets the stored record back and it would be someone else's delivery keys.
211
+ * A shared device that legitimately changes hands is handled by its current
212
+ * owner calling `unregister` first.
213
+ *
214
+ * **The predicate and the write must be ONE operation**, as for `deleteOwned`:
215
+ * between a read that checks the owner and the write that acts on it, another
216
+ * registration can replace the row. The D1 store puts the predicate in the
217
+ * `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
218
+ * between the two.
219
+ */
143
220
  put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
144
221
  }
145
222
  /** Per-recipient outcome from a fan-out `broadcast`. */
@@ -183,7 +260,7 @@ interface BroadcastPageResult {
183
260
  *
184
261
  * - `accepted` — the provider took the message (a `Receipt.successful` send).
185
262
  * - `failed` — a provider error; the log line carries the `error` text.
186
- * - `gone` — the endpoint is unregistered (404/410, FCM `UNREGISTERED`) and pruned; push-only.
263
+ * - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
187
264
  *
188
265
  * Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
189
266
  * send attempt: a `delivered`/`opened` status would be a lie for these channels.
@@ -228,26 +305,28 @@ interface LunoraPush {
228
305
  /**
229
306
  * Fan-out a push to every stored subscription matching `filter` (default: all).
230
307
  * Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
231
- * the push service reports as gone (HTTP 404/410, FCM `UNREGISTERED`). The `to`
308
+ * the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
232
309
  * target is derived from each subscription, so it is omitted from the payload.
233
310
  *
234
311
  * Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
235
- * keyset-paginated on the subscription `id`) so a huge audience is never
236
- * materialized wholesale in the isolate — see `CreateNotifyOptions`'s
237
- * `broadcastPageSize`. This call still processes the WHOLE matched
238
- * audience in one request/queue message; use {@link LunoraPush.broadcastPage}
239
- * directly (as `runPushBroadcastJob` does) to bound a single queue message
240
- * to one page.
312
+ * keyset-paginated on the subscription `id`) so the audience ROWS are never
313
+ * materialized wholesale in the isolate — see `defineNotify`'s
314
+ * `broadcastPageSize`. The returned `outcomes` ARE whole-audience, though: one
315
+ * `{ id, status }` per recipient accumulates across every page, so this call is
316
+ * bounded in rows held but not in outcomes reported. It also processes the WHOLE
317
+ * matched audience in one request/queue message; use {@link LunoraPush.broadcastPage}
318
+ * directly (as `runPushBroadcastPage` does) to bound a single queue message —
319
+ * and its result — to one page.
241
320
  */
242
321
  broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
243
322
  /**
244
323
  * Fan-out a push to ONE bounded page of stored subscriptions matching
245
- * `filter` (page size: `CreateNotifyOptions`'s `broadcastPageSize`, default
324
+ * `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
246
325
  * 250, capped by `filter.limit` when set). Same delivery semantics as
247
326
  * {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
248
327
  * scoped to a single page; returns the page's own {@link BroadcastResult}
249
328
  * plus a `nextCursor` to fetch the next page (`undefined` when done).
250
- * Backs `runPushBroadcastJob` so one queue message does bounded work
329
+ * Backs `runPushBroadcastPage` so one queue message does bounded work
251
330
  * regardless of audience size — most app code should call
252
331
  * {@link LunoraPush.broadcast} instead.
253
332
  */
@@ -256,18 +335,66 @@ interface LunoraPush {
256
335
  * List stored subscriptions (optionally filtered), with the delivery
257
336
  * **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
258
337
  * FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
259
- * a device, so they never cross the app-facing facade; the raw rows are
338
+ * a device, so no READ on this facade returns them; the raw rows are otherwise
260
339
  * reachable only through the internal `SubscriptionStore`.
340
+ *
341
+ * {@link LunoraPush.register} is the one exception, and deliberately so: it
342
+ * echoes back the record the caller just supplied, so it discloses nothing
343
+ * the caller did not already hold and never another device's row.
261
344
  */
262
345
  list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
263
- /** Register (upsert) a device subscription and return the stored record. */
346
+ /**
347
+ * Register (upsert) a device subscription and return the stored record (the
348
+ * caller's own row, secrets included).
349
+ *
350
+ * Owner-scoped, exactly as {@link LunoraPush.unregister} is: registering an
351
+ * endpoint that is already another user's row is REFUSED (`FORBIDDEN`) rather
352
+ * than re-owning it. Pass `ctx.auth?.userId` so the check has something to
353
+ * separate; an app that registers every device anonymously gets no separation
354
+ * from it (every row is unowned, and unowned rows stay claimable). A device
355
+ * that legitimately changes hands — one browser profile, two accounts —
356
+ * unregisters as its current owner first.
357
+ */
264
358
  register: (input: RegisterInput) => Promise<StoredSubscription>;
265
359
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
266
360
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
267
- /** Remove a subscription by id (idempotent). */
268
- unregister: (id: string) => Promise<void>;
361
+ /**
362
+ * Remove ONE of `owner`'s subscriptions by id (idempotent).
363
+ *
364
+ * `owner` is not optional, and the removal happens only when the stored row
365
+ * carries that same owner. A subscription id is derived from the endpoint
366
+ * (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
367
+ * intended call is a mutation forwarding `subscribeToPush`'s
368
+ * `replacedEndpoint` after a VAPID rotation, and nothing about that argument
369
+ * proves the browser sending it ever held the subscription it names.
370
+ * Deleting by id alone let any caller that could guess or observe another
371
+ * user's endpoint silence that device's notifications (CWE-639).
372
+ *
373
+ * A row belonging to someone else is left alone SILENTLY rather than
374
+ * refused, so the call cannot be used to probe which endpoints exist — the
375
+ * same answer, and the same absence of a write, as an id that was never
376
+ * registered.
377
+ *
378
+ * `{ userId: null }` (or `undefined`, which normalises to it) addresses the
379
+ * anonymous rows — those registered with no `userId`. An app that registers
380
+ * every device anonymously therefore gets no separation from this check;
381
+ * pass `ctx.auth?.userId` and register with it to get any.
382
+ */
383
+ unregister: (id: string, owner: PushOwner) => Promise<void>;
269
384
  }
270
- /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
385
+ /** Who a {@link LunoraPush.unregister} call is acting as. */
386
+ interface PushOwner {
387
+ /**
388
+ * The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
389
+ * an anonymous registration. Required — see {@link LunoraPush.unregister}.
390
+ */
391
+ userId: string | null | undefined;
392
+ }
393
+ /**
394
+ * A push payload without its `to` target — the facade derives `to` from the
395
+ * stored subscription. Its `ttl` / `urgency` override the channel defaults per
396
+ * message on every push transport (Web Push, FCM, APNs, Expo).
397
+ */
271
398
  type PushContent = Omit<PushPayload, "to">;
272
399
  /**
273
400
  * The multi-channel notification facade — spliced onto ctx as `ctx.notify`.
@@ -276,7 +403,7 @@ type PushContent = Omit<PushPayload, "to">;
276
403
  * single-channel convenience senders for the edge-safe channels.
277
404
  */
278
405
  interface LunoraNotify {
279
- /** Send an outbound webhook. */
406
+ /** Post to a chat channel (Slack/Discord/Teams/Telegram). */
280
407
  chat: (payload: ChatPayload) => Promise<Receipt>;
281
408
  /** Deliver an in-app inbox notification. */
282
409
  inApp: (payload: InAppPayload) => Promise<Receipt>;
@@ -284,7 +411,7 @@ interface LunoraNotify {
284
411
  push: LunoraPush;
285
412
  /** Deliver a multi-channel message (one payload per channel). */
286
413
  send: (message: NotificationMessage) => Promise<Receipt[]>;
287
- /** Post to a chat channel (Slack/Discord/Teams/Telegram). */
414
+ /** Send an outbound webhook. */
288
415
  webhook: (payload: WebhookPayload) => Promise<Receipt>;
289
416
  }
290
417
  /**
@@ -312,11 +439,32 @@ interface NotifyConfig {
312
439
  * wildcards) to hard-pin the boundary and close DNS rebinding.
313
440
  */
314
441
  allowedPushOrigins?: string[];
442
+ /**
443
+ * Page size for `push.broadcast`'s internal keyset pagination over the
444
+ * subscription store (default 250, minimum 1). Each page is fetched,
445
+ * delivered, and counted before the next page's store round trip, so a huge
446
+ * audience is never materialized wholesale in the isolate. Also the
447
+ * per-message bound `push.broadcastPage` (and `runPushBroadcastPage`) uses.
448
+ *
449
+ * Declared here, and not only on `createNotify`'s third argument, because
450
+ * this file is the only handle an app has: the sole production constructor is
451
+ * codegen's fixed `createNotify(definition, env, { log, metrics })`, so a knob
452
+ * that lives only on those options is unsettable by every Lunora app —
453
+ * while {@link SubscriptionFilter.limit}'s own docs point at it as the way to
454
+ * size pages.
455
+ */
456
+ broadcastPageSize?: number;
315
457
  /**
316
458
  * Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
317
459
  * provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
318
460
  */
319
461
  chat?: (env: NotifyEnv) => unknown;
462
+ /**
463
+ * Max concurrent sends during a `push.broadcast` (default 10, minimum 1).
464
+ * Same reasoning as {@link NotifyConfig.broadcastPageSize}: this is where an
465
+ * app can reach it.
466
+ */
467
+ concurrency?: number;
320
468
  /** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
321
469
  fcm?: FcmConfig | FcmConfigFactory;
322
470
  /** Optional in-app inbox provider factory. Edge-safe. */
@@ -405,14 +553,18 @@ interface CreateNotifyOptions {
405
553
  /**
406
554
  * Page size for `push.broadcast`'s internal keyset pagination over the
407
555
  * subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
408
- * Each page is fetched, delivered, and counted independently before the
409
- * next page's store round trip, so a huge audience is never materialized
410
- * wholesale in the isolate. Also the per-message bound `push.broadcastPage`
411
- * (and so `runPushBroadcastJob`) uses. A test/tuning seam — most apps never
412
- * need to set this.
556
+ *
557
+ * A test/tuning seam only. **Apps set `broadcastPageSize` on `defineNotify`
558
+ * instead** — the sole production call is codegen's fixed
559
+ * `createNotify(definition, env, { log, metrics })`, so nothing an app writes
560
+ * reaches this object. Set here it wins over the definition's value.
413
561
  */
414
562
  broadcastPageSize?: number;
415
- /** Max concurrent sends during a `broadcast` (default 10). */
563
+ /**
564
+ * Max concurrent sends during a `broadcast` (default 10). Test/tuning seam;
565
+ * apps set `concurrency` on `defineNotify` — see
566
+ * {@link CreateNotifyOptions.broadcastPageSize}.
567
+ */
416
568
  concurrency?: number;
417
569
  /**
418
570
  * Override the assembled `@visulima/notification` engine. Advanced/testing
@@ -456,7 +608,19 @@ declare const createNotify: (definition: NotifyDefinition, env: NotifyEnv, optio
456
608
  };
457
609
  /** Options for {@link routingPushProvider}. */
458
610
  interface RoutingPushOptions {
611
+ /**
612
+ * The definition's exact-origin allowlist, when configured. Its presence
613
+ * disables the send-time rebinding re-check (see {@link assertPushTargetResolvable}).
614
+ */
615
+ allowedPushOrigins?: string[];
459
616
  fcm?: Provider<unknown, PushPayload>;
617
+ /**
618
+ * Backoff base for the in-router group retry, in ms (default 250 — the
619
+ * engine's own). Only a TEST double passes anything else, for the same
620
+ * reason {@link ResilienceOptions.retryBaseDelay} exists: a mock that fails
621
+ * on purpose would otherwise spend the real ~1 s of backoff per partial.
622
+ */
623
+ retryBaseDelay?: number;
460
624
  webPush?: Provider<unknown, PushPayload>;
461
625
  }
462
626
  /**
@@ -469,6 +633,8 @@ interface RoutingPushOptions {
469
633
  declare const routingPushProvider: (options: RoutingPushOptions) => Provider<unknown, PushPayload>;
470
634
  /** A resolved, ready-to-wire set of channel configs (edge-safe channels only). */
471
635
  interface ResolvedProviders {
636
+ /** The definition's `allowedPushOrigins`, threaded to the push router's send-time SSRF guard. */
637
+ allowedPushOrigins?: string[];
472
638
  chat?: Provider;
473
639
  fcm?: FcmConfig;
474
640
  inApp?: Provider;
@@ -487,45 +653,102 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
487
653
  * fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
488
654
  * `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
489
655
  * `filter.after`, when set, resumes a broadcast partway through (see
490
- * {@link runPushBroadcastJob}'s continuation semantics).
656
+ * {@link runPushBroadcastPage}'s continuation semantics).
491
657
  */
492
658
  interface PushBroadcastJob {
493
659
  /** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
494
660
  filter?: SubscriptionFilter;
495
661
  /** The push payload to deliver (the `to` target is derived per subscription). */
496
662
  payload: PushContent;
663
+ /**
664
+ * Redeliver to exactly these subscription ids instead of walking a page —
665
+ * an earlier page's {@link PushBroadcastPageOutcome.failedIds}. Set by the
666
+ * consumer when it re-enqueues a page's transient failures; `filter` is
667
+ * ignored on such a job. See {@link runPushBroadcastPage}.
668
+ */
669
+ retryIds?: string[];
497
670
  /** Discriminator so a shared queue can multiplex message kinds. */
498
671
  type: "lunora.push.broadcast";
499
672
  }
500
- /** The structural slice of a `@lunora/queue` producer (`ctx.queues.&lt;name>`) used here. */
673
+ /**
674
+ * One page's outcome plus the ids that need redelivering.
675
+ *
676
+ * The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
677
+ * `failedIds` redelivers the recipients this page missed. Acking a message while
678
+ * ignoring either silently drops part of the audience.
679
+ */
680
+ interface PushBroadcastPageOutcome {
681
+ /**
682
+ * Subscriptions that failed transiently on this run (gone/pruned devices are
683
+ * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
684
+ * as `retryIds` to redeliver to just them.
685
+ */
686
+ failedIds: string[];
687
+ /**
688
+ * The filter for the CONTINUATION job, or `undefined` when the broadcast is
689
+ * finished (no further pages, or `filter.limit` is spent). Enqueue it
690
+ * verbatim — it carries the next page's cursor AND, when the job set
691
+ * `filter.limit`, the REMAINING budget.
692
+ *
693
+ * This replaces the raw `nextCursor` the runner used to return. Rebuilding
694
+ * the filter at the call site (`{ ...job.filter, after: nextCursor }`)
695
+ * forwarded the ORIGINAL `limit` to every message, so a `limit` documented
696
+ * as an overall audience cap (see {@link SubscriptionFilter.limit}, which
697
+ * `broadcast` honours as one) became a per-message cap and the walk reached
698
+ * the entire audience anyway.
699
+ */
700
+ nextFilter?: SubscriptionFilter;
701
+ /** This page's delivery result. */
702
+ result: BroadcastResult;
703
+ }
704
+ /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
501
705
  interface QueueProducerLike {
502
706
  send: (body: PushBroadcastJob) => Promise<void>;
503
707
  }
504
708
  /**
505
709
  * Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
506
- * queue instead of blocking the request. Pair with {@link runPushBroadcastJob} in
710
+ * queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
507
711
  * the queue consumer — see its doc comment for how a large audience continues
508
712
  * across MULTIPLE messages (one bounded page per message), not one.
509
713
  *
510
714
  * ```ts
511
- * // in a mutation/action:
512
- * await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
715
+ * // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
716
+ * // `ctx.push` and `ctx.queues` exist.
717
+ * import { internalAction, v } from "./_generated/server";
718
+ * import { enqueuePushBroadcast, runPushBroadcastPage } from "@lunora/notify";
513
719
  *
514
- * // in lunora/queues.ts consumer:
515
- * export const push = defineQueue({ async handler(batch, ctx) {
516
- * for (const message of batch.messages) {
517
- * const { nextCursor } = await runPushBroadcastJob(ctx.push, message.body);
720
+ * export const deliverPage = internalAction
721
+ * .input({ job: v.any() })
722
+ * .action(async ({ args: { job }, ctx }) => {
723
+ * const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
518
724
  *
519
- * if (nextCursor !== undefined) {
725
+ * if (nextFilter !== undefined) {
520
726
  * // More pages remain — enqueue the continuation. Each message still
521
- * // does only ONE bounded page of work.
522
- * await enqueuePushBroadcast(ctx.queues.push, {
523
- * payload: message.body.payload,
524
- * filter: { ...message.body.filter, after: nextCursor },
525
- * });
727
+ * // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
728
+ * // it carries the cursor and the remaining `limit` budget.
729
+ * await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
730
+ * }
731
+ *
732
+ * if (failedIds.length > 0) {
733
+ * // Redeliver ONLY the recipients that failed — never the whole page.
734
+ * await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
526
735
  * }
527
- * }
528
- * }});
736
+ * });
737
+ *
738
+ * // lunora/queues.ts — the consumer itself has NO ctx.push / ctx.queues: a
739
+ * // `QueueRunContext` is exactly `{ env, log, run }` (handler signature
740
+ * // `(context, batch)`, in that order). It hands each message to the action above.
741
+ * export const push = defineQueue<PushBroadcastJob>({
742
+ * handler: async (context, batch) => {
743
+ * for (const message of batch.messages) {
744
+ * await message.run(internal.notifyFanout.deliverPage, { job: message.body });
745
+ * message.ack();
746
+ * }
747
+ * },
748
+ * });
749
+ *
750
+ * // in a mutation/action, to start it:
751
+ * await enqueuePushBroadcast(ctx.queues.push, { payload: { body: "…", title: "New drop" } });
529
752
  * ```
530
753
  */
531
754
  declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
@@ -535,43 +758,48 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
535
758
  * reuses the engine's retry + circuit-breaker middleware and prunes gone
536
759
  * subscriptions).
537
760
  *
538
- * RETRY / CONTINUATION SEMANTICS (rewritten for plan 222 / NOTIFY-01 — a
539
- * broadcast job used to process the WHOLE audience in one message, which could
540
- * exceed Worker CPU/wall limits for a large audience and made a retry re-run
541
- * everything):
761
+ * RETRY / CONTINUATION SEMANTICS:
542
762
  *
543
- * - A job now processes exactly ONE bounded page (see `CreateNotifyOptions`'s
544
- * page-size option, default 250, or `job.filter.limit` when smaller),
763
+ * - A job processes exactly ONE bounded page (see `defineNotify`'s
764
+ * `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
545
765
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
546
766
  * — so per-message work is bounded regardless of total audience size.
547
- * - Retry is still gated on `result.failed` — the count of TRANSIENT delivery
548
- * errors (a provider 5xx / network fault worth another attempt). When at
549
- * least one recipient in THIS PAGE `failed`, the job is RE-THROWN so the
550
- * queue does NOT ack it and its normal retry/backoff (and, on exhaustion,
551
- * dead-letter) applies — to just this page, not the whole broadcast.
552
- * - A page with zero `failed` resolves and is acked — this includes the
553
- * all-`pruned` case (every device on the page had unsubscribed:
554
- * `sent:0`, `failed:0`, `pruned:N`), which is a SUCCESSFUL prune, not a
555
- * failure, so throwing on it would spuriously retry and pressure the DLQ;
556
- * and the empty-page case (zero `total`), which has nothing to retry.
557
- * - The returned `nextCursor` is set when more pages remain. `@lunora/notify`
558
- * does NOT enqueue the continuation itself — it has no `@lunora/queue`
559
- * dependency (the seam stays structural) and no reference to the producer
560
- * that enqueued this message — so the CALLER (the `lunora/queues.ts`
561
- * consumer) is responsible for re-enqueueing with `filter.after: nextCursor`
562
- * when present. See the consumer example on {@link enqueuePushBroadcast}.
563
- * - A retry of a page redelivers only that page's already-delivered recipients
564
- * on a transient partial failure (a page is not individually idempotent) —
565
- * the accepted cost of getting the transiently failed ones redelivered, now
566
- * scoped to one page instead of the whole broadcast.
567
- */
568
- declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<BroadcastPageResult>;
767
+ * - A page NEVER throws for a partial failure. Throwing discarded the page's
768
+ * continuation, which is the only way the broadcast advances: one device that
769
+ * fails permanently (a rotated VAPID keypair leaves a stale device answering
770
+ * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
771
+ * every already-delivered recipient on each retry, dead-letter, and leave
772
+ * every LATER page unreached. The page's `nextFilter` and its `failedIds`
773
+ * both come back instead.
774
+ * - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
775
+ * `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
776
+ * do it itself — it has no `@lunora/queue` dependency (the seam stays
777
+ * structural) and no reference to the producer that enqueued this message. See
778
+ * the consumer example on {@link enqueuePushBroadcast}.
779
+ * - `job.filter.limit` is spent across messages, not re-granted to each one:
780
+ * `nextFilter` carries the REMAINING budget and is `undefined` once it runs
781
+ * out, so `limit` caps the whole audience here exactly as it does on
782
+ * {@link LunoraPush.broadcast}.
783
+ * - A `retryIds` job redelivers to exactly those ids and throws only while ALL
784
+ * of them still fail, so the queue's backoff/dead-letter bounds a device that
785
+ * never recovers. Once any recipient recovers the run resolves and reports the
786
+ * rest in `failedIds`, so the narrower retry never re-sends to a device this
787
+ * message already reached. A recipient that turns out to be GONE — a 404/410
788
+ * receipt, or an id whose row no longer exists at all — counts as `pruned` here
789
+ * too, never as a failure: there is nothing left to redeliver to, so retrying it
790
+ * could only burn the queue's budget and dead-letter an unsubscribe.
791
+ * - Gone subscriptions (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) are pruned by the page and
792
+ * never appear in `failedIds` — an all-`pruned` page is a success, not a
793
+ * failure, as is an empty page.
794
+ */
795
+ declare const runPushBroadcastPage: (push: LunoraPush, job: PushBroadcastJob) => Promise<PushBroadcastPageOutcome>;
569
796
  /**
570
797
  * The minimal structural slice of Cloudflare's `D1Database` this store uses. A
571
798
  * structural type (rather than importing `@cloudflare/workers-types`) keeps the
572
799
  * store runtime-agnostic and trivially fakeable in tests.
573
800
  */
574
801
  interface D1Like {
802
+ batch: (statements: D1PreparedLike[]) => Promise<unknown>;
575
803
  prepare: (query: string) => D1PreparedLike;
576
804
  }
577
805
  interface D1PreparedLike {
@@ -587,27 +815,6 @@ interface D1StoreOptions {
587
815
  /** Table name (default `lunora_push_subscriptions`). Must be a bare identifier. */
588
816
  tableName?: string;
589
817
  }
590
- /**
591
- * A D1-backed {@link SubscriptionStore}. Edge-safe (D1 is a Worker binding). The
592
- * backing table is created lazily on first use (`CREATE TABLE IF NOT EXISTS`), so
593
- * no migration step is required for the subscription table itself.
594
- *
595
- * ID SCHEME / LAZY MIGRATION: `id` (the `PRIMARY KEY`, upserted via `ON
596
- * CONFLICT(id) DO UPDATE`) is a version-prefixed digest of the endpoint/token —
597
- * currently `wp2_`/`fcm2_` (64-bit FNV-1a; see `normalize.ts`). No table migration
598
- * runs when the id scheme is revised: a returning device re-registers under its new
599
- * id and upserts a fresh row, while its old-prefix row (`wp_`/`fcm_`) ages out via
600
- * the normal gone-pruning on the next failed send. So a table can transiently hold
601
- * both an old- and new-prefix row for one device — expected, self-healing, and the
602
- * reason a prefix must NEVER be reused for a different scheme.
603
- *
604
- * ```ts
605
- * export default defineNotify({
606
- * webPush: (env) => webPushFromEnv(env),
607
- * store: (env) => d1SubscriptionStore(env.DB),
608
- * });
609
- * ```
610
- */
611
818
  declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions) => SubscriptionStore;
612
819
  /**
613
820
  * An in-memory {@link SubscriptionStore} — the zero-dependency default. Suitable
@@ -617,7 +824,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
617
824
  */
618
825
  declare const memorySubscriptionStore: () => SubscriptionStore;
619
826
  /**
620
- * Stable store id for a web-push endpoint.
827
+ * Stable store id for a web-push endpoint — `fnv1a64Hex` of the (long) endpoint,
828
+ * so re-registering the same device upserts rather than duplicates.
829
+ *
830
+ * The digest comes from the canonical `shared/fnv1a`, not a local copy. This id
831
+ * is a PERSISTED primary key: a digest that drifts in one copy silently re-keys
832
+ * every existing subscription — the old row goes dark and the device
833
+ * re-registers as a duplicate — so the implementation must have exactly one
834
+ * home. `shared/fnv1a`'s is bit-verified against a BigInt reference in
835
+ * `packages/replica/__tests__/apply-diff.test.ts`.
836
+ *
837
+ * Widened from the previous 32-bit FNV-1a (8 hex): at 100K devices a 32-bit key
838
+ * collides with ~68% probability (birthday bound), and a collision silently
839
+ * overwrites another device's row under the store's `ON CONFLICT(id) DO UPDATE` —
840
+ * so the wrong user gets the push and the victim goes dark. 64 bits drops that to
841
+ * negligible at any realistic device count.
621
842
  *
622
843
  * The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
623
844
  * the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
@@ -638,9 +859,10 @@ interface NormalizeOptions {
638
859
  *
639
860
  * When unset, the default posture applies: `https:` scheme + a host the
640
861
  * {@link assertPushEndpoint} STRING classifier does not flag as
641
- * private/loopback. That classifier does NOT resolve DNS, so a public hostname
642
- * resolving to a private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT
643
- * blocked by it — set this allowlist to close that gap.
862
+ * private/loopback, plus a resolved-address re-check at send time. Setting
863
+ * this allowlist replaces both with an exact-origin match — the hard
864
+ * guarantee, and the only one that also covers an internal push service you
865
+ * deliberately want to reach.
644
866
  */
645
867
  allowedPushOrigins?: string[];
646
868
  }
@@ -663,11 +885,97 @@ declare const targetOf: (subscription: StoredSubscription) => string;
663
885
  * (the browser/device unsubscribed) and should be pruned — as opposed to a
664
886
  * transient failure worth retrying.
665
887
  *
666
- * Gates on STRUCTURED signals first: a Web Push `HTTP 404/410` status or an FCM
667
- * `UNREGISTERED`/`NOT_REGISTERED` code, both of which the providers surface in
668
- * their failure receipts. The free-text {@link GONE_TEXT_FALLBACK} is a tightened
669
- * last resort only, so a transient error that happens to contain `expired`
670
- * (a cert/session expiry) can never permanently drop a valid subscription.
888
+ * Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
889
+ * answer one for a dead endpoint/token, though FCM's is usually replaced by its
890
+ * error body before it reaches here) or, for FCM only, an
891
+ * `UNREGISTERED`/`NOT_REGISTERED` code or the `NOT_FOUND` prose that is the one
892
+ * signal its provider actually forwards (see {@link FCM_GONE_PATTERN}). The
893
+ * free-text {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a
894
+ * transient error that happens to contain `expired` (a cert/session expiry) can
895
+ * never permanently drop a valid subscription.
896
+ *
897
+ * `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
898
+ * The web-push provider echoes the push service's response body into
899
+ * `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
900
+ * registered" matched the FCM-only codes and permanently deleted a live
901
+ * subscription. Omit `kind` (the third-party/unknown-provider case) to test
902
+ * every pattern, as before.
903
+ */
904
+ declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
905
+ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
906
+ /**
907
+ * `@lunora/notify`
908
+ *
909
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
910
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
911
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
912
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
913
+ * → `ctx.flags`).
914
+ *
915
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
916
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
917
+ * deliberately **not** on the edge facade — route heavy fan-out through
918
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
919
+ *
920
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
921
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
922
+ * @packageDocumentation
923
+ */
924
+ 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 SubscriptionOutcomes, type SubscriptionStatus, type SubscriptionStore,
925
+ /**
926
+ * `@lunora/notify`
927
+ *
928
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
929
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
930
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
931
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
932
+ * → `ctx.flags`).
933
+ *
934
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
935
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
936
+ * deliberately **not** on the edge facade — route heavy fan-out through
937
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
938
+ *
939
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
940
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
941
+ * @packageDocumentation
942
+ */
943
+ WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast,
944
+ /**
945
+ * `@lunora/notify`
946
+ *
947
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
948
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
949
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
950
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
951
+ * → `ctx.flags`).
952
+ *
953
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
954
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
955
+ * deliberately **not** on the edge facade — route heavy fan-out through
956
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
957
+ *
958
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
959
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
960
+ * @packageDocumentation
961
+ */
962
+ fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastPage, targetOf,
963
+ /**
964
+ * `@lunora/notify`
965
+ *
966
+ * Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
967
+ * engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
968
+ * (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
969
+ * and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
970
+ * → `ctx.flags`).
971
+ *
972
+ * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
973
+ * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
974
+ * deliberately **not** on the edge facade — route heavy fan-out through
975
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
976
+ *
977
+ * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
978
+ * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
979
+ * @packageDocumentation
671
980
  */
672
- declare const isGoneError: (message: string | undefined) => boolean;
673
- 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 };
981
+ webPushFromEnv, webPushId };