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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,13 +57,23 @@ export const registerDevice = mutation
57
57
  .input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
58
58
  .mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
59
59
  if (replacedEndpoint !== undefined) {
60
- await ctx.push.unregister(webPushId(replacedEndpoint));
60
+ await ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId });
61
61
  }
62
62
 
63
63
  await ctx.push.register({ subscription, userId: ctx.auth?.userId });
64
64
  });
65
65
  ```
66
66
 
67
+ `unregister`'s owner argument is **required**, and the row is removed only when
68
+ it carries that same owner. A subscription id is derived from the endpoint, so
69
+ `replacedEndpoint` is a caller-controlled key and nothing about it proves the
70
+ browser that sent it ever held the subscription it names — without the scope,
71
+ anyone who could guess or observe another user's endpoint could silence that
72
+ device. A row owned by someone else is left alone silently, so the call cannot be
73
+ used to probe which endpoints exist. Register with the same `userId` you
74
+ unregister with; devices registered anonymously (`userId` absent) all share the
75
+ one anonymous scope and get no separation from this check.
76
+
67
77
  ## Send (from an action)
68
78
 
69
79
  Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
package/dist/index.d.mts CHANGED
@@ -138,6 +138,30 @@ interface SubscriptionFilter {
138
138
  interface SubscriptionStore {
139
139
  /** Remove a subscription by id (idempotent). */
140
140
  delete: (id: string) => Promise<void>;
141
+ /**
142
+ * Remove a subscription by id ONLY if it is owned by `userId`, and report
143
+ * whether it was.
144
+ *
145
+ * Separate from {@link SubscriptionStore.delete} because the caller-facing
146
+ * `unregister` must not be a read followed by a write: between a `get` that
147
+ * checks the owner and a `delete` that acts on it, a re-registration can
148
+ * replace the row, so the check passes for one owner and the removal lands on
149
+ * another's subscription.
150
+ *
151
+ * **The predicate and the removal must be ONE operation.** A store that
152
+ * cannot do that atomically should say so in its own documentation rather
153
+ * than implement this as a get-then-delete, which reintroduces the race this
154
+ * method exists to remove. Both shipped stores manage it: the in-memory one
155
+ * because a `Map` check-and-delete has no await between the two, and the D1
156
+ * one with a single `DELETE … WHERE id = ? AND user_id = ? RETURNING id`.
157
+ *
158
+ * `userId` is `null` for an anonymous subscription, and matches only a row
159
+ * that is itself unowned.
160
+ * @param id The subscription id.
161
+ * @param userId The owner the row must carry, or `null` for unowned.
162
+ * @returns `true` when a row was removed.
163
+ */
164
+ deleteOwned: (id: string, userId: string | null) => Promise<boolean>;
141
165
  /** Read a subscription by id, or `undefined`. */
142
166
  get: (id: string) => Promise<StoredSubscription | undefined>;
143
167
  /**
@@ -279,8 +303,37 @@ interface LunoraPush {
279
303
  register: (input: RegisterInput) => Promise<StoredSubscription>;
280
304
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
281
305
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
282
- /** Remove a subscription by id (idempotent). */
283
- unregister: (id: string) => Promise<void>;
306
+ /**
307
+ * Remove ONE of `owner`'s subscriptions by id (idempotent).
308
+ *
309
+ * `owner` is not optional, and the removal happens only when the stored row
310
+ * carries that same owner. A subscription id is derived from the endpoint
311
+ * (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
312
+ * intended call is a mutation forwarding `subscribeToPush`'s
313
+ * `replacedEndpoint` after a VAPID rotation, and nothing about that argument
314
+ * proves the browser sending it ever held the subscription it names.
315
+ * Deleting by id alone let any caller that could guess or observe another
316
+ * user's endpoint silence that device's notifications (CWE-639).
317
+ *
318
+ * A row belonging to someone else is left alone SILENTLY rather than
319
+ * refused, so the call cannot be used to probe which endpoints exist — the
320
+ * same answer, and the same absence of a write, as an id that was never
321
+ * registered.
322
+ *
323
+ * `{ userId: null }` (or `undefined`, which normalises to it) addresses the
324
+ * anonymous rows — those registered with no `userId`. An app that registers
325
+ * every device anonymously therefore gets no separation from this check;
326
+ * pass `ctx.auth?.userId` and register with it to get any.
327
+ */
328
+ unregister: (id: string, owner: PushOwner) => Promise<void>;
329
+ }
330
+ /** Who a {@link LunoraPush.unregister} call is acting as. */
331
+ interface PushOwner {
332
+ /**
333
+ * The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
334
+ * an anonymous registration. Required — see {@link LunoraPush.unregister}.
335
+ */
336
+ userId: string | null | undefined;
284
337
  }
285
338
  /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
286
339
  type PushContent = Omit<PushPayload, "to">;
@@ -817,7 +870,7 @@ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions,
817
870
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
818
871
  * @packageDocumentation
819
872
  */
820
- FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
873
+ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushOwner, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
821
874
  /**
822
875
  * `@lunora/notify`
823
876
  *
package/dist/index.d.ts CHANGED
@@ -138,6 +138,30 @@ interface SubscriptionFilter {
138
138
  interface SubscriptionStore {
139
139
  /** Remove a subscription by id (idempotent). */
140
140
  delete: (id: string) => Promise<void>;
141
+ /**
142
+ * Remove a subscription by id ONLY if it is owned by `userId`, and report
143
+ * whether it was.
144
+ *
145
+ * Separate from {@link SubscriptionStore.delete} because the caller-facing
146
+ * `unregister` must not be a read followed by a write: between a `get` that
147
+ * checks the owner and a `delete` that acts on it, a re-registration can
148
+ * replace the row, so the check passes for one owner and the removal lands on
149
+ * another's subscription.
150
+ *
151
+ * **The predicate and the removal must be ONE operation.** A store that
152
+ * cannot do that atomically should say so in its own documentation rather
153
+ * than implement this as a get-then-delete, which reintroduces the race this
154
+ * method exists to remove. Both shipped stores manage it: the in-memory one
155
+ * because a `Map` check-and-delete has no await between the two, and the D1
156
+ * one with a single `DELETE … WHERE id = ? AND user_id = ? RETURNING id`.
157
+ *
158
+ * `userId` is `null` for an anonymous subscription, and matches only a row
159
+ * that is itself unowned.
160
+ * @param id The subscription id.
161
+ * @param userId The owner the row must carry, or `null` for unowned.
162
+ * @returns `true` when a row was removed.
163
+ */
164
+ deleteOwned: (id: string, userId: string | null) => Promise<boolean>;
141
165
  /** Read a subscription by id, or `undefined`. */
142
166
  get: (id: string) => Promise<StoredSubscription | undefined>;
143
167
  /**
@@ -279,8 +303,37 @@ interface LunoraPush {
279
303
  register: (input: RegisterInput) => Promise<StoredSubscription>;
280
304
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
281
305
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
282
- /** Remove a subscription by id (idempotent). */
283
- unregister: (id: string) => Promise<void>;
306
+ /**
307
+ * Remove ONE of `owner`'s subscriptions by id (idempotent).
308
+ *
309
+ * `owner` is not optional, and the removal happens only when the stored row
310
+ * carries that same owner. A subscription id is derived from the endpoint
311
+ * (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
312
+ * intended call is a mutation forwarding `subscribeToPush`'s
313
+ * `replacedEndpoint` after a VAPID rotation, and nothing about that argument
314
+ * proves the browser sending it ever held the subscription it names.
315
+ * Deleting by id alone let any caller that could guess or observe another
316
+ * user's endpoint silence that device's notifications (CWE-639).
317
+ *
318
+ * A row belonging to someone else is left alone SILENTLY rather than
319
+ * refused, so the call cannot be used to probe which endpoints exist — the
320
+ * same answer, and the same absence of a write, as an id that was never
321
+ * registered.
322
+ *
323
+ * `{ userId: null }` (or `undefined`, which normalises to it) addresses the
324
+ * anonymous rows — those registered with no `userId`. An app that registers
325
+ * every device anonymously therefore gets no separation from this check;
326
+ * pass `ctx.auth?.userId` and register with it to get any.
327
+ */
328
+ unregister: (id: string, owner: PushOwner) => Promise<void>;
329
+ }
330
+ /** Who a {@link LunoraPush.unregister} call is acting as. */
331
+ interface PushOwner {
332
+ /**
333
+ * The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
334
+ * an anonymous registration. Required — see {@link LunoraPush.unregister}.
335
+ */
336
+ userId: string | null | undefined;
284
337
  }
285
338
  /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
286
339
  type PushContent = Omit<PushPayload, "to">;
@@ -817,7 +870,7 @@ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions,
817
870
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
818
871
  * @packageDocumentation
819
872
  */
820
- FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
873
+ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushOwner, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
821
874
  /**
822
875
  * `@lunora/notify`
823
876
  *
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-BARZyA-R.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-C3cCfrHL.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-BfaIGuWs.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 u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-DmRk1CHx.mjs";import{buildEngine as a,routingPushProvider as d}from"./packem_shared/buildEngine-zGk_1q8H.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-COksdUlz.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-Cpi56NIh.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-DiVmkZEp.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-hLwzY2K2.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
@@ -1 +1 @@
1
- import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-zGk_1q8H.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-BfaIGuWs.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},E=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a,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 E(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},O=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await O(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:O,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await E(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:e=>f.delete(e)},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};
1
+ import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-zGk_1q8H.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-DiVmkZEp.mjs";import{normalizeRegisterInput as R,targetOf as B,isGoneError as L}from"./fcmId-hLwzY2K2.mjs";const W=250,w=(o,l)=>typeof o=="function"?o(l):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),U=(o,l,d)=>o.successful?"accepted":L(l,d)?"gone":"failed",j=async(o,l,d)=>{const u=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,u[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(l,o.length)},()=>g())),u},G=(o,l)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,l),fcm:w(o.fcm,l),inApp:w(o.inApp,l),webhook:w(o.webhook,l),webPush:w(o.webPush,l)}),x=new WeakMap,Q=(o,l)=>{let d=x.get(o);d===void 0&&(d=new WeakMap,x.set(o,d));let u=d.get(l);return u===void 0&&(u={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(l,u)),u},Y=(o,l,d={})=>{const u=Q(o,l);let h;d.engine===void 0?(u.engine??=z(G(o,l)),h=u.engine):h=d.engine,u.store??=o.store?.(l);let{store:g}=u;g===void 0&&(u.fallbackStore??=F(),!d.silent&&!u.warnedNoStore&&(u.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=u.fallbackStore);const f=g,C=Math.max(1,d.concurrency??o.concurrency??10),b=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??W),{log:M,metrics:P}=d,p=(e,t,r,n=1)=>{P?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{M?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{P?.count("notify.skipped",1,{channel:e,reason:t})},I=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||u.warnedNoPushOriginAllowlist||(u.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},T=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),_=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},O=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a,e.kind)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},D=async(e,t)=>{const r=await j(t,C,async s=>{const{error:c,status:i}=await O(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},E=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await E(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:E,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await O(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:async(e,t)=>{await f.deleteOwned(e,t.userId??null)}},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};
@@ -0,0 +1 @@
1
+ import{LunoraError as h}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-hLwzY2K2.mjs";const N=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},L=(e,i={})=>{const a=i.tableName??"lunora_push_subscriptions";if(!N(a))throw new h("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${a}" is not a bare SQL identifier`);let s;const r=()=>(s===void 0&&(s=e.prepare(`CREATE TABLE IF NOT EXISTS ${a} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${a}_user_id_idx ON ${a} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${a}_kind_idx ON ${a} (kind)`).run()).then(()=>{}),s.catch(()=>{s=void 0})),s),l=async t=>{await r();const n=await e.prepare(`SELECT * FROM ${a} WHERE id = ?1`).bind(t).first();return n===null?void 0:E(n)};return{delete:async t=>{await r(),await e.prepare(`DELETE FROM ${a} WHERE id = ?1`).bind(t).run()},deleteOwned:async(t,n)=>(await r(),await(n===null?e.prepare(`DELETE FROM ${a} WHERE id = ?1 AND user_id IS NULL RETURNING id`).bind(t):e.prepare(`DELETE FROM ${a} WHERE id = ?1 AND user_id = ?2 RETURNING id`).bind(t,n)).first()!==null),get:l,list:async t=>{await r();const n=[],d=[];t?.kind!==void 0&&(d.push(t.kind),n.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?n.push("user_id IS NULL"):(d.push(t.userId),n.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),n.push(`id > ?${d.length.toString()}`));const o=n.length===0?"":` WHERE ${n.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:p}=await e.prepare(`SELECT * FROM ${a}${o}${T}${u}`).bind(...d).all();return p.map(_=>E(_))},markStatus:async(t,n,d)=>{await r(),await e.prepare(`UPDATE ${a} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,n,d??null,Date.now()).run()},put:async t=>{await r(),await e.prepare(`INSERT INTO ${a} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const n=S(t);return n!==void 0&&n!==t.id&&await e.prepare(`DELETE FROM ${a} WHERE id = ?1`).bind(n).run(),await l(t.id)??t}}};export{L as d1SubscriptionStore};
@@ -0,0 +1 @@
1
+ import{legacyIdFor as n}from"./fcmId-hLwzY2K2.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,u=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),l=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),deleteOwned:(e,r)=>{const o=t.get(e);return o===void 0||(o.userId??null)!==r?Promise.resolve(!1):(t.delete(e),Promise.resolve(!0))},get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())u(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{l as memorySubscriptionStore};
package/dist/web.d.mts CHANGED
@@ -16,8 +16,9 @@ interface SubscribeToPushResult {
16
16
  * VAPID-rotation path, where the stale subscription is unsubscribed and a
17
17
  * new one minted under the current key.
18
18
  *
19
- * Send it to the server and unregister it
20
- * (`ctx.push.unregister(webPushId(replacedEndpoint))`). The new subscription
19
+ * Send it to the server and unregister it — owner-scoped, since this is a
20
+ * caller-supplied key
21
+ * (`ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId })`). The new subscription
21
22
  * carries a NEW endpoint, hence a new store id, so it never upserts over the
22
23
  * old row — and `403 VapidPkHashMismatch`, which every send to that row now
23
24
  * answers, is correctly not a "gone" signal, so nothing prunes it either.
package/dist/web.d.ts CHANGED
@@ -16,8 +16,9 @@ interface SubscribeToPushResult {
16
16
  * VAPID-rotation path, where the stale subscription is unsubscribed and a
17
17
  * new one minted under the current key.
18
18
  *
19
- * Send it to the server and unregister it
20
- * (`ctx.push.unregister(webPushId(replacedEndpoint))`). The new subscription
19
+ * Send it to the server and unregister it — owner-scoped, since this is a
20
+ * caller-supplied key
21
+ * (`ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId })`). The new subscription
21
22
  * carries a NEW endpoint, hence a new store id, so it never upserts over the
22
23
  * old row — and `403 VapidPkHashMismatch`, which every send to that row now
23
24
  * answers, is correctly not a "gone" signal, so nothing prunes it either.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/notify",
3
- "version": "1.0.0-alpha.35",
3
+ "version": "1.0.0-alpha.36",
4
4
  "description": "Multi-channel notifications for Lunora — ctx.notify / ctx.push over @visulima/notification: edge-safe Web Push + FCM, plus chat, in-app inbox and webhook channels, with subscription storage and queue-backed fan-out",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-hLwzY2K2.mjs";const c=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},O=(e,i={})=>{const n=i.tableName??"lunora_push_subscriptions";if(!c(n))throw new p("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${n}" is not a bare SQL identifier`);let r;const s=()=>(r===void 0&&(r=e.prepare(`CREATE TABLE IF NOT EXISTS ${n} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_user_id_idx ON ${n} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_kind_idx ON ${n} (kind)`).run()).then(()=>{}),r.catch(()=>{r=void 0})),r),l=async t=>{await s();const a=await e.prepare(`SELECT * FROM ${n} WHERE id = ?1`).bind(t).first();return a===null?void 0:E(a)};return{delete:async t=>{await s(),await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(t).run()},get:l,list:async t=>{await s();const a=[],d=[];t?.kind!==void 0&&(d.push(t.kind),a.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?a.push("user_id IS NULL"):(d.push(t.userId),a.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),a.push(`id > ?${d.length.toString()}`));const o=a.length===0?"":` WHERE ${a.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:_}=await e.prepare(`SELECT * FROM ${n}${o}${T}${u}`).bind(...d).all();return _.map(h=>E(h))},markStatus:async(t,a,d)=>{await s(),await e.prepare(`UPDATE ${n} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,a,d??null,Date.now()).run()},put:async t=>{await s(),await e.prepare(`INSERT INTO ${n} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const a=S(t);return a!==void 0&&a!==t.id&&await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(a).run(),await l(t.id)??t}}};export{O as d1SubscriptionStore};
@@ -1 +0,0 @@
1
- import{legacyIdFor as n}from"./fcmId-hLwzY2K2.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,a=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),c=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())a(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{c as memorySubscriptionStore};