@lunora/notify 1.0.0-alpha.37 → 1.0.0-alpha.38
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 -2
- package/dist/index.d.mts +60 -16
- package/dist/index.d.ts +60 -16
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/buildEngine-C2xUH4Bu.mjs +1 -0
- package/dist/packem_shared/createNotify-BOB6CJVp.mjs +1 -0
- package/dist/packem_shared/d1SubscriptionStore-DoInbQak.mjs +1 -0
- package/dist/packem_shared/enqueuePushBroadcast-BnuMQfuG.mjs +1 -0
- package/dist/packem_shared/fcmId-B89fNAgc.mjs +1 -0
- package/dist/packem_shared/memorySubscriptionStore-C7hyd6ii.mjs +1 -0
- package/dist/packem_shared/normalize-BdOnBfM2.mjs +1 -0
- package/package.json +1 -1
- package/dist/packem_shared/buildEngine-zGk_1q8H.mjs +0 -1
- package/dist/packem_shared/createNotify-DmRk1CHx.mjs +0 -1
- package/dist/packem_shared/d1SubscriptionStore-Cpi56NIh.mjs +0 -1
- package/dist/packem_shared/enqueuePushBroadcast-COksdUlz.mjs +0 -1
- package/dist/packem_shared/fcmId-hLwzY2K2.mjs +0 -1
- package/dist/packem_shared/memorySubscriptionStore-DiVmkZEp.mjs +0 -1
- package/dist/packem_shared/ssrf-host-BCpHorGa.mjs +0 -1
package/README.md
CHANGED
|
@@ -74,6 +74,43 @@ used to probe which endpoints exist. Register with the same `userId` you
|
|
|
74
74
|
unregister with; devices registered anonymously (`userId` absent) all share the
|
|
75
75
|
one anonymous scope and get no separation from this check.
|
|
76
76
|
|
|
77
|
+
`register` is scoped the same way, because an unguarded upsert closes only half
|
|
78
|
+
of that: re-registering a victim's endpoint under your own `userId` (with keys of
|
|
79
|
+
your choosing) takes their device dark just as effectively, and hands you
|
|
80
|
+
`unregister` over it. An endpoint already registered to another user is
|
|
81
|
+
**refused** (`FORBIDDEN`) rather than re-owned — unowned rows stay claimable (the
|
|
82
|
+
device signed in), and a device that legitimately changes hands unregisters as
|
|
83
|
+
its current owner first.
|
|
84
|
+
|
|
85
|
+
**Unregister on sign-out, or the next account on that browser cannot register.**
|
|
86
|
+
`subscribeToPush` REUSES the browser's existing subscription while the VAPID key
|
|
87
|
+
is unchanged, so the endpoint — and the store id derived from it — is the same
|
|
88
|
+
for every account that signs in on that browser. Without the sign-out call, user
|
|
89
|
+
B's `register` hits user A's row and throws `FORBIDDEN`; since `register` is
|
|
90
|
+
usually fire-and-forget on sign-in, that surfaces as a failed mutation and B
|
|
91
|
+
silently never receives a push. Release the row where you clear the session:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// lunora/registerDevice.ts — the same file as above
|
|
95
|
+
export const unregisterDevice = mutation.input({ endpoint: v.string() }).mutation(async ({ args: { endpoint }, ctx }) => {
|
|
96
|
+
await ctx.push.unregister(webPushId(endpoint), { userId: ctx.auth?.userId });
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// wherever you sign out. `subscription.endpoint` is on the object subscribeToPush returned.
|
|
102
|
+
await client.mutation("unregisterDevice", { endpoint: subscription.endpoint });
|
|
103
|
+
await auth.signOut();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Keep the browser subscription itself (don't call `unsubscribeFromPush`) unless
|
|
107
|
+
the user is turning notifications off: dropping it re-prompts for permission on
|
|
108
|
+
the next sign-in. Note that only the owner can release a row — B cannot
|
|
109
|
+
`unregister` A's — so a sign-out that never runs (the tab was closed, the session
|
|
110
|
+
expired) leaves the next account refused until A signs in again on that browser
|
|
111
|
+
or the row is removed server-side. On a browser several people sign in on, treat
|
|
112
|
+
the sign-out unregister as required, not as cleanup.
|
|
113
|
+
|
|
77
114
|
## Send (from an action)
|
|
78
115
|
|
|
79
116
|
Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
|
|
@@ -85,7 +122,7 @@ export const announce = action.input({ title: v.string(), body: v.string() }).ac
|
|
|
85
122
|
});
|
|
86
123
|
```
|
|
87
124
|
|
|
88
|
-
`broadcast` reuses the engine's retry + circuit-breaker middleware and prunes subscriptions the push service reports as gone (HTTP 404/410
|
|
125
|
+
`broadcast` reuses the engine's retry + circuit-breaker middleware and prunes subscriptions the push service reports as gone (Web Push HTTP 404/410; FCM's `NOT_FOUND` answer for a dead token, plus the `UNREGISTERED`/`NotRegistered` codes a legacy transport sends). A single targeted send:
|
|
89
126
|
|
|
90
127
|
```ts
|
|
91
128
|
await ctx.push.send(subscriptionId, { title: "Hi", body: "…" });
|
|
@@ -179,7 +216,7 @@ client-supplied data, so the facade enforces two boundaries:
|
|
|
179
216
|
|
|
180
217
|
Every send is counted onto `ctx.metrics` and failures onto `ctx.log` for you — codegen threads the request's logger/metrics into `ctx.notify` (`createNotify(notifyConfig, env, { log, metrics })`), so there is nothing to wire. Two low-cardinality metric series feed the durable metric history + trend charts:
|
|
181
218
|
|
|
182
|
-
- **`notify.send`** `{ channel, provider, status }` — attempted sends. `status` is `accepted` (the provider took it), `failed`, or `gone` (endpoint unregistered — 404/410
|
|
219
|
+
- **`notify.send`** `{ channel, provider, status }` — attempted sends. `status` is `accepted` (the provider took it), `failed`, or `gone` (endpoint unregistered — Web Push 404/410, or FCM's `NOT_FOUND` for a dead token — and pruned). A single send counts 1; a **broadcast aggregates** into one count per `(provider, status)` bucket (value = the bucket's count), not one per recipient — each `ctx.metrics.count` is a durable write.
|
|
183
220
|
- **`notify.skipped`** `{ channel, reason }` — a send that reached nobody: `no-subscriptions-matched` (empty broadcast) or `channel-not-configured`.
|
|
184
221
|
|
|
185
222
|
A **failed** send also emits one `ctx.log.warn` line carrying the error and, for push, the subscription/user ids — trace-correlated to the enclosing action and durably archived. Successes and prunes stay off the log; failure logs stay per-recipient even in a broadcast (they have no durable write).
|
package/dist/index.d.mts
CHANGED
|
@@ -174,7 +174,33 @@ interface SubscriptionStore {
|
|
|
174
174
|
list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
|
|
175
175
|
/** Record the latest delivery outcome for a subscription (best-effort). */
|
|
176
176
|
markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
|
|
177
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Insert or update a subscription (upsert by id), refusing to move an
|
|
179
|
+
* existing row to a DIFFERENT owner.
|
|
180
|
+
*
|
|
181
|
+
* The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
|
|
182
|
+
* carries, and for the same reason: the id is derived from the endpoint or the
|
|
183
|
+
* FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
|
|
184
|
+
* caller who could guess or observe another user's endpoint re-register it
|
|
185
|
+
* under their own `userId` with keys of their choosing — the victim's device
|
|
186
|
+
* then fails every send (an encryption failure is not a gone signal, so it is
|
|
187
|
+
* never pruned either) and the attacker can `unregister` it as their own. The
|
|
188
|
+
* `unregister` guard alone closed exactly half of that (CWE-639).
|
|
189
|
+
*
|
|
190
|
+
* A row with no owner is claimable (the device signed in), and a row the caller
|
|
191
|
+
* already owns is theirs to refresh — that is the routine service-worker
|
|
192
|
+
* re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
|
|
193
|
+
* no-op: unlike `unregister` there is nothing safe to return, since a caller
|
|
194
|
+
* gets the stored record back and it would be someone else's delivery keys.
|
|
195
|
+
* A shared device that legitimately changes hands is handled by its current
|
|
196
|
+
* owner calling `unregister` first.
|
|
197
|
+
*
|
|
198
|
+
* **The predicate and the write must be ONE operation**, as for `deleteOwned`:
|
|
199
|
+
* between a read that checks the owner and the write that acts on it, another
|
|
200
|
+
* registration can replace the row. The D1 store puts the predicate in the
|
|
201
|
+
* `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
|
|
202
|
+
* between the two.
|
|
203
|
+
*/
|
|
178
204
|
put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
|
|
179
205
|
}
|
|
180
206
|
/** Per-recipient outcome from a fan-out `broadcast`. */
|
|
@@ -218,7 +244,7 @@ interface BroadcastPageResult {
|
|
|
218
244
|
*
|
|
219
245
|
* - `accepted` — the provider took the message (a `Receipt.successful` send).
|
|
220
246
|
* - `failed` — a provider error; the log line carries the `error` text.
|
|
221
|
-
* - `gone` — the endpoint is unregistered (404/410, FCM `
|
|
247
|
+
* - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
|
|
222
248
|
*
|
|
223
249
|
* Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
|
|
224
250
|
* send attempt: a `delivered`/`opened` status would be a lie for these channels.
|
|
@@ -263,16 +289,18 @@ interface LunoraPush {
|
|
|
263
289
|
/**
|
|
264
290
|
* Fan-out a push to every stored subscription matching `filter` (default: all).
|
|
265
291
|
* Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
|
|
266
|
-
* the push service reports as gone (
|
|
292
|
+
* the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
|
|
267
293
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
268
294
|
*
|
|
269
295
|
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
270
|
-
* keyset-paginated on the subscription `id`) so
|
|
296
|
+
* keyset-paginated on the subscription `id`) so the audience ROWS are never
|
|
271
297
|
* materialized wholesale in the isolate — see `defineNotify`'s
|
|
272
|
-
* `broadcastPageSize`.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
298
|
+
* `broadcastPageSize`. The returned `outcomes` ARE whole-audience, though: one
|
|
299
|
+
* `{ id, status }` per recipient accumulates across every page, so this call is
|
|
300
|
+
* bounded in rows held but not in outcomes reported. It also processes the WHOLE
|
|
301
|
+
* matched audience in one request/queue message; use {@link LunoraPush.broadcastPage}
|
|
302
|
+
* directly (as `runPushBroadcastPage` does) to bound a single queue message —
|
|
303
|
+
* and its result — to one page.
|
|
276
304
|
*/
|
|
277
305
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
278
306
|
/**
|
|
@@ -299,7 +327,18 @@ interface LunoraPush {
|
|
|
299
327
|
* the caller did not already hold and never another device's row.
|
|
300
328
|
*/
|
|
301
329
|
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
302
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* Register (upsert) a device subscription and return the stored record (the
|
|
332
|
+
* caller's own row, secrets included).
|
|
333
|
+
*
|
|
334
|
+
* Owner-scoped, exactly as {@link LunoraPush.unregister} is: registering an
|
|
335
|
+
* endpoint that is already another user's row is REFUSED (`FORBIDDEN`) rather
|
|
336
|
+
* than re-owning it. Pass `ctx.auth?.userId` so the check has something to
|
|
337
|
+
* separate; an app that registers every device anonymously gets no separation
|
|
338
|
+
* from it (every row is unowned, and unowned rows stay claimable). A device
|
|
339
|
+
* that legitimately changes hands — one browser profile, two accounts —
|
|
340
|
+
* unregisters as its current owner first.
|
|
341
|
+
*/
|
|
303
342
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
304
343
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
305
344
|
send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
|
|
@@ -718,8 +757,11 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
718
757
|
* of them still fail, so the queue's backoff/dead-letter bounds a device that
|
|
719
758
|
* never recovers. Once any recipient recovers the run resolves and reports the
|
|
720
759
|
* rest in `failedIds`, so the narrower retry never re-sends to a device this
|
|
721
|
-
* message already reached.
|
|
722
|
-
*
|
|
760
|
+
* message already reached. A recipient that turns out to be GONE — a 404/410
|
|
761
|
+
* receipt, or an id whose row no longer exists at all — counts as `pruned` here
|
|
762
|
+
* too, never as a failure: there is nothing left to redeliver to, so retrying it
|
|
763
|
+
* could only burn the queue's budget and dead-letter an unsubscribe.
|
|
764
|
+
* - Gone subscriptions (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) are pruned by the page and
|
|
723
765
|
* never appear in `failedIds` — an all-`pruned` page is a success, not a
|
|
724
766
|
* failure, as is an empty page.
|
|
725
767
|
*/
|
|
@@ -837,11 +879,13 @@ declare const targetOf: (subscription: StoredSubscription) => string;
|
|
|
837
879
|
* transient failure worth retrying.
|
|
838
880
|
*
|
|
839
881
|
* Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
|
|
840
|
-
* answer one for a dead endpoint/token
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
882
|
+
* answer one for a dead endpoint/token, though FCM's is usually replaced by its
|
|
883
|
+
* error body before it reaches here) or, for FCM only, an
|
|
884
|
+
* `UNREGISTERED`/`NOT_REGISTERED` code or the `NOT_FOUND` prose that is the one
|
|
885
|
+
* signal its provider actually forwards (see {@link FCM_GONE_PATTERN}). The
|
|
886
|
+
* free-text {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a
|
|
887
|
+
* transient error that happens to contain `expired` (a cert/session expiry) can
|
|
888
|
+
* never permanently drop a valid subscription.
|
|
845
889
|
*
|
|
846
890
|
* `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
|
|
847
891
|
* The web-push provider echoes the push service's response body into
|
package/dist/index.d.ts
CHANGED
|
@@ -174,7 +174,33 @@ interface SubscriptionStore {
|
|
|
174
174
|
list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
|
|
175
175
|
/** Record the latest delivery outcome for a subscription (best-effort). */
|
|
176
176
|
markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
|
|
177
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Insert or update a subscription (upsert by id), refusing to move an
|
|
179
|
+
* existing row to a DIFFERENT owner.
|
|
180
|
+
*
|
|
181
|
+
* The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
|
|
182
|
+
* carries, and for the same reason: the id is derived from the endpoint or the
|
|
183
|
+
* FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
|
|
184
|
+
* caller who could guess or observe another user's endpoint re-register it
|
|
185
|
+
* under their own `userId` with keys of their choosing — the victim's device
|
|
186
|
+
* then fails every send (an encryption failure is not a gone signal, so it is
|
|
187
|
+
* never pruned either) and the attacker can `unregister` it as their own. The
|
|
188
|
+
* `unregister` guard alone closed exactly half of that (CWE-639).
|
|
189
|
+
*
|
|
190
|
+
* A row with no owner is claimable (the device signed in), and a row the caller
|
|
191
|
+
* already owns is theirs to refresh — that is the routine service-worker
|
|
192
|
+
* re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
|
|
193
|
+
* no-op: unlike `unregister` there is nothing safe to return, since a caller
|
|
194
|
+
* gets the stored record back and it would be someone else's delivery keys.
|
|
195
|
+
* A shared device that legitimately changes hands is handled by its current
|
|
196
|
+
* owner calling `unregister` first.
|
|
197
|
+
*
|
|
198
|
+
* **The predicate and the write must be ONE operation**, as for `deleteOwned`:
|
|
199
|
+
* between a read that checks the owner and the write that acts on it, another
|
|
200
|
+
* registration can replace the row. The D1 store puts the predicate in the
|
|
201
|
+
* `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
|
|
202
|
+
* between the two.
|
|
203
|
+
*/
|
|
178
204
|
put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
|
|
179
205
|
}
|
|
180
206
|
/** Per-recipient outcome from a fan-out `broadcast`. */
|
|
@@ -218,7 +244,7 @@ interface BroadcastPageResult {
|
|
|
218
244
|
*
|
|
219
245
|
* - `accepted` — the provider took the message (a `Receipt.successful` send).
|
|
220
246
|
* - `failed` — a provider error; the log line carries the `error` text.
|
|
221
|
-
* - `gone` — the endpoint is unregistered (404/410, FCM `
|
|
247
|
+
* - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
|
|
222
248
|
*
|
|
223
249
|
* Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
|
|
224
250
|
* send attempt: a `delivered`/`opened` status would be a lie for these channels.
|
|
@@ -263,16 +289,18 @@ interface LunoraPush {
|
|
|
263
289
|
/**
|
|
264
290
|
* Fan-out a push to every stored subscription matching `filter` (default: all).
|
|
265
291
|
* Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
|
|
266
|
-
* the push service reports as gone (
|
|
292
|
+
* the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
|
|
267
293
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
268
294
|
*
|
|
269
295
|
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
270
|
-
* keyset-paginated on the subscription `id`) so
|
|
296
|
+
* keyset-paginated on the subscription `id`) so the audience ROWS are never
|
|
271
297
|
* materialized wholesale in the isolate — see `defineNotify`'s
|
|
272
|
-
* `broadcastPageSize`.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
298
|
+
* `broadcastPageSize`. The returned `outcomes` ARE whole-audience, though: one
|
|
299
|
+
* `{ id, status }` per recipient accumulates across every page, so this call is
|
|
300
|
+
* bounded in rows held but not in outcomes reported. It also processes the WHOLE
|
|
301
|
+
* matched audience in one request/queue message; use {@link LunoraPush.broadcastPage}
|
|
302
|
+
* directly (as `runPushBroadcastPage` does) to bound a single queue message —
|
|
303
|
+
* and its result — to one page.
|
|
276
304
|
*/
|
|
277
305
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
278
306
|
/**
|
|
@@ -299,7 +327,18 @@ interface LunoraPush {
|
|
|
299
327
|
* the caller did not already hold and never another device's row.
|
|
300
328
|
*/
|
|
301
329
|
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
302
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* Register (upsert) a device subscription and return the stored record (the
|
|
332
|
+
* caller's own row, secrets included).
|
|
333
|
+
*
|
|
334
|
+
* Owner-scoped, exactly as {@link LunoraPush.unregister} is: registering an
|
|
335
|
+
* endpoint that is already another user's row is REFUSED (`FORBIDDEN`) rather
|
|
336
|
+
* than re-owning it. Pass `ctx.auth?.userId` so the check has something to
|
|
337
|
+
* separate; an app that registers every device anonymously gets no separation
|
|
338
|
+
* from it (every row is unowned, and unowned rows stay claimable). A device
|
|
339
|
+
* that legitimately changes hands — one browser profile, two accounts —
|
|
340
|
+
* unregisters as its current owner first.
|
|
341
|
+
*/
|
|
303
342
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
304
343
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
305
344
|
send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
|
|
@@ -718,8 +757,11 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
718
757
|
* of them still fail, so the queue's backoff/dead-letter bounds a device that
|
|
719
758
|
* never recovers. Once any recipient recovers the run resolves and reports the
|
|
720
759
|
* rest in `failedIds`, so the narrower retry never re-sends to a device this
|
|
721
|
-
* message already reached.
|
|
722
|
-
*
|
|
760
|
+
* message already reached. A recipient that turns out to be GONE — a 404/410
|
|
761
|
+
* receipt, or an id whose row no longer exists at all — counts as `pruned` here
|
|
762
|
+
* too, never as a failure: there is nothing left to redeliver to, so retrying it
|
|
763
|
+
* could only burn the queue's budget and dead-letter an unsubscribe.
|
|
764
|
+
* - Gone subscriptions (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) are pruned by the page and
|
|
723
765
|
* never appear in `failedIds` — an all-`pruned` page is a success, not a
|
|
724
766
|
* failure, as is an empty page.
|
|
725
767
|
*/
|
|
@@ -837,11 +879,13 @@ declare const targetOf: (subscription: StoredSubscription) => string;
|
|
|
837
879
|
* transient failure worth retrying.
|
|
838
880
|
*
|
|
839
881
|
* Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
|
|
840
|
-
* answer one for a dead endpoint/token
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
882
|
+
* answer one for a dead endpoint/token, though FCM's is usually replaced by its
|
|
883
|
+
* error body before it reaches here) or, for FCM only, an
|
|
884
|
+
* `UNREGISTERED`/`NOT_REGISTERED` code or the `NOT_FOUND` prose that is the one
|
|
885
|
+
* signal its provider actually forwards (see {@link FCM_GONE_PATTERN}). The
|
|
886
|
+
* free-text {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a
|
|
887
|
+
* transient error that happens to contain `expired` (a cert/session expiry) can
|
|
888
|
+
* never permanently drop a valid subscription.
|
|
845
889
|
*
|
|
846
890
|
* `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
|
|
847
891
|
* The web-push provider echoes the push service's response body into
|
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
|
|
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 m}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as u}from"./packem_shared/createNotify-BOB6CJVp.mjs";import{buildEngine as E,routingPushProvider as d}from"./packem_shared/buildEngine-C2xUH4Bu.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-BnuMQfuG.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-DoInbQak.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-C7hyd6ii.mjs";import{f as _,i as y,n as v,t as w,w as B}from"./packem_shared/normalize-BdOnBfM2.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,E as buildEngine,u as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,m as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,w as targetOf,f as webPushFromEnv,B as webPushId};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as l}from"@lunora/errors";import{createNotification as I}from"@visulima/notification";import{retryMiddleware as k}from"@visulima/notification/middleware";import{fcmProvider as T}from"@visulima/notification/providers/fcm";import{webPushProvider as D}from"@visulima/notification/providers/web-push";import{a as S,p as w,b as _,d as C,i as N}from"./normalize-BdOnBfM2.mjs";const O=(e,t)=>{if(e.size<t)return;const n=e.keys().next().value;n!==void 0&&e.delete(n)},L="https://cloudflare-dns.com/dns-query",f=1,h=28,M=2e3,U=(e,t)=>{if(t===f){const n=w(e);return n===void 0||_(n)}return C(e.toLowerCase())},m=async(e,t,n)=>{try{const r=await fetch(`${L}?name=${encodeURIComponent(e)}&type=${String(t)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(n)});return r.ok?(await r.json()).Answer??[]:void 0}catch{return}},$=async(e,t=M)=>{const n=S(e);if(n.includes(":")||w(n)!==void 0)return{kind:"unknown"};const[r,i]=await Promise.all([m(n,f,t),m(n,h,t)]);if(r===void 0&&i===void 0)return{kind:"unknown"};for(const o of[...r??[],...i??[]])if((o.type===f||o.type===h)&&U(o.data,o.type))return{address:o.data,kind:"private"};return{kind:"public"}},B=e=>{let t=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{t=JSON.parse(e)}catch{return}}const n=t?.endpoint;return typeof n=="string"?n:void 0},u=new Map,H=256,z=async(e,t)=>{if(t!==void 0&&t.length>0)return;let n;try{({hostname:n}=new URL(e))}catch{return}const r=u.get(n),i=r??$(n),o=await i;if(r===void 0&&o.kind!=="unknown"&&(O(u,H),u.set(n,i)),o.kind==="private")throw new l("FORBIDDEN",`@lunora/notify: web-push endpoint host "${n}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`)},j=(e,t)=>{const n=[e.provider,t.provider].filter(i=>i!==void 0),r=[...e.recipients??[],...t.recipients??[]];return{...e,messageId:[e.messageId,t.messageId].join(","),response:[e.response,t.response],sent:e.sent&&t.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),t.timestamp.getTime())),...n.length>0?{provider:n.join(",")}:{},...r.length>0?{recipients:r}:{}}},V=(e,t)=>{if(!e.success||!t.success)return e.success||t.success?e.success?t:e:{error:new AggregateError([e.error,t.error],"@lunora/notify: both push target groups failed"),success:!1};const n=e.data===void 0||t.data===void 0?e.data??t.data:j(e.data,t.data);return n===void 0?{success:!0}:{data:n,success:!0}},F=e=>e instanceof Error?e.message:typeof e=="string"?e:void 0,g=e=>N(F(e)),c=5,Y=3e4,G=()=>{const e=new Map;return async(t,n)=>{const r=e.get(t.provider)??{failures:0,openedAt:0};if(e.set(t.provider,r),r.failures>=c){if(Date.now()-r.openedAt<Y)return{error:new l("SERVICE_UNAVAILABLE",`@lunora/notify: circuit open for provider "${t.provider}" after ${c.toString()} consecutive failures`),success:!1};r.failures=c-1}const i=await n(t);return i.success?r.failures=0:g(i.error)||(r.failures+=1,r.failures>=c&&(r.openedAt=Date.now())),i}},q=e=>{const t=n=>{const r=n===void 0?e.fcm:e.webPush;if(r===void 0)throw new Error(n===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 n=>{const r=Array.isArray(n.to)?n.to:[n.to];if(r.length===0)throw new l("BAD_REQUEST","@lunora/notify: push send has no recipients — `to` is an empty array");const i=r.map(s=>B(s));for(const s of i)s!==void 0&&await z(s,e.allowedPushOrigins);const o=r.filter((s,a)=>i[a]!==void 0),p=r.filter((s,a)=>i[a]===void 0),d=i.find(s=>s!==void 0);if(p.length===0&&d!==void 0)return t(d).send(n);if(o.length===0)return t(void 0).send(n);const y=s=>({...n,to:s.length===1&&s[0]!==void 0?s[0]:s}),b=t(d),E=t(void 0),v=async(s,a)=>s.send(y(a)),P=await Promise.allSettled([v(b,o),v(E,p)]),[A,R]=P.map(s=>s.status==="fulfilled"?s.value:{error:s.reason,success:!1});return V(A,R)}}},x=(e,t={})=>e.use(k({baseDelay:t.retryBaseDelay,shouldRetry:n=>!g(n)})).use(G()),ee=e=>{const t=e.webPush===void 0?void 0:D(e.webPush),n=e.fcm===void 0?void 0:T(e.fcm),r={};return(t!==void 0||n!==void 0)&&(r.push=q({allowedPushOrigins:e.allowedPushOrigins,fcm:n,webPush:t})),e.chat!==void 0&&(r.chat=e.chat),e.inApp!==void 0&&(r.inapp=e.inApp),e.webhook!==void 0&&(r.webhook=e.webhook),x(I(r))};export{x as attachResilience,ee as buildEngine,q as routingPushProvider};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-C2xUH4Bu.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-C7hyd6ii.mjs";import{n as R,t as B,i as L}from"./normalize-BdOnBfM2.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{c as S,l as N}from"./normalize-BdOnBfM2.mjs";const R=e=>/^[A-Z_]\w*$/i.test(e),T=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},A=(e,i={})=>{const n=i.tableName??"lunora_push_subscriptions";if(!R(n))throw new h("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${n}" is not a bare SQL identifier`);let s;const r=()=>(s===void 0&&(s=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(()=>{}),s.catch(()=>{s=void 0})),s),E=async t=>{await r();const a=await e.prepare(`SELECT * FROM ${n} WHERE id = ?1`).bind(t).first();return a===null?void 0:T(a)};return{delete:async t=>{await r(),await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(t).run()},deleteOwned:async(t,a)=>(await r(),await(a===null?e.prepare(`DELETE FROM ${n} WHERE id = ?1 AND user_id IS NULL RETURNING id`).bind(t):e.prepare(`DELETE FROM ${n} WHERE id = ?1 AND user_id = ?2 RETURNING id`).bind(t,a)).first()!==null),get:E,list:async t=>{await r();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 l=a.length===0?"":` WHERE ${a.join(" AND ")}`,u=" ORDER BY id ASC";let o="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),o=` LIMIT ?${d.length.toString()}`);const{results:_}=await e.prepare(`SELECT * FROM ${n}${l}${u}${o}`).bind(...d).all();return _.map(p=>T(p))},markStatus:async(t,a,d)=>{await r(),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 r(),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 WHERE ${n}.user_id IS NULL OR ${n}.user_id = ?7`).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=await E(t.id),d=S(a,t);if(d!==void 0)throw d;const l=N(t);if(l!==void 0&&l!==t.id){const u=t.userId??null;await(u===null?e.prepare(`DELETE FROM ${n} WHERE id = ?1 AND user_id IS NULL`).bind(l):e.prepare(`DELETE FROM ${n} WHERE id = ?1 AND (user_id IS NULL OR user_id = ?2)`).bind(l,u)).run()}return a??t}}};export{A as d1SubscriptionStore};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as d}from"@lunora/errors";import{i as c,k as f}from"./normalize-BdOnBfM2.mjs";const u=r=>r.filter(t=>t.status==="failed").map(t=>t.id),v=(r,t)=>r.send({...t,type:"lunora.push.broadcast"}),g=(r,t,s)=>{if(t===void 0)return;if(r?.limit===void 0)return{...r,after:t};const e=r.limit-s;return e>0?{...r,after:t,limit:e}:void 0},h=/no registered subscription/u,p=async(r,t,s)=>{try{const e=await r.send(s,t);if(e.successful)return{id:s,status:"ok"};const n=e.errorMessages.join("; ");return{error:n,id:s,status:c(n,f(s))?"expired":"failed"}}catch(e){const n=e instanceof Error?e.message:String(e);return{error:n,id:s,status:h.test(n)?"expired":"failed"}}},y=async(r,t,s)=>{const e=[];for(const o of s)e.push(await p(r,t,o));const n=u(e),a=e.filter(o=>o.status==="expired").length,i=e.length-n.length-a,l={failed:n.length,outcomes:e,pruned:a,sent:i,total:e.length};if(i===0&&n.length>0)throw new d("INTERNAL",`@lunora/notify: push retry failed for ${n.length.toString()} of ${e.length.toString()} subscription(s) — throwing so the queue retries and eventually dead-letters them`);return{failedIds:n,nextFilter:void 0,result:l}},w=async(r,t)=>{if(t.retryIds!==void 0&&t.retryIds.length>0)return y(r,t.payload,t.retryIds);const s=await r.broadcastPage(t.payload,t.filter);return{failedIds:u(s.result.outcomes),nextFilter:g(t.filter,s.nextCursor,s.result.total),result:s.result}};export{v as enqueuePushBroadcast,w as runPushBroadcastPage};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{c as e,f as r,i as c,k as d,e as l,l as I,g as f,n as g,t as i,w as o}from"./normalize-BdOnBfM2.mjs";export{e as claimRefusal,r as fcmId,c as isGoneError,d as kindOfId,l as legacyFcmId,I as legacyIdFor,f as legacyWebPushId,g as normalizeRegisterInput,i as targetOf,o as webPushId};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{c as n,l as a}from"./normalize-BdOnBfM2.mjs";const l=(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),m=()=>{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(l);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=t.get(e.id),o=n(r,e);if(o!==void 0)return Promise.reject(o);const s=r===void 0?e:{...r,...e,createdAt:r.createdAt};t.set(s.id,s);const d=a(e),i=d===void 0||d===e.id?void 0:t.get(d);return i!==void 0&&n(i,e)===void 0&&t.delete(i.id),Promise.resolve(s)}}};export{m as memorySubscriptionStore};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as c}from"@lunora/errors";const T=/^\d{1,3}$/u,S=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,I=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,P=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,O=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,D=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,N=/^\[|\]$/gu,k=/\.$/u,h=t=>{const e=t.split(".");if(e.length!==4)return;const n=e.map(r=>T.test(r)?Number(r):-1);if(!n.some(r=>r<0||r>255))return[n[0],n[1],n[2],n[3]]},l=([t,e])=>t===0||t===10||t===127||t===100&&e>=64&&e<=127||t===169&&e===254||t===172&&e>=16&&e<=31||t===192&&e===168||t>=224,E=(t,e)=>{const n=Number.parseInt(t??"",16),r=Number.parseInt(e??"",16);return!Number.isFinite(n)||!Number.isFinite(r)?!0:l([Math.floor(n/256),n%256,Math.floor(r/256),r%256])},$=t=>{const e=t.toLowerCase(),n=S.exec(e);if(n)return E(n[1],n[2]);const r=I.exec(e);if(r){const i=h(r[1]??"");return i===void 0||l(i)}const s=P.exec(e);if(s){const i=h(s[1]??"");return i===void 0||l(i)}const o=O.exec(e);return o?E(o[1],o[2]):D.test(e)||e.startsWith("2002:")||e.startsWith("2001:0:")?!0:e==="::"||e==="::1"||e.startsWith("fc")||e.startsWith("fd")||e.startsWith("fe8")||e.startsWith("fe9")||e.startsWith("fea")||e.startsWith("feb")},B=t=>t==="localhost"||t.endsWith(".localhost")||t.endsWith(".local")||t.endsWith(".internal")||t.endsWith(".home.arpa"),R=t=>t.replaceAll(N,"").replace(k,"").toLowerCase(),W=t=>{const e=R(t);if(e.includes(":"))return $(e);const n=h(e);return n===void 0?B(e):l(n)},x=2166136261,F=16777619,M=(t,e=x)=>{let n=e;for(let r=0;r<t.length;r+=1)n^=t.charCodeAt(r),n=Math.imul(n,F);return(n>>>0).toString(16).padStart(8,"0")},u=t=>t.toString(16).padStart(4,"0"),b=t=>{let e=8997,n=33826,r=40164,s=52210;for(let o=0;o<t.length;o+=1){e^=t.charCodeAt(o);const i=e*435,d=n*435,a=r*435+e*256,v=s*435+n*256,p=d+(i>>>16),g=a+(p>>>16),A=v+(g>>>16);e=i&65535,n=p&65535,r=g&65535,s=A&65535}return u(s)+u(r)+u(n)+u(e)},y=4096,H=2048,L=2048,_=512,f=(t,e,n)=>{const r=new TextEncoder().encode(t).length;if(r>e)throw new c("BAD_REQUEST",`@lunora/notify: register() \`${n}\` is ${r.toString()} bytes, exceeding the ${e.toString()}-byte cap`)},w=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 c("BAD_REQUEST","@lunora/notify: register() `metadata` must be a plain object");let r;try{r=JSON.stringify(t)}catch(o){throw new c("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is not JSON-serialisable: ${o instanceof Error?o.message:String(o)}`)}const s=new TextEncoder().encode(r).length;if(s>y)throw new c("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is ${s.toString()} bytes, exceeding the ${y.toString()}-byte cap`);return t},U=t=>`wp2_${b(t)}`,C=t=>`fcm2_${b(t)}`,K=t=>t.startsWith("fcm2_")||t.startsWith("fcm_")?"fcm":t.startsWith("wp2_")||t.startsWith("wp_")?"web-push":void 0,m=t=>M(t),Q=t=>`wp_${m(t)}`,V=t=>`fcm_${m(t)}`,q=t=>t.kind==="fcm"?t.token===void 0?void 0:V(t.token):t.endpoint===void 0?void 0:Q(t.endpoint),Z=(t,e)=>{const n=t?.userId??null;if(!(t===void 0||n===null||n===(e.userId??null)))return new c("FORBIDDEN",`@lunora/notify: subscription "${e.id}" is registered to a different user; a device must be unregistered by its owner before another account can claim it`)},X=t=>{if(typeof t!="string")return t??{};try{return JSON.parse(t)}catch(e){throw new c("BAD_REQUEST",`@lunora/notify: register() web-push subscription is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}},j=(t,e)=>{let n;try{n=new URL(t)}catch{throw new c("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must be an absolute https URL (got "${t}")`)}if(n.protocol!=="https:")throw new c("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 c("FORBIDDEN",`@lunora/notify: register() web-push endpoint origin "${n.origin}" is not in the configured allowedPushOrigins allowlist`);return}if(W(n.hostname))throw new c("FORBIDDEN",`@lunora/notify: register() web-push endpoint host "${n.hostname}" is a private/internal address; configure allowedPushOrigins to permit a specific origin`)},tt=(t,e=Date.now(),n={})=>{if(t.kind===void 0?t.token!==void 0:t.kind==="fcm"){const{token:a}=t;if(typeof a!="string"||a==="")throw new c("BAD_REQUEST","@lunora/notify: register() fcm input requires a non-empty `token`");return f(a,L,"token"),{createdAt:e,id:C(a),kind:"fcm",lastSeenAt:e,metadata:w(t.metadata),token:a,userId:t.userId??null}}const s=X(t.subscription),{endpoint:o}=s,i=s.keys?.p256dh,d=s.keys?.auth;if(typeof o!="string"||o===""||typeof i!="string"||typeof d!="string")throw new c("BAD_REQUEST","@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");return f(o,H,"endpoint"),f(d,_,"keys.auth"),f(i,_,"keys.p256dh"),j(o,n.allowedPushOrigins),{createdAt:e,endpoint:o,id:U(o),keys:{auth:d,p256dh:i},kind:"web-push",lastSeenAt:e,metadata:w(t.metadata),userId:t.userId??null}},et=t=>t.kind==="fcm"?t.token??"":JSON.stringify({endpoint:t.endpoint,keys:t.keys}),G=/\bhttp\s*4(?:04|10)\b/iu,J=/\b(?:unregistered|not[\s-]?registered|registration-token-not-registered|requested entity was not found)\b/iu,Y=/\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu,nt=(t,e)=>t===void 0?!1:G.test(t)||Y.test(t)?!0:e!=="web-push"&&J.test(t);export{R as a,l as b,Z as c,$ as d,V as e,C as f,Q as g,nt as i,K as k,q as l,tt as n,h as p,et as t,U as w};
|
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.38",
|
|
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 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};
|
|
@@ -1 +0,0 @@
|
|
|
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};
|
|
@@ -1 +0,0 @@
|
|
|
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};
|
|
@@ -1 +0,0 @@
|
|
|
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};
|
|
@@ -1 +0,0 @@
|
|
|
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};
|
|
@@ -1 +0,0 @@
|
|
|
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};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const u=/^\d{1,3}$/u,d=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,p=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,l=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,P=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,h=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,v=/^\[|\]$/gu,I=/\.$/u,a=e=>{const t=e.split(".");if(t.length!==4)return;const s=t.map(r=>u.test(r)?Number(r):-1);if(!s.some(r=>r<0||r>255))return[s[0],s[1],s[2],s[3]]},o=([e,t])=>e===0||e===10||e===127||e===100&&t>=64&&t<=127||e===169&&t===254||e===172&&t>=16&&t<=31||e===192&&t===168||e>=224,f=(e,t)=>{const s=Number.parseInt(e??"",16),r=Number.parseInt(t??"",16);return!Number.isFinite(s)||!Number.isFinite(r)?!0:o([Math.floor(s/256),s%256,Math.floor(r/256),r%256])},m=e=>{const t=e.toLowerCase(),s=d.exec(t);if(s)return f(s[1],s[2]);const r=p.exec(t);if(r){const n=a(r[1]??"");return n===void 0||o(n)}const c=l.exec(t);if(c){const n=a(c[1]??"");return n===void 0||o(n)}const i=P.exec(t);return i?f(i[1],i[2]):h.test(t)||t.startsWith("2002:")||t.startsWith("2001:0:")?!0:t==="::"||t==="::1"||t.startsWith("fc")||t.startsWith("fd")||t.startsWith("fe8")||t.startsWith("fe9")||t.startsWith("fea")||t.startsWith("feb")},_=e=>e==="localhost"||e.endsWith(".localhost")||e.endsWith(".local")||e.endsWith(".internal")||e.endsWith(".home.arpa"),E=e=>e.replaceAll(v,"").replace(I,"").toLowerCase(),T=e=>{const t=E(e);if(t.includes(":"))return m(t);const s=a(t);return s===void 0?_(t):o(s)};export{o as a,m as b,T as i,E as n,a as p};
|