@lunora/notify 1.0.0-alpha.36 → 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 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, FCM `UNREGISTERED`). A single targeted send:
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 / FCM `UNREGISTERED` — 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.
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
- /** Insert or update a subscription (upsert by id). */
177
+ /**
178
+ * Insert or update a subscription (upsert by id), refusing to move an
179
+ * existing row to a DIFFERENT owner.
180
+ *
181
+ * The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
182
+ * carries, and for the same reason: the id is derived from the endpoint or the
183
+ * FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
184
+ * caller who could guess or observe another user's endpoint re-register it
185
+ * under their own `userId` with keys of their choosing — the victim's device
186
+ * then fails every send (an encryption failure is not a gone signal, so it is
187
+ * never pruned either) and the attacker can `unregister` it as their own. The
188
+ * `unregister` guard alone closed exactly half of that (CWE-639).
189
+ *
190
+ * A row with no owner is claimable (the device signed in), and a row the caller
191
+ * already owns is theirs to refresh — that is the routine service-worker
192
+ * re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
193
+ * no-op: unlike `unregister` there is nothing safe to return, since a caller
194
+ * gets the stored record back and it would be someone else's delivery keys.
195
+ * A shared device that legitimately changes hands is handled by its current
196
+ * owner calling `unregister` first.
197
+ *
198
+ * **The predicate and the write must be ONE operation**, as for `deleteOwned`:
199
+ * between a read that checks the owner and the write that acts on it, another
200
+ * registration can replace the row. The D1 store puts the predicate in the
201
+ * `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
202
+ * between the two.
203
+ */
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 `UNREGISTERED`) and pruned; push-only.
247
+ * - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
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 (HTTP 404/410, FCM `UNREGISTERED`). The `to`
292
+ * the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
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 a huge audience is never
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`. This call still processes the WHOLE matched
273
- * audience in one request/queue message; use {@link LunoraPush.broadcastPage}
274
- * directly (as `runPushBroadcastPage` does) to bound a single queue message
275
- * to one page.
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
- /** Register (upsert) a device subscription and return the stored record (the caller's own row, secrets included). */
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
- * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
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) 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.
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
- /** Insert or update a subscription (upsert by id). */
177
+ /**
178
+ * Insert or update a subscription (upsert by id), refusing to move an
179
+ * existing row to a DIFFERENT owner.
180
+ *
181
+ * The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
182
+ * carries, and for the same reason: the id is derived from the endpoint or the
183
+ * FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
184
+ * caller who could guess or observe another user's endpoint re-register it
185
+ * under their own `userId` with keys of their choosing — the victim's device
186
+ * then fails every send (an encryption failure is not a gone signal, so it is
187
+ * never pruned either) and the attacker can `unregister` it as their own. The
188
+ * `unregister` guard alone closed exactly half of that (CWE-639).
189
+ *
190
+ * A row with no owner is claimable (the device signed in), and a row the caller
191
+ * already owns is theirs to refresh — that is the routine service-worker
192
+ * re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
193
+ * no-op: unlike `unregister` there is nothing safe to return, since a caller
194
+ * gets the stored record back and it would be someone else's delivery keys.
195
+ * A shared device that legitimately changes hands is handled by its current
196
+ * owner calling `unregister` first.
197
+ *
198
+ * **The predicate and the write must be ONE operation**, as for `deleteOwned`:
199
+ * between a read that checks the owner and the write that acts on it, another
200
+ * registration can replace the row. The D1 store puts the predicate in the
201
+ * `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
202
+ * between the two.
203
+ */
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 `UNREGISTERED`) and pruned; push-only.
247
+ * - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
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 (HTTP 404/410, FCM `UNREGISTERED`). The `to`
292
+ * the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
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 a huge audience is never
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`. This call still processes the WHOLE matched
273
- * audience in one request/queue message; use {@link LunoraPush.broadcastPage}
274
- * directly (as `runPushBroadcastPage` does) to bound a single queue message
275
- * to one page.
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
- /** Register (upsert) a device subscription and return the stored record (the caller's own row, secrets included). */
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
- * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
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) 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.
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 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
+ 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.36",
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",
@@ -49,7 +49,7 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
- "@lunora/errors": "1.0.0-alpha.30",
52
+ "@lunora/errors": "1.0.0-alpha.31",
53
53
  "@visulima/notification": "1.0.12"
54
54
  },
55
55
  "engines": {
@@ -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};