@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 +39 -12
- package/dist/index.d.mts +116 -32
- package/dist/index.d.ts +116 -32
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/{buildEngine-oDWs9Pom.mjs → buildEngine-zGk_1q8H.mjs} +1 -1
- package/dist/packem_shared/createNotify-DmRk1CHx.mjs +1 -0
- package/dist/packem_shared/d1SubscriptionStore-Cpi56NIh.mjs +1 -0
- package/dist/packem_shared/enqueuePushBroadcast-COksdUlz.mjs +1 -0
- package/dist/packem_shared/fcmId-hLwzY2K2.mjs +1 -0
- package/dist/packem_shared/memorySubscriptionStore-DiVmkZEp.mjs +1 -0
- package/dist/web.d.mts +32 -3
- package/dist/web.d.ts +32 -3
- package/dist/web.mjs +1 -1
- package/package.json +1 -1
- package/dist/packem_shared/createNotify-BWRG-JIC.mjs +0 -1
- package/dist/packem_shared/d1SubscriptionStore-BjvQlFZW.mjs +0 -1
- package/dist/packem_shared/enqueuePushBroadcast-B6v3DLLp.mjs +0 -1
- package/dist/packem_shared/fcmId-Bt_7V8FU.mjs +0 -1
- package/dist/packem_shared/memorySubscriptionStore-w_dCcy6d.mjs +0 -1
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
|
-
|
|
49
|
-
|
|
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,
|
|
114
|
+
const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
91
115
|
|
|
92
|
-
// One message = ONE bounded page. Discarding `
|
|
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
|
-
|
|
95
|
-
|
|
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.
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
/**
|
|
279
|
-
|
|
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: `
|
|
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
|
|
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,
|
|
657
|
+
* const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
585
658
|
*
|
|
586
|
-
* if (
|
|
659
|
+
* if (nextFilter !== undefined) {
|
|
587
660
|
* // More pages remain — enqueue the continuation. Each message still
|
|
588
|
-
* // does only ONE bounded page of work.
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
-
*
|
|
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 `
|
|
706
|
+
* every LATER page unreached. The page's `nextFilter` and its `failedIds`
|
|
636
707
|
* both come back instead.
|
|
637
|
-
* - The CALLER re-enqueues: `filter
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* the
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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:
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* last resort only, so a transient
|
|
767
|
-
* (a cert/session expiry) can never
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
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
|
-
/**
|
|
279
|
-
|
|
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: `
|
|
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
|
|
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,
|
|
657
|
+
* const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
585
658
|
*
|
|
586
|
-
* if (
|
|
659
|
+
* if (nextFilter !== undefined) {
|
|
587
660
|
* // More pages remain — enqueue the continuation. Each message still
|
|
588
|
-
* // does only ONE bounded page of work.
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
-
*
|
|
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 `
|
|
706
|
+
* every LATER page unreached. The page's `nextFilter` and its `failedIds`
|
|
636
707
|
* both come back instead.
|
|
637
|
-
* - The CALLER re-enqueues: `filter
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* the
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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:
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* last resort only, so a transient
|
|
767
|
-
* (a cert/session expiry) can never
|
|
768
|
-
|
|
769
|
-
|
|
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-
|
|
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
|
|
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
|
-
/**
|
|
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<
|
|
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
|
-
/**
|
|
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<
|
|
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
|
|
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.
|
|
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};
|