@lunora/notify 1.0.0-alpha.34 → 1.0.0-alpha.36

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/README.md CHANGED
@@ -39,17 +39,41 @@ Generate a VAPID keypair once: `npx web-push generate-vapid-keys`.
39
39
  ```ts
40
40
  import { subscribeToPush } from "@lunora/notify/web";
41
41
 
42
- const subscription = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
43
- await client.mutation("registerDevice", { subscription });
42
+ const { replacedEndpoint, subscription } = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
43
+ await client.mutation("registerDevice", { replacedEndpoint, subscription });
44
44
  ```
45
45
 
46
+ `replacedEndpoint` is set only after a **VAPID key rotation**: the stale browser
47
+ subscription is dropped and a new one minted, and the new one has a new endpoint
48
+ — hence a new store id — so it never upserts over the old row. Every send to that
49
+ row now answers `403 VapidPkHashMismatch`, which is (correctly) not a "gone"
50
+ signal, so nothing prunes it either. Forward it and unregister it:
51
+
46
52
  ```ts
47
53
  // lunora/registerDevice.ts (a mutation — storage write is fine here)
48
- export const registerDevice = mutation.input({ subscription: v.any() }).mutation(async ({ ctx, args: { subscription } }) => {
49
- await ctx.push.register({ subscription, userId: ctx.auth?.userId });
50
- });
54
+ import { webPushId } from "@lunora/notify";
55
+
56
+ export const registerDevice = mutation
57
+ .input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
58
+ .mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
59
+ if (replacedEndpoint !== undefined) {
60
+ await ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId });
61
+ }
62
+
63
+ await ctx.push.register({ subscription, userId: ctx.auth?.userId });
64
+ });
51
65
  ```
52
66
 
67
+ `unregister`'s owner argument is **required**, and the row is removed only when
68
+ it carries that same owner. A subscription id is derived from the endpoint, so
69
+ `replacedEndpoint` is a caller-controlled key and nothing about it proves the
70
+ browser that sent it ever held the subscription it names — without the scope,
71
+ anyone who could guess or observe another user's endpoint could silence that
72
+ device. A row owned by someone else is left alone silently, so the call cannot be
73
+ used to probe which endpoints exist. Register with the same `userId` you
74
+ unregister with; devices registered anonymously (`userId` absent) all share the
75
+ one anonymous scope and get no separation from this check.
76
+
53
77
  ## Send (from an action)
54
78
 
55
79
  Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
@@ -87,12 +111,13 @@ await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body
87
111
  // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
88
112
  // `ctx.push` and `ctx.queues` exist.
89
113
  export const deliverPage = internalAction.input({ job: v.any() }).action(async ({ args: { job }, ctx }) => {
90
- const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
114
+ const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
91
115
 
92
- // One message = ONE bounded page. Discarding `nextCursor` delivers only the
116
+ // One message = ONE bounded page. Discarding `nextFilter` delivers only the
93
117
  // first page (default 250 devices) and reports success for the whole audience.
94
- if (nextCursor !== undefined) {
95
- await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: { ...job.filter, after: nextCursor } });
118
+ // Pass it verbatim — it carries the cursor AND the remaining `filter.limit`.
119
+ if (nextFilter !== undefined) {
120
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: nextFilter });
96
121
  }
97
122
 
98
123
  // Redeliver ONLY the recipients that failed — a retry of the whole page would
@@ -144,9 +169,11 @@ client-supplied data, so the facade enforces two boundaries:
144
169
  - **No secrets on the app facade.** `ctx.push.list()`
145
170
  returns the registered devices with the delivery **secrets stripped** — the Web
146
171
  Push `keys` (`auth`/`p256dh`) and the FCM `token`, which together with the
147
- endpoint are enough to deliver arbitrary push to a device. The raw rows are
148
- reachable only through the internal `SubscriptionStore` (which handlers never
149
- hold); the broadcast path uses the store directly.
172
+ endpoint are enough to deliver arbitrary push to a device. Every other facade
173
+ read is projected the same way; the broadcast path uses the store directly. The
174
+ one place a handler does see a raw row is the return of `ctx.push.register(...)`,
175
+ which echoes back the record the caller just supplied — nothing it did not
176
+ already hold, and never another device's.
150
177
 
151
178
  ## Delivery observability
152
179
 
package/dist/index.d.mts CHANGED
@@ -138,6 +138,30 @@ interface SubscriptionFilter {
138
138
  interface SubscriptionStore {
139
139
  /** Remove a subscription by id (idempotent). */
140
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>;
141
165
  /** Read a subscription by id, or `undefined`. */
142
166
  get: (id: string) => Promise<StoredSubscription | undefined>;
143
167
  /**
@@ -267,16 +291,49 @@ interface LunoraPush {
267
291
  * List stored subscriptions (optionally filtered), with the delivery
268
292
  * **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
269
293
  * FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
270
- * a device, so they never cross the app-facing facade; the raw rows are
294
+ * a device, so no READ on this facade returns them; the raw rows are otherwise
271
295
  * reachable only through the internal `SubscriptionStore`.
296
+ *
297
+ * {@link LunoraPush.register} is the one exception, and deliberately so: it
298
+ * echoes back the record the caller just supplied, so it discloses nothing
299
+ * the caller did not already hold and never another device's row.
272
300
  */
273
301
  list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
274
- /** Register (upsert) a device subscription and return the stored record. */
302
+ /** Register (upsert) a device subscription and return the stored record (the caller's own row, secrets included). */
275
303
  register: (input: RegisterInput) => Promise<StoredSubscription>;
276
304
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
277
305
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
278
- /** Remove a subscription by id (idempotent). */
279
- unregister: (id: string) => Promise<void>;
306
+ /**
307
+ * Remove ONE of `owner`'s subscriptions by id (idempotent).
308
+ *
309
+ * `owner` is not optional, and the removal happens only when the stored row
310
+ * carries that same owner. A subscription id is derived from the endpoint
311
+ * (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
312
+ * intended call is a mutation forwarding `subscribeToPush`'s
313
+ * `replacedEndpoint` after a VAPID rotation, and nothing about that argument
314
+ * proves the browser sending it ever held the subscription it names.
315
+ * Deleting by id alone let any caller that could guess or observe another
316
+ * user's endpoint silence that device's notifications (CWE-639).
317
+ *
318
+ * A row belonging to someone else is left alone SILENTLY rather than
319
+ * refused, so the call cannot be used to probe which endpoints exist — the
320
+ * same answer, and the same absence of a write, as an id that was never
321
+ * registered.
322
+ *
323
+ * `{ userId: null }` (or `undefined`, which normalises to it) addresses the
324
+ * anonymous rows — those registered with no `userId`. An app that registers
325
+ * every device anonymously therefore gets no separation from this check;
326
+ * pass `ctx.auth?.userId` and register with it to get any.
327
+ */
328
+ unregister: (id: string, owner: PushOwner) => Promise<void>;
329
+ }
330
+ /** Who a {@link LunoraPush.unregister} call is acting as. */
331
+ interface PushOwner {
332
+ /**
333
+ * The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
334
+ * an anonymous registration. Required — see {@link LunoraPush.unregister}.
335
+ */
336
+ userId: string | null | undefined;
280
337
  }
281
338
  /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
282
339
  type PushContent = Omit<PushPayload, "to">;
@@ -550,17 +607,33 @@ interface PushBroadcastJob {
550
607
  /**
551
608
  * One page's outcome plus the ids that need redelivering.
552
609
  *
553
- * The consumer MUST act on BOTH fields: `nextCursor` continues the broadcast and
610
+ * The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
554
611
  * `failedIds` redelivers the recipients this page missed. Acking a message while
555
612
  * ignoring either silently drops part of the audience.
556
613
  */
557
- interface PushBroadcastPageOutcome extends BroadcastPageResult {
614
+ interface PushBroadcastPageOutcome {
558
615
  /**
559
616
  * Subscriptions that failed transiently on this run (gone/pruned devices are
560
617
  * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
561
618
  * as `retryIds` to redeliver to just them.
562
619
  */
563
620
  failedIds: string[];
621
+ /**
622
+ * The filter for the CONTINUATION job, or `undefined` when the broadcast is
623
+ * finished (no further pages, or `filter.limit` is spent). Enqueue it
624
+ * verbatim — it carries the next page's cursor AND, when the job set
625
+ * `filter.limit`, the REMAINING budget.
626
+ *
627
+ * This replaces the raw `nextCursor` the runner used to return. Rebuilding
628
+ * the filter at the call site (`{ ...job.filter, after: nextCursor }`)
629
+ * forwarded the ORIGINAL `limit` to every message, so a `limit` documented
630
+ * as an overall audience cap (see {@link SubscriptionFilter.limit}, which
631
+ * `broadcast` honours as one) became a per-message cap and the walk reached
632
+ * the entire audience anyway.
633
+ */
634
+ nextFilter?: SubscriptionFilter;
635
+ /** This page's delivery result. */
636
+ result: BroadcastResult;
564
637
  }
565
638
  /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
566
639
  interface QueueProducerLike {
@@ -581,15 +654,13 @@ interface QueueProducerLike {
581
654
  * export const deliverPage = internalAction
582
655
  * .input({ job: v.any() })
583
656
  * .action(async ({ args: { job }, ctx }) => {
584
- * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
657
+ * const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
585
658
  *
586
- * if (nextCursor !== undefined) {
659
+ * if (nextFilter !== undefined) {
587
660
  * // More pages remain — enqueue the continuation. Each message still
588
- * // does only ONE bounded page of work.
589
- * await enqueuePushBroadcast(ctx.queues.push, {
590
- * filter: { ...job.filter, after: nextCursor },
591
- * payload: job.payload,
592
- * });
661
+ * // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
662
+ * // it carries the cursor and the remaining `limit` budget.
663
+ * await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
593
664
  * }
594
665
  *
595
666
  * if (failedIds.length > 0) {
@@ -628,21 +699,26 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
628
699
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
629
700
  * — so per-message work is bounded regardless of total audience size.
630
701
  * - A page NEVER throws for a partial failure. Throwing discarded the page's
631
- * `nextCursor`, which is the only way the broadcast advances: one device that
702
+ * continuation, which is the only way the broadcast advances: one device that
632
703
  * fails permanently (a rotated VAPID keypair leaves a stale device answering
633
704
  * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
634
705
  * every already-delivered recipient on each retry, dead-letter, and leave
635
- * every LATER page unreached. The page's `nextCursor` and its `failedIds`
706
+ * every LATER page unreached. The page's `nextFilter` and its `failedIds`
636
707
  * both come back instead.
637
- * - The CALLER re-enqueues: `filter.after:
638
- * nextCursor` while more pages remain, and a `retryIds: failedIds` job when
639
- * any recipient failed. `@lunora/notify` cannot do it itself — it has no
640
- * `@lunora/queue` dependency (the seam stays structural) and no reference to
641
- * the producer that enqueued this message. See the consumer example on
642
- * {@link enqueuePushBroadcast}.
643
- * - A `retryIds` job redelivers to exactly those ids and DOES throw while any
644
- * of them still fails, so the queue's backoff/dead-letter bounds it. That
645
- * message contains no already-delivered recipient, so nothing is re-sent.
708
+ * - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
709
+ * `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
710
+ * do it itself — it has no `@lunora/queue` dependency (the seam stays
711
+ * structural) and no reference to the producer that enqueued this message. See
712
+ * the consumer example on {@link enqueuePushBroadcast}.
713
+ * - `job.filter.limit` is spent across messages, not re-granted to each one:
714
+ * `nextFilter` carries the REMAINING budget and is `undefined` once it runs
715
+ * out, so `limit` caps the whole audience here exactly as it does on
716
+ * {@link LunoraPush.broadcast}.
717
+ * - A `retryIds` job redelivers to exactly those ids and throws only while ALL
718
+ * of them still fail, so the queue's backoff/dead-letter bounds a device that
719
+ * never recovers. Once any recipient recovers the run resolves and reports the
720
+ * rest in `failedIds`, so the narrower retry never re-sends to a device this
721
+ * message already reached.
646
722
  * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
647
723
  * never appear in `failedIds` — an all-`pruned` page is a success, not a
648
724
  * failure, as is an empty page.
@@ -760,13 +836,21 @@ declare const targetOf: (subscription: StoredSubscription) => string;
760
836
  * (the browser/device unsubscribed) and should be pruned — as opposed to a
761
837
  * transient failure worth retrying.
762
838
  *
763
- * Gates on STRUCTURED signals first: a Web Push `HTTP 404/410` status or an FCM
764
- * `UNREGISTERED`/`NOT_REGISTERED` code, both of which the providers surface in
765
- * their failure receipts. The free-text {@link GONE_TEXT_FALLBACK} is a tightened
766
- * last resort only, so a transient error that happens to contain `expired`
767
- * (a cert/session expiry) can never permanently drop a valid subscription.
768
- */
769
- declare const isGoneError: (message: string | undefined) => boolean;
839
+ * Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
840
+ * answer one for a dead endpoint/token) or, for FCM only, an
841
+ * `UNREGISTERED`/`NOT_REGISTERED` code. The free-text
842
+ * {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a transient
843
+ * error that happens to contain `expired` (a cert/session expiry) can never
844
+ * permanently drop a valid subscription.
845
+ *
846
+ * `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
847
+ * The web-push provider echoes the push service's response body into
848
+ * `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
849
+ * registered" matched the FCM-only codes and permanently deleted a live
850
+ * subscription. Omit `kind` (the third-party/unknown-provider case) to test
851
+ * every pattern, as before.
852
+ */
853
+ declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
770
854
  export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
771
855
  /**
772
856
  * `@lunora/notify`
@@ -786,7 +870,7 @@ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions,
786
870
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
787
871
  * @packageDocumentation
788
872
  */
789
- 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 PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
873
+ 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,
790
874
  /**
791
875
  * `@lunora/notify`
792
876
  *
package/dist/index.d.ts CHANGED
@@ -138,6 +138,30 @@ interface SubscriptionFilter {
138
138
  interface SubscriptionStore {
139
139
  /** Remove a subscription by id (idempotent). */
140
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>;
141
165
  /** Read a subscription by id, or `undefined`. */
142
166
  get: (id: string) => Promise<StoredSubscription | undefined>;
143
167
  /**
@@ -267,16 +291,49 @@ interface LunoraPush {
267
291
  * List stored subscriptions (optionally filtered), with the delivery
268
292
  * **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
269
293
  * FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
270
- * a device, so they never cross the app-facing facade; the raw rows are
294
+ * a device, so no READ on this facade returns them; the raw rows are otherwise
271
295
  * reachable only through the internal `SubscriptionStore`.
296
+ *
297
+ * {@link LunoraPush.register} is the one exception, and deliberately so: it
298
+ * echoes back the record the caller just supplied, so it discloses nothing
299
+ * the caller did not already hold and never another device's row.
272
300
  */
273
301
  list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
274
- /** Register (upsert) a device subscription and return the stored record. */
302
+ /** Register (upsert) a device subscription and return the stored record (the caller's own row, secrets included). */
275
303
  register: (input: RegisterInput) => Promise<StoredSubscription>;
276
304
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
277
305
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
278
- /** Remove a subscription by id (idempotent). */
279
- unregister: (id: string) => Promise<void>;
306
+ /**
307
+ * Remove ONE of `owner`'s subscriptions by id (idempotent).
308
+ *
309
+ * `owner` is not optional, and the removal happens only when the stored row
310
+ * carries that same owner. A subscription id is derived from the endpoint
311
+ * (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
312
+ * intended call is a mutation forwarding `subscribeToPush`'s
313
+ * `replacedEndpoint` after a VAPID rotation, and nothing about that argument
314
+ * proves the browser sending it ever held the subscription it names.
315
+ * Deleting by id alone let any caller that could guess or observe another
316
+ * user's endpoint silence that device's notifications (CWE-639).
317
+ *
318
+ * A row belonging to someone else is left alone SILENTLY rather than
319
+ * refused, so the call cannot be used to probe which endpoints exist — the
320
+ * same answer, and the same absence of a write, as an id that was never
321
+ * registered.
322
+ *
323
+ * `{ userId: null }` (or `undefined`, which normalises to it) addresses the
324
+ * anonymous rows — those registered with no `userId`. An app that registers
325
+ * every device anonymously therefore gets no separation from this check;
326
+ * pass `ctx.auth?.userId` and register with it to get any.
327
+ */
328
+ unregister: (id: string, owner: PushOwner) => Promise<void>;
329
+ }
330
+ /** Who a {@link LunoraPush.unregister} call is acting as. */
331
+ interface PushOwner {
332
+ /**
333
+ * The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
334
+ * an anonymous registration. Required — see {@link LunoraPush.unregister}.
335
+ */
336
+ userId: string | null | undefined;
280
337
  }
281
338
  /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
282
339
  type PushContent = Omit<PushPayload, "to">;
@@ -550,17 +607,33 @@ interface PushBroadcastJob {
550
607
  /**
551
608
  * One page's outcome plus the ids that need redelivering.
552
609
  *
553
- * The consumer MUST act on BOTH fields: `nextCursor` continues the broadcast and
610
+ * The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
554
611
  * `failedIds` redelivers the recipients this page missed. Acking a message while
555
612
  * ignoring either silently drops part of the audience.
556
613
  */
557
- interface PushBroadcastPageOutcome extends BroadcastPageResult {
614
+ interface PushBroadcastPageOutcome {
558
615
  /**
559
616
  * Subscriptions that failed transiently on this run (gone/pruned devices are
560
617
  * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
561
618
  * as `retryIds` to redeliver to just them.
562
619
  */
563
620
  failedIds: string[];
621
+ /**
622
+ * The filter for the CONTINUATION job, or `undefined` when the broadcast is
623
+ * finished (no further pages, or `filter.limit` is spent). Enqueue it
624
+ * verbatim — it carries the next page's cursor AND, when the job set
625
+ * `filter.limit`, the REMAINING budget.
626
+ *
627
+ * This replaces the raw `nextCursor` the runner used to return. Rebuilding
628
+ * the filter at the call site (`{ ...job.filter, after: nextCursor }`)
629
+ * forwarded the ORIGINAL `limit` to every message, so a `limit` documented
630
+ * as an overall audience cap (see {@link SubscriptionFilter.limit}, which
631
+ * `broadcast` honours as one) became a per-message cap and the walk reached
632
+ * the entire audience anyway.
633
+ */
634
+ nextFilter?: SubscriptionFilter;
635
+ /** This page's delivery result. */
636
+ result: BroadcastResult;
564
637
  }
565
638
  /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
566
639
  interface QueueProducerLike {
@@ -581,15 +654,13 @@ interface QueueProducerLike {
581
654
  * export const deliverPage = internalAction
582
655
  * .input({ job: v.any() })
583
656
  * .action(async ({ args: { job }, ctx }) => {
584
- * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
657
+ * const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
585
658
  *
586
- * if (nextCursor !== undefined) {
659
+ * if (nextFilter !== undefined) {
587
660
  * // More pages remain — enqueue the continuation. Each message still
588
- * // does only ONE bounded page of work.
589
- * await enqueuePushBroadcast(ctx.queues.push, {
590
- * filter: { ...job.filter, after: nextCursor },
591
- * payload: job.payload,
592
- * });
661
+ * // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
662
+ * // it carries the cursor and the remaining `limit` budget.
663
+ * await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
593
664
  * }
594
665
  *
595
666
  * if (failedIds.length > 0) {
@@ -628,21 +699,26 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
628
699
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
629
700
  * — so per-message work is bounded regardless of total audience size.
630
701
  * - A page NEVER throws for a partial failure. Throwing discarded the page's
631
- * `nextCursor`, which is the only way the broadcast advances: one device that
702
+ * continuation, which is the only way the broadcast advances: one device that
632
703
  * fails permanently (a rotated VAPID keypair leaves a stale device answering
633
704
  * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
634
705
  * every already-delivered recipient on each retry, dead-letter, and leave
635
- * every LATER page unreached. The page's `nextCursor` and its `failedIds`
706
+ * every LATER page unreached. The page's `nextFilter` and its `failedIds`
636
707
  * both come back instead.
637
- * - The CALLER re-enqueues: `filter.after:
638
- * nextCursor` while more pages remain, and a `retryIds: failedIds` job when
639
- * any recipient failed. `@lunora/notify` cannot do it itself — it has no
640
- * `@lunora/queue` dependency (the seam stays structural) and no reference to
641
- * the producer that enqueued this message. See the consumer example on
642
- * {@link enqueuePushBroadcast}.
643
- * - A `retryIds` job redelivers to exactly those ids and DOES throw while any
644
- * of them still fails, so the queue's backoff/dead-letter bounds it. That
645
- * message contains no already-delivered recipient, so nothing is re-sent.
708
+ * - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
709
+ * `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
710
+ * do it itself — it has no `@lunora/queue` dependency (the seam stays
711
+ * structural) and no reference to the producer that enqueued this message. See
712
+ * the consumer example on {@link enqueuePushBroadcast}.
713
+ * - `job.filter.limit` is spent across messages, not re-granted to each one:
714
+ * `nextFilter` carries the REMAINING budget and is `undefined` once it runs
715
+ * out, so `limit` caps the whole audience here exactly as it does on
716
+ * {@link LunoraPush.broadcast}.
717
+ * - A `retryIds` job redelivers to exactly those ids and throws only while ALL
718
+ * of them still fail, so the queue's backoff/dead-letter bounds a device that
719
+ * never recovers. Once any recipient recovers the run resolves and reports the
720
+ * rest in `failedIds`, so the narrower retry never re-sends to a device this
721
+ * message already reached.
646
722
  * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
647
723
  * never appear in `failedIds` — an all-`pruned` page is a success, not a
648
724
  * failure, as is an empty page.
@@ -760,13 +836,21 @@ declare const targetOf: (subscription: StoredSubscription) => string;
760
836
  * (the browser/device unsubscribed) and should be pruned — as opposed to a
761
837
  * transient failure worth retrying.
762
838
  *
763
- * Gates on STRUCTURED signals first: a Web Push `HTTP 404/410` status or an FCM
764
- * `UNREGISTERED`/`NOT_REGISTERED` code, both of which the providers surface in
765
- * their failure receipts. The free-text {@link GONE_TEXT_FALLBACK} is a tightened
766
- * last resort only, so a transient error that happens to contain `expired`
767
- * (a cert/session expiry) can never permanently drop a valid subscription.
768
- */
769
- declare const isGoneError: (message: string | undefined) => boolean;
839
+ * Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
840
+ * answer one for a dead endpoint/token) or, for FCM only, an
841
+ * `UNREGISTERED`/`NOT_REGISTERED` code. The free-text
842
+ * {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a transient
843
+ * error that happens to contain `expired` (a cert/session expiry) can never
844
+ * permanently drop a valid subscription.
845
+ *
846
+ * `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
847
+ * The web-push provider echoes the push service's response body into
848
+ * `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
849
+ * registered" matched the FCM-only codes and permanently deleted a live
850
+ * subscription. Omit `kind` (the third-party/unknown-provider case) to test
851
+ * every pattern, as before.
852
+ */
853
+ declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
770
854
  export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
771
855
  /**
772
856
  * `@lunora/notify`
@@ -786,7 +870,7 @@ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions,
786
870
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
787
871
  * @packageDocumentation
788
872
  */
789
- 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 PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
873
+ 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,
790
874
  /**
791
875
  * `@lunora/notify`
792
876
  *
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-BWRG-JIC.mjs";import{buildEngine as a,routingPushProvider as d}from"./packem_shared/buildEngine-oDWs9Pom.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-B6v3DLLp.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-BjvQlFZW.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-w_dCcy6d.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-Bt_7V8FU.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
1
+ import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-DmRk1CHx.mjs";import{buildEngine as a,routingPushProvider as d}from"./packem_shared/buildEngine-zGk_1q8H.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-COksdUlz.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-Cpi56NIh.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-DiVmkZEp.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-hLwzY2K2.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
@@ -1 +1 @@
1
- import{LunoraError as k}from"@lunora/errors";import{createNotification as A}from"@visulima/notification";import{retryMiddleware as E,circuitBreakerMiddleware as I}from"@visulima/notification/middleware";import{fcmProvider as R}from"@visulima/notification/providers/fcm";import{webPushProvider as T}from"@visulima/notification/providers/web-push";import{n as D,p as h,a as _,b as S}from"./ssrf-host-BCpHorGa.mjs";const N=(e,n)=>{if(e.size<n)return;const t=e.keys().next().value;t!==void 0&&e.delete(t)},O="https://cloudflare-dns.com/dns-query",u=1,l=28,C=2e3,M=(e,n)=>{if(n===u){const t=h(e);return t===void 0||_(t)}return S(e.toLowerCase())},f=async(e,n,t)=>{try{const r=await fetch(`${O}?name=${encodeURIComponent(e)}&type=${String(n)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(t)});return r.ok?(await r.json()).Answer??[]:void 0}catch{return}},z=async(e,n=C)=>{const t=D(e);if(t.includes(":")||h(t)!==void 0)return{kind:"unknown"};const[r,i]=await Promise.all([f(t,u,n),f(t,l,n)]);if(r===void 0&&i===void 0)return{kind:"unknown"};for(const o of[...r??[],...i??[]])if((o.type===u||o.type===l)&&M(o.data,o.type))return{address:o.data,kind:"private"};return{kind:"public"}},$=e=>{let n=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{n=JSON.parse(e)}catch{return}}const t=n?.endpoint;return typeof t=="string"?t:void 0},d=new Map,j=256,H=async(e,n)=>{if(n!==void 0&&n.length>0)return;let t;try{({hostname:t}=new URL(e))}catch{return}const r=d.get(t),i=r??z(t),o=await i;if(r===void 0&&o.kind!=="unknown"&&(N(d,j),d.set(t,i)),o.kind==="private")throw new k("FORBIDDEN",`@lunora/notify: web-push endpoint host "${t}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`)},L=(e,n)=>{const t=[e.provider,n.provider].filter(i=>i!==void 0),r=[...e.recipients??[],...n.recipients??[]];return{...e,messageId:[e.messageId,n.messageId].join(","),response:[e.response,n.response],sent:e.sent&&n.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),n.timestamp.getTime())),...t.length>0?{provider:t.join(",")}:{},...r.length>0?{recipients:r}:{}}},x=(e,n)=>{if(!e.success||!n.success)return e.success||n.success?e.success?n:e:{error:new AggregateError([e.error,n.error],"@lunora/notify: both push target groups failed"),success:!1};const t=e.data===void 0||n.data===void 0?e.data??n.data:L(e.data,n.data);return t===void 0?{success:!0}:{data:t,success:!0}},B=e=>{const n=t=>{const r=t===void 0?e.fcm:e.webPush;if(r===void 0)throw new Error(t===void 0?"@lunora/notify: received an FCM token target but no `fcm` channel is configured":"@lunora/notify: received a web-push target but no `webPush` channel is configured");return r};return{channel:"push",id:"lunora-push-router",initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:async t=>{const r=Array.isArray(t.to)?t.to:[t.to],i=r.map(s=>$(s));for(const s of i)s!==void 0&&await H(s,e.allowedPushOrigins);const o=r.filter((s,a)=>i[a]!==void 0),p=r.filter((s,a)=>i[a]===void 0),c=i.find(s=>s!==void 0);if(p.length===0&&c!==void 0)return n(c).send(t);if(o.length===0)return n(void 0).send(t);const m=s=>({...t,to:s.length===1&&s[0]!==void 0?s[0]:s}),w=n(c),g=n(void 0),v=async(s,a)=>s.send(m(a)),b=await Promise.allSettled([v(w,o),v(g,p)]),[P,y]=b.map(s=>s.status==="fulfilled"?s.value:{error:s.reason,success:!1});return x(P,y)}}},J=e=>{const n=e.webPush===void 0?void 0:T(e.webPush),t=e.fcm===void 0?void 0:R(e.fcm),r={};(n!==void 0||t!==void 0)&&(r.push=B({allowedPushOrigins:e.allowedPushOrigins,fcm:t,webPush:n})),e.chat!==void 0&&(r.chat=e.chat),e.inApp!==void 0&&(r.inapp=e.inApp),e.webhook!==void 0&&(r.webhook=e.webhook);const i=A(r);return i.use(E()).use(I()),i};export{J as buildEngine,B as routingPushProvider};
1
+ import{LunoraError as h}from"@lunora/errors";import{createNotification as A}from"@visulima/notification";import{retryMiddleware as E,circuitBreakerMiddleware as I}from"@visulima/notification/middleware";import{fcmProvider as R}from"@visulima/notification/providers/fcm";import{webPushProvider as T}from"@visulima/notification/providers/web-push";import{n as D,p as m,a as _,b as S}from"./ssrf-host-BCpHorGa.mjs";const N=(e,n)=>{if(e.size<n)return;const t=e.keys().next().value;t!==void 0&&e.delete(t)},O="https://cloudflare-dns.com/dns-query",u=1,v=28,C=2e3,M=(e,n)=>{if(n===u){const t=m(e);return t===void 0||_(t)}return S(e.toLowerCase())},f=async(e,n,t)=>{try{const r=await fetch(`${O}?name=${encodeURIComponent(e)}&type=${String(n)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(t)});return r.ok?(await r.json()).Answer??[]:void 0}catch{return}},z=async(e,n=C)=>{const t=D(e);if(t.includes(":")||m(t)!==void 0)return{kind:"unknown"};const[r,i]=await Promise.all([f(t,u,n),f(t,v,n)]);if(r===void 0&&i===void 0)return{kind:"unknown"};for(const o of[...r??[],...i??[]])if((o.type===u||o.type===v)&&M(o.data,o.type))return{address:o.data,kind:"private"};return{kind:"public"}},$=e=>{let n=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{n=JSON.parse(e)}catch{return}}const t=n?.endpoint;return typeof t=="string"?t:void 0},d=new Map,j=256,B=async(e,n)=>{if(n!==void 0&&n.length>0)return;let t;try{({hostname:t}=new URL(e))}catch{return}const r=d.get(t),i=r??z(t),o=await i;if(r===void 0&&o.kind!=="unknown"&&(N(d,j),d.set(t,i)),o.kind==="private")throw new h("FORBIDDEN",`@lunora/notify: web-push endpoint host "${t}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`)},H=(e,n)=>{const t=[e.provider,n.provider].filter(i=>i!==void 0),r=[...e.recipients??[],...n.recipients??[]];return{...e,messageId:[e.messageId,n.messageId].join(","),response:[e.response,n.response],sent:e.sent&&n.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),n.timestamp.getTime())),...t.length>0?{provider:t.join(",")}:{},...r.length>0?{recipients:r}:{}}},L=(e,n)=>{if(!e.success||!n.success)return e.success||n.success?e.success?n:e:{error:new AggregateError([e.error,n.error],"@lunora/notify: both push target groups failed"),success:!1};const t=e.data===void 0||n.data===void 0?e.data??n.data:H(e.data,n.data);return t===void 0?{success:!0}:{data:t,success:!0}},U=e=>{const n=t=>{const r=t===void 0?e.fcm:e.webPush;if(r===void 0)throw new Error(t===void 0?"@lunora/notify: received an FCM token target but no `fcm` channel is configured":"@lunora/notify: received a web-push target but no `webPush` channel is configured");return r};return{channel:"push",id:"lunora-push-router",initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:async t=>{const r=Array.isArray(t.to)?t.to:[t.to];if(r.length===0)throw new h("BAD_REQUEST","@lunora/notify: push send has no recipients — `to` is an empty array");const i=r.map(s=>$(s));for(const s of i)s!==void 0&&await B(s,e.allowedPushOrigins);const o=r.filter((s,a)=>i[a]!==void 0),p=r.filter((s,a)=>i[a]===void 0),c=i.find(s=>s!==void 0);if(p.length===0&&c!==void 0)return n(c).send(t);if(o.length===0)return n(void 0).send(t);const w=s=>({...t,to:s.length===1&&s[0]!==void 0?s[0]:s}),g=n(c),b=n(void 0),l=async(s,a)=>s.send(w(a)),y=await Promise.allSettled([l(g,o),l(b,p)]),[P,k]=y.map(s=>s.status==="fulfilled"?s.value:{error:s.reason,success:!1});return L(P,k)}}},J=e=>{const n=e.webPush===void 0?void 0:T(e.webPush),t=e.fcm===void 0?void 0:R(e.fcm),r={};(n!==void 0||t!==void 0)&&(r.push=U({allowedPushOrigins:e.allowedPushOrigins,fcm:t,webPush:n})),e.chat!==void 0&&(r.chat=e.chat),e.inApp!==void 0&&(r.inapp=e.inApp),e.webhook!==void 0&&(r.webhook=e.webhook);const i=A(r);return i.use(E()).use(I()),i};export{J as buildEngine,U as routingPushProvider};
@@ -0,0 +1 @@
1
+ import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-zGk_1q8H.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-DiVmkZEp.mjs";import{normalizeRegisterInput as R,targetOf as B,isGoneError as L}from"./fcmId-hLwzY2K2.mjs";const W=250,w=(o,l)=>typeof o=="function"?o(l):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),U=(o,l,d)=>o.successful?"accepted":L(l,d)?"gone":"failed",j=async(o,l,d)=>{const u=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,u[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(l,o.length)},()=>g())),u},G=(o,l)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,l),fcm:w(o.fcm,l),inApp:w(o.inApp,l),webhook:w(o.webhook,l),webPush:w(o.webPush,l)}),x=new WeakMap,Q=(o,l)=>{let d=x.get(o);d===void 0&&(d=new WeakMap,x.set(o,d));let u=d.get(l);return u===void 0&&(u={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(l,u)),u},Y=(o,l,d={})=>{const u=Q(o,l);let h;d.engine===void 0?(u.engine??=z(G(o,l)),h=u.engine):h=d.engine,u.store??=o.store?.(l);let{store:g}=u;g===void 0&&(u.fallbackStore??=F(),!d.silent&&!u.warnedNoStore&&(u.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=u.fallbackStore);const f=g,C=Math.max(1,d.concurrency??o.concurrency??10),b=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??W),{log:M,metrics:P}=d,p=(e,t,r,n=1)=>{P?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{M?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{P?.count("notify.skipped",1,{channel:e,reason:t})},I=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||u.warnedNoPushOriginAllowlist||(u.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},T=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),_=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},O=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a,e.kind)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},D=async(e,t)=>{const r=await j(t,C,async s=>{const{error:c,status:i}=await O(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},E=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await E(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:E,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await O(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:async(e,t)=>{await f.deleteOwned(e,t.userId??null)}},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};
@@ -0,0 +1 @@
1
+ import{LunoraError as h}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-hLwzY2K2.mjs";const N=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},L=(e,i={})=>{const a=i.tableName??"lunora_push_subscriptions";if(!N(a))throw new h("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${a}" is not a bare SQL identifier`);let s;const r=()=>(s===void 0&&(s=e.prepare(`CREATE TABLE IF NOT EXISTS ${a} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${a}_user_id_idx ON ${a} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${a}_kind_idx ON ${a} (kind)`).run()).then(()=>{}),s.catch(()=>{s=void 0})),s),l=async t=>{await r();const n=await e.prepare(`SELECT * FROM ${a} WHERE id = ?1`).bind(t).first();return n===null?void 0:E(n)};return{delete:async t=>{await r(),await e.prepare(`DELETE FROM ${a} WHERE id = ?1`).bind(t).run()},deleteOwned:async(t,n)=>(await r(),await(n===null?e.prepare(`DELETE FROM ${a} WHERE id = ?1 AND user_id IS NULL RETURNING id`).bind(t):e.prepare(`DELETE FROM ${a} WHERE id = ?1 AND user_id = ?2 RETURNING id`).bind(t,n)).first()!==null),get:l,list:async t=>{await r();const n=[],d=[];t?.kind!==void 0&&(d.push(t.kind),n.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?n.push("user_id IS NULL"):(d.push(t.userId),n.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),n.push(`id > ?${d.length.toString()}`));const o=n.length===0?"":` WHERE ${n.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:p}=await e.prepare(`SELECT * FROM ${a}${o}${T}${u}`).bind(...d).all();return p.map(_=>E(_))},markStatus:async(t,n,d)=>{await r(),await e.prepare(`UPDATE ${a} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,n,d??null,Date.now()).run()},put:async t=>{await r(),await e.prepare(`INSERT INTO ${a} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const n=S(t);return n!==void 0&&n!==t.id&&await e.prepare(`DELETE FROM ${a} WHERE id = ?1`).bind(n).run(),await l(t.id)??t}}};export{L as d1SubscriptionStore};
@@ -0,0 +1 @@
1
+ import{LunoraError as u}from"@lunora/errors";const l=e=>e.filter(t=>t.status==="failed").map(t=>t.id),g=(e,t)=>e.send({...t,type:"lunora.push.broadcast"}),c=(e,t,s)=>{if(t===void 0)return;if(e?.limit===void 0)return{...e,after:t};const r=e.limit-s;return r>0?{...e,after:t,limit:r}:void 0},f=async(e,t,s)=>{const r=[];for(const a of s)try{const o=await e.send(a,t);r.push({id:a,status:o.successful?"ok":"failed"})}catch(o){r.push({error:o instanceof Error?o.message:String(o),id:a,status:"failed"})}const n=l(r),i=r.length-n.length,d={failed:n.length,outcomes:r,pruned:0,sent:i,total:r.length};if(i===0&&n.length>0)throw new u("INTERNAL",`@lunora/notify: push retry failed for ${n.length.toString()} of ${r.length.toString()} subscription(s) — throwing so the queue retries and eventually dead-letters them`);return{failedIds:n,nextFilter:void 0,result:d}},p=async(e,t)=>{if(t.retryIds!==void 0&&t.retryIds.length>0)return f(e,t.payload,t.retryIds);const s=await e.broadcastPage(t.payload,t.filter);return{failedIds:l(s.result.outcomes),nextFilter:c(t.filter,s.nextCursor,s.result.total),result:s.result}};export{g as enqueuePushBroadcast,p as runPushBroadcastPage};
@@ -0,0 +1 @@
1
+ import{LunoraError as i}from"@lunora/errors";import{i as _}from"./ssrf-host-BCpHorGa.mjs";const b=2166136261,T=16777619,k=(t,e=b)=>{let n=e;for(let o=0;o<t.length;o+=1)n^=t.charCodeAt(o),n=Math.imul(n,T);return(n>>>0).toString(16).padStart(8,"0")},l=t=>t.toString(16).padStart(4,"0"),E=t=>{let e=8997,n=33826,o=40164,s=52210;for(let r=0;r<t.length;r+=1){e^=t.charCodeAt(r);const a=e*435,d=n*435,c=o*435+e*256,S=s*435+n*256,f=d+(a>>>16),u=c+(f>>>16),A=S+(u>>>16);e=a&65535,n=f&65535,o=u&65535,s=A&65535}return l(s)+l(o)+l(n)+l(e)},g=4096,m=2048,O=2048,p=512,h=(t,e,n)=>{const o=new TextEncoder().encode(t).length;if(o>e)throw new i("BAD_REQUEST",`@lunora/notify: register() \`${n}\` is ${o.toString()} bytes, exceeding the ${e.toString()}-byte cap`)},y=t=>{if(t===void 0)return;const e=typeof t=="object"&&t!==null?Object.getPrototypeOf(t):void 0;if(!(typeof t=="object"&&t!==null&&!Array.isArray(t)&&(e===Object.prototype||e===null)))throw new i("BAD_REQUEST","@lunora/notify: register() `metadata` must be a plain object");let o;try{o=JSON.stringify(t)}catch(r){throw new i("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is not JSON-serialisable: ${r instanceof Error?r.message:String(r)}`)}const s=new TextEncoder().encode(o).length;if(s>g)throw new i("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is ${s.toString()} bytes, exceeding the ${g.toString()}-byte cap`);return t},v=t=>`wp2_${E(t)}`,B=t=>`fcm2_${E(t)}`,w=t=>k(t),N=t=>`wp_${w(t)}`,R=t=>`fcm_${w(t)}`,M=t=>t.kind==="fcm"?t.token===void 0?void 0:R(t.token):t.endpoint===void 0?void 0:N(t.endpoint),D=t=>{if(typeof t!="string")return t??{};try{return JSON.parse(t)}catch(e){throw new i("BAD_REQUEST",`@lunora/notify: register() web-push subscription is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}},I=(t,e)=>{let n;try{n=new URL(t)}catch{throw new i("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must be an absolute https URL (got "${t}")`)}if(n.protocol!=="https:")throw new i("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must use https (got "${n.protocol}")`);if(e!==void 0&&e.length>0){if(!e.includes(n.origin))throw new i("FORBIDDEN",`@lunora/notify: register() web-push endpoint origin "${n.origin}" is not in the configured allowedPushOrigins allowlist`);return}if(_(n.hostname))throw new i("FORBIDDEN",`@lunora/notify: register() web-push endpoint host "${n.hostname}" is a private/internal address; configure allowedPushOrigins to permit a specific origin`)},Q=(t,e=Date.now(),n={})=>{if(t.kind===void 0?t.token!==void 0:t.kind==="fcm"){const{token:c}=t;if(typeof c!="string"||c==="")throw new i("BAD_REQUEST","@lunora/notify: register() fcm input requires a non-empty `token`");return h(c,O,"token"),{createdAt:e,id:B(c),kind:"fcm",lastSeenAt:e,metadata:y(t.metadata),token:c,userId:t.userId??null}}const s=D(t.subscription),{endpoint:r}=s,a=s.keys?.p256dh,d=s.keys?.auth;if(typeof r!="string"||r===""||typeof a!="string"||typeof d!="string")throw new i("BAD_REQUEST","@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");return h(r,m,"endpoint"),h(d,p,"keys.auth"),h(a,p,"keys.p256dh"),I(r,n.allowedPushOrigins),{createdAt:e,endpoint:r,id:v(r),keys:{auth:d,p256dh:a},kind:"web-push",lastSeenAt:e,metadata:y(t.metadata),userId:t.userId??null}},j=t=>t.kind==="fcm"?t.token??"":JSON.stringify({endpoint:t.endpoint,keys:t.keys}),$=/\bhttp\s*4(?:04|10)\b/iu,F=/\b(?:unregistered|not[\s-]?registered|registration-token-not-registered)\b/iu,P=/\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu,L=(t,e)=>t===void 0?!1:$.test(t)||P.test(t)?!0:e!=="web-push"&&F.test(t);export{B as fcmId,L as isGoneError,R as legacyFcmId,M as legacyIdFor,N as legacyWebPushId,Q as normalizeRegisterInput,j as targetOf,v as webPushId};
@@ -0,0 +1 @@
1
+ import{legacyIdFor as n}from"./fcmId-hLwzY2K2.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,u=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),l=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),deleteOwned:(e,r)=>{const o=t.get(e);return o===void 0||(o.userId??null)!==r?Promise.resolve(!1):(t.delete(e),Promise.resolve(!0))},get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())u(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{l as memorySubscriptionStore};
package/dist/web.d.mts CHANGED
@@ -6,6 +6,29 @@ interface SerializedPushSubscription {
6
6
  p256dh: string;
7
7
  };
8
8
  }
9
+ /**
10
+ * What {@link subscribeToPush} returns: the (new or reused) subscription, plus
11
+ * the endpoint of the one it replaced, when it replaced one.
12
+ */
13
+ interface SubscribeToPushResult {
14
+ /**
15
+ * The endpoint of the subscription this call dropped — set ONLY on the
16
+ * VAPID-rotation path, where the stale subscription is unsubscribed and a
17
+ * new one minted under the current key.
18
+ *
19
+ * Send it to the server and unregister it — owner-scoped, since this is a
20
+ * caller-supplied key
21
+ * (`ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId })`). The new subscription
22
+ * carries a NEW endpoint, hence a new store id, so it never upserts over the
23
+ * old row — and `403 VapidPkHashMismatch`, which every send to that row now
24
+ * answers, is correctly not a "gone" signal, so nothing prunes it either.
25
+ * Dropped instead of returned, the row is billed a POST and a write on every
26
+ * later broadcast, forever.
27
+ */
28
+ replacedEndpoint?: string;
29
+ /** The active subscription, in the serialisable shape `ctx.push.register` accepts. */
30
+ subscription: SerializedPushSubscription;
31
+ }
9
32
  /** Options for {@link subscribeToPush}. */
10
33
  interface SubscribeToPushOptions {
11
34
  /**
@@ -24,14 +47,20 @@ interface SubscribeToPushOptions {
24
47
  */
25
48
  vapidPublicKey: string;
26
49
  }
27
- /** Whether the current browser supports the Web Push flow (service workers + Push API). */
50
+ /**
51
+ * Whether the current browser supports the Web Push flow (service workers +
52
+ * Push API + the Notifications API). `Notification` is part of the check because
53
+ * {@link subscribeToPush} calls `Notification.requestPermission()` — without it
54
+ * a browser missing the API got a bare `ReferenceError` rather than the
55
+ * "not supported" error this predicate exists to produce.
56
+ */
28
57
  declare const isPushSupported: () => boolean;
29
58
  /**
30
59
  * Register (or reuse) a service worker and subscribe the browser to Web Push,
31
60
  * returning the subscription in serialisable form. Reuses an existing subscription
32
61
  * when present. Throws if push is unsupported or the user denies permission.
33
62
  */
34
- declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SerializedPushSubscription>;
63
+ declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SubscribeToPushResult>;
35
64
  /** Unsubscribe the browser's current Web Push subscription. Returns whether one was removed. */
36
65
  declare const unsubscribeFromPush: () => Promise<boolean>;
37
- export { type SerializedPushSubscription, type SubscribeToPushOptions, isPushSupported, subscribeToPush, unsubscribeFromPush };
66
+ export { type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
package/dist/web.d.ts CHANGED
@@ -6,6 +6,29 @@ interface SerializedPushSubscription {
6
6
  p256dh: string;
7
7
  };
8
8
  }
9
+ /**
10
+ * What {@link subscribeToPush} returns: the (new or reused) subscription, plus
11
+ * the endpoint of the one it replaced, when it replaced one.
12
+ */
13
+ interface SubscribeToPushResult {
14
+ /**
15
+ * The endpoint of the subscription this call dropped — set ONLY on the
16
+ * VAPID-rotation path, where the stale subscription is unsubscribed and a
17
+ * new one minted under the current key.
18
+ *
19
+ * Send it to the server and unregister it — owner-scoped, since this is a
20
+ * caller-supplied key
21
+ * (`ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId })`). The new subscription
22
+ * carries a NEW endpoint, hence a new store id, so it never upserts over the
23
+ * old row — and `403 VapidPkHashMismatch`, which every send to that row now
24
+ * answers, is correctly not a "gone" signal, so nothing prunes it either.
25
+ * Dropped instead of returned, the row is billed a POST and a write on every
26
+ * later broadcast, forever.
27
+ */
28
+ replacedEndpoint?: string;
29
+ /** The active subscription, in the serialisable shape `ctx.push.register` accepts. */
30
+ subscription: SerializedPushSubscription;
31
+ }
9
32
  /** Options for {@link subscribeToPush}. */
10
33
  interface SubscribeToPushOptions {
11
34
  /**
@@ -24,14 +47,20 @@ interface SubscribeToPushOptions {
24
47
  */
25
48
  vapidPublicKey: string;
26
49
  }
27
- /** Whether the current browser supports the Web Push flow (service workers + Push API). */
50
+ /**
51
+ * Whether the current browser supports the Web Push flow (service workers +
52
+ * Push API + the Notifications API). `Notification` is part of the check because
53
+ * {@link subscribeToPush} calls `Notification.requestPermission()` — without it
54
+ * a browser missing the API got a bare `ReferenceError` rather than the
55
+ * "not supported" error this predicate exists to produce.
56
+ */
28
57
  declare const isPushSupported: () => boolean;
29
58
  /**
30
59
  * Register (or reuse) a service worker and subscribe the browser to Web Push,
31
60
  * returning the subscription in serialisable form. Reuses an existing subscription
32
61
  * when present. Throws if push is unsupported or the user denies permission.
33
62
  */
34
- declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SerializedPushSubscription>;
63
+ declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SubscribeToPushResult>;
35
64
  /** Unsubscribe the browser's current Web Push subscription. Returns whether one was removed. */
36
65
  declare const unsubscribeFromPush: () => Promise<boolean>;
37
- export { type SerializedPushSubscription, type SubscribeToPushOptions, isPushSupported, subscribeToPush, unsubscribeFromPush };
66
+ export { type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
package/dist/web.mjs CHANGED
@@ -1 +1 @@
1
- const l=r=>{const e=atob(r),s=new Uint8Array(e.length);for(let t=0;t<e.length;t+=1)s[t]=e.codePointAt(t)??0;return s},u=r=>{const e=r.replaceAll("-","+").replaceAll("_","/"),s=e+"=".repeat((4-e.length%4)%4);return l(s)},o=r=>u(r),b=(r,e)=>r.length===e.length&&r.every((s,t)=>s===e[t]),g=(r,e)=>{const s=r.options.applicationServerKey;return s===null?!1:b(new Uint8Array(s),o(e))},n=globalThis,a=()=>n.navigator?.serviceWorker!==void 0&&n.PushManager!==void 0,d=async r=>{if(!a())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager)");let e;if(r.serviceWorkerUrl===void 0)e=await navigator.serviceWorker.ready;else{const c=r.scope===void 0?void 0:{scope:r.scope};e=await navigator.serviceWorker.register(r.serviceWorkerUrl,c)}const s=await Notification.requestPermission();if(s!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${s}")`);const t=await e.pushManager.getSubscription();let i=null;return t!==null&&(g(t,r.vapidPublicKey)?i=t:await t.unsubscribe()),(i??await e.pushManager.subscribe({applicationServerKey:o(r.vapidPublicKey),userVisibleOnly:!0})).toJSON()},v=async()=>{if(!a())return!1;const e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();return e===null?!1:e.unsubscribe()};export{a as isPushSupported,d as subscribeToPush,v as unsubscribeFromPush};
1
+ const p=r=>{const e=atob(r),t=new Uint8Array(e.length);for(let i=0;i<e.length;i+=1)t[i]=e.codePointAt(i)??0;return t},b=r=>{const e=r.replaceAll("-","+").replaceAll("_","/"),t=e+"=".repeat((4-e.length%4)%4);return p(t)},a=r=>b(r),d=(r,e)=>r.length===e.length&&r.every((t,i)=>t===e[i]),g=(r,e)=>{const t=r.options.applicationServerKey;return t===null?!1:d(new Uint8Array(t),a(e))},s=globalThis,c=()=>s.navigator?.serviceWorker!==void 0&&s.PushManager!==void 0&&s.Notification!==void 0,v=async r=>{if(!c())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager + Notification)");let e;if(r.serviceWorkerUrl===void 0)e=await navigator.serviceWorker.ready;else{const u=r.scope===void 0?void 0:{scope:r.scope};e=await navigator.serviceWorker.register(r.serviceWorkerUrl,u)}const t=await Notification.requestPermission();if(t!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${t}")`);const i=await e.pushManager.getSubscription();let n=null,o;i!==null&&(g(i,r.vapidPublicKey)?n=i:(o=i.endpoint,await i.unsubscribe()));const l=n??await e.pushManager.subscribe({applicationServerKey:a(r.vapidPublicKey),userVisibleOnly:!0});return{replacedEndpoint:o,subscription:l.toJSON()}},f=async()=>{if(!c())return!1;const e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();return e===null?!1:e.unsubscribe()};export{c as isPushSupported,v as subscribeToPush,f as unsubscribeFromPush};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/notify",
3
- "version": "1.0.0-alpha.34",
3
+ "version": "1.0.0-alpha.36",
4
4
  "description": "Multi-channel notifications for Lunora — ctx.notify / ctx.push over @visulima/notification: edge-safe Web Push + FCM, plus chat, in-app inbox and webhook channels, with subscription storage and queue-backed fan-out",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-oDWs9Pom.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-w_dCcy6d.mjs";import{normalizeRegisterInput as R,targetOf as B,isGoneError as L}from"./fcmId-Bt_7V8FU.mjs";const W=250,w=(o,u)=>typeof o=="function"?o(u):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),U=(o,u)=>o.successful?"accepted":L(u)?"gone":"failed",j=async(o,u,d)=>{const l=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,l[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(u,o.length)},()=>g())),l},G=(o,u)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,u),fcm:w(o.fcm,u),inApp:w(o.inApp,u),webhook:w(o.webhook,u),webPush:w(o.webPush,u)}),x=new WeakMap,Q=(o,u)=>{let d=x.get(o);d===void 0&&(d=new WeakMap,x.set(o,d));let l=d.get(u);return l===void 0&&(l={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(u,l)),l},Y=(o,u,d={})=>{const l=Q(o,u);let h;d.engine===void 0?(l.engine??=z(G(o,u)),h=l.engine):h=d.engine,l.store??=o.store?.(u);let{store:g}=l;g===void 0&&(l.fallbackStore??=F(),!d.silent&&!l.warnedNoStore&&(l.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=l.fallbackStore);const f=g,C=Math.max(1,d.concurrency??o.concurrency??10),b=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??W),{log:M,metrics:P}=d,p=(e,t,r,n=1)=>{P?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{M?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{P?.count("notify.skipped",1,{channel:e,reason:t})},I=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||l.warnedNoPushOriginAllowlist||(l.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},T=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),_=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},E=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},D=async(e,t)=>{const r=await j(t,C,async s=>{const{error:c,status:i}=await E(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},O=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await O(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:O,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await E(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:e=>f.delete(e)},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};
@@ -1 +0,0 @@
1
- import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-Bt_7V8FU.mjs";const c=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},O=(e,i={})=>{const n=i.tableName??"lunora_push_subscriptions";if(!c(n))throw new p("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${n}" is not a bare SQL identifier`);let r;const s=()=>(r===void 0&&(r=e.prepare(`CREATE TABLE IF NOT EXISTS ${n} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_user_id_idx ON ${n} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_kind_idx ON ${n} (kind)`).run()).then(()=>{}),r.catch(()=>{r=void 0})),r),l=async t=>{await s();const a=await e.prepare(`SELECT * FROM ${n} WHERE id = ?1`).bind(t).first();return a===null?void 0:E(a)};return{delete:async t=>{await s(),await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(t).run()},get:l,list:async t=>{await s();const a=[],d=[];t?.kind!==void 0&&(d.push(t.kind),a.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?a.push("user_id IS NULL"):(d.push(t.userId),a.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),a.push(`id > ?${d.length.toString()}`));const o=a.length===0?"":` WHERE ${a.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:_}=await e.prepare(`SELECT * FROM ${n}${o}${T}${u}`).bind(...d).all();return _.map(h=>E(h))},markStatus:async(t,a,d)=>{await s(),await e.prepare(`UPDATE ${n} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,a,d??null,Date.now()).run()},put:async t=>{await s(),await e.prepare(`INSERT INTO ${n} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const a=S(t);return a!==void 0&&a!==t.id&&await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(a).run(),await l(t.id)??t}}};export{O as d1SubscriptionStore};
@@ -1 +0,0 @@
1
- import{LunoraError as i}from"@lunora/errors";const u=e=>e.filter(t=>t.status==="failed").map(t=>t.id),h=(e,t)=>e.send({...t,type:"lunora.push.broadcast"}),c=async(e,t,s)=>{const r=[];for(const a of s)try{const o=await e.send(a,t);r.push({id:a,status:o.successful?"ok":"failed"})}catch(o){r.push({error:o instanceof Error?o.message:String(o),id:a,status:"failed"})}const n=u(r),d=r.length-n.length,l={failed:n.length,outcomes:r,pruned:0,sent:d,total:r.length};if(n.length>0)throw new i("INTERNAL",`@lunora/notify: push retry failed for ${n.length.toString()} of ${r.length.toString()} subscription(s) — throwing so the queue retries and eventually dead-letters them`);return{failedIds:n,nextCursor:void 0,result:l}},g=async(e,t)=>{if(t.retryIds!==void 0&&t.retryIds.length>0)return c(e,t.payload,t.retryIds);const s=await e.broadcastPage(t.payload,t.filter);return{failedIds:u(s.result.outcomes),nextCursor:s.nextCursor,result:s.result}};export{h as enqueuePushBroadcast,g as runPushBroadcastPage};
@@ -1 +0,0 @@
1
- import{LunoraError as s}from"@lunora/errors";import{i as A}from"./ssrf-host-BCpHorGa.mjs";const b=2166136261,_=16777619,m=(t,e=b)=>{let n=e;for(let o=0;o<t.length;o+=1)n^=t.charCodeAt(o),n=Math.imul(n,_);return(n>>>0).toString(16).padStart(8,"0")},d=t=>t.toString(16).padStart(4,"0"),p=t=>{let e=8997,n=33826,o=40164,r=52210;for(let i=0;i<t.length;i+=1){const a=t.codePointAt(i)??0;e^=a&65535,n^=a>>>16&65535;const c=e*435,y=n*435,E=o*435+e*256,w=r*435+n*256,l=y+(c>>>16),u=E+(l>>>16),S=w+(u>>>16);e=c&65535,n=l&65535,o=u&65535,r=S&65535}return d(r)+d(o)+d(n)+d(e)},f=4096,h=t=>{if(t===void 0)return;const e=typeof t=="object"&&t!==null?Object.getPrototypeOf(t):void 0;if(!(typeof t=="object"&&t!==null&&!Array.isArray(t)&&(e===Object.prototype||e===null)))throw new s("BAD_REQUEST","@lunora/notify: register() `metadata` must be a plain object");let o;try{o=JSON.stringify(t)}catch(i){throw new s("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is not JSON-serialisable: ${i instanceof Error?i.message:String(i)}`)}const r=new TextEncoder().encode(o).length;if(r>f)throw new s("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is ${r.toString()} bytes, exceeding the ${f.toString()}-byte cap`);return t},T=t=>`wp2_${p(t)}`,O=t=>`fcm2_${p(t)}`,g=t=>m(t),k=t=>`wp_${g(t)}`,v=t=>`fcm_${g(t)}`,U=t=>t.kind==="fcm"?t.token===void 0?void 0:v(t.token):t.endpoint===void 0?void 0:k(t.endpoint),R=t=>{if(typeof t!="string")return t??{};try{return JSON.parse(t)}catch(e){throw new s("BAD_REQUEST",`@lunora/notify: register() web-push subscription is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}},B=(t,e)=>{let n;try{n=new URL(t)}catch{throw new s("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must be an absolute https URL (got "${t}")`)}if(n.protocol!=="https:")throw new s("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must use https (got "${n.protocol}")`);if(e!==void 0&&e.length>0){if(!e.includes(n.origin))throw new s("FORBIDDEN",`@lunora/notify: register() web-push endpoint origin "${n.origin}" is not in the configured allowedPushOrigins allowlist`);return}if(A(n.hostname))throw new s("FORBIDDEN",`@lunora/notify: register() web-push endpoint host "${n.hostname}" is a private/internal address; configure allowedPushOrigins to permit a specific origin`)},x=(t,e=Date.now(),n={})=>{if("token"in t){const{token:c}=t;if(typeof c!="string"||c==="")throw new s("BAD_REQUEST","@lunora/notify: register() fcm input requires a non-empty `token`");return{createdAt:e,id:O(c),kind:"fcm",lastSeenAt:e,metadata:h(t.metadata),token:c,userId:t.userId??null}}const o=R(t.subscription),{endpoint:r}=o,i=o.keys?.p256dh,a=o.keys?.auth;if(typeof r!="string"||r===""||typeof i!="string"||typeof a!="string")throw new s("BAD_REQUEST","@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");return B(r,n.allowedPushOrigins),{createdAt:e,endpoint:r,id:T(r),keys:{auth:a,p256dh:i},kind:"web-push",lastSeenAt:e,metadata:h(t.metadata),userId:t.userId??null}},F=t=>t.kind==="fcm"?t.token??"":JSON.stringify({endpoint:t.endpoint,keys:t.keys}),D=/\bhttp\s*4(?:04|10)\b/iu,I=/\b(?:unregistered|not[\s-]?registered|registration-token-not-registered)\b/iu,N=/\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu,Q=t=>t===void 0?!1:D.test(t)||I.test(t)||N.test(t);export{O as fcmId,Q as isGoneError,v as legacyFcmId,U as legacyIdFor,k as legacyWebPushId,x as normalizeRegisterInput,F as targetOf,T as webPushId};
@@ -1 +0,0 @@
1
- import{legacyIdFor as n}from"./fcmId-Bt_7V8FU.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,a=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),c=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())a(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{c as memorySubscriptionStore};