@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 +11 -1
- package/dist/index.d.mts +56 -3
- package/dist/index.d.ts +56 -3
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/{createNotify-BARZyA-R.mjs → createNotify-DmRk1CHx.mjs} +1 -1
- package/dist/packem_shared/d1SubscriptionStore-Cpi56NIh.mjs +1 -0
- package/dist/packem_shared/memorySubscriptionStore-DiVmkZEp.mjs +1 -0
- package/dist/web.d.mts +3 -2
- package/dist/web.d.ts +3 -2
- package/package.json +1 -1
- package/dist/packem_shared/d1SubscriptionStore-C3cCfrHL.mjs +0 -1
- package/dist/packem_shared/memorySubscriptionStore-BfaIGuWs.mjs +0 -1
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
|
-
/**
|
|
283
|
-
|
|
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
|
-
/**
|
|
283
|
-
|
|
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-
|
|
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-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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};
|