@lunora/notify 1.0.0-alpha.5 → 1.0.0-alpha.51

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
@@ -39,35 +39,90 @@ Generate a VAPID keypair once: `npx web-push generate-vapid-keys`.
39
39
  ```ts
40
40
  import { subscribeToPush } from "@lunora/notify/web";
41
41
 
42
- const subscription = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
43
- await client.mutation("registerDevice", { subscription });
42
+ const { replacedEndpoint, subscription } = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
43
+ await client.mutation("registerDevice", { replacedEndpoint, subscription });
44
44
  ```
45
45
 
46
+ `replacedEndpoint` is set only after a **VAPID key rotation**: the stale browser
47
+ subscription is dropped and a new one minted, and the new one has a new endpoint
48
+ — hence a new store id — so it never upserts over the old row. Every send to that
49
+ row now answers `403 VapidPkHashMismatch`, which is (correctly) not a "gone"
50
+ signal, so nothing prunes it either. Forward it and unregister it:
51
+
46
52
  ```ts
47
53
  // lunora/registerDevice.ts (a mutation — storage write is fine here)
48
- export const registerDevice = mutation({
49
- args: { subscription: v.any() },
50
- handler: async (ctx, { subscription }) => {
54
+ import { webPushId } from "@lunora/notify";
55
+
56
+ export const registerDevice = mutation
57
+ .input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
58
+ .mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
59
+ if (replacedEndpoint !== undefined) {
60
+ await ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId });
61
+ }
62
+
51
63
  await ctx.push.register({ subscription, userId: ctx.auth?.userId });
52
- },
64
+ });
65
+ ```
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
+
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 });
53
97
  });
54
98
  ```
55
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
+
56
114
  ## Send (from an action)
57
115
 
58
116
  Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
59
117
 
60
118
  ```ts
61
- export const announce = action({
62
- args: { title: v.string(), body: v.string() },
63
- handler: async (ctx, { title, body }) => {
64
- const result = await ctx.push.broadcast({ title, body });
65
- // result: { total, sent, pruned, failed, outcomes }
66
- },
119
+ export const announce = action.input({ title: v.string(), body: v.string() }).action(async ({ ctx, args: { title, body } }) => {
120
+ const result = await ctx.push.broadcast({ title, body });
121
+ // result: { total, sent, pruned, failed, outcomes }
67
122
  });
68
123
  ```
69
124
 
70
- `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:
71
126
 
72
127
  ```ts
73
128
  await ctx.push.send(subscriptionId, { title: "Hi", body: "…" });
@@ -90,8 +145,36 @@ Move a large broadcast off the request path with `@lunora/queue`:
90
145
  // producer (mutation/action)
91
146
  await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
92
147
 
93
- // consumer (lunora/queues.ts)
94
- for (const message of batch.messages) await runPushBroadcastJob(ctx.push, message.body);
148
+ // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
149
+ // `ctx.push` and `ctx.queues` exist.
150
+ export const deliverPage = internalAction.input({ job: v.any() }).action(async ({ args: { job }, ctx }) => {
151
+ const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
152
+
153
+ // One message = ONE bounded page. Discarding `nextFilter` delivers only the
154
+ // first page (default 250 devices) and reports success for the whole audience.
155
+ // Pass it verbatim — it carries the cursor AND the remaining `filter.limit`.
156
+ if (nextFilter !== undefined) {
157
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: nextFilter });
158
+ }
159
+
160
+ // Redeliver ONLY the recipients that failed — a retry of the whole page would
161
+ // re-POST everyone it already reached.
162
+ if (failedIds.length > 0) {
163
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
164
+ }
165
+ });
166
+
167
+ // lunora/queues.ts — a `QueueRunContext` is exactly `{ env, log, run }`: no
168
+ // `ctx.push`, no `ctx.queues`. The consumer hands each message to the action above.
169
+ // (Note the handler signature: `(context, batch)`, in that order.)
170
+ export const push = defineQueue<PushBroadcastJob>({
171
+ handler: async (context, batch) => {
172
+ for (const message of batch.messages) {
173
+ await message.run(internal.notifyFanout.deliverPage, { job: message.body });
174
+ message.ack();
175
+ }
176
+ },
177
+ });
95
178
  ```
96
179
 
97
180
  ## Subscription storage
@@ -123,15 +206,17 @@ client-supplied data, so the facade enforces two boundaries:
123
206
  - **No secrets on the app facade.** `ctx.push.list()`
124
207
  returns the registered devices with the delivery **secrets stripped** — the Web
125
208
  Push `keys` (`auth`/`p256dh`) and the FCM `token`, which together with the
126
- endpoint are enough to deliver arbitrary push to a device. The raw rows are
127
- reachable only through the internal `SubscriptionStore` (which handlers never
128
- hold); the broadcast path uses the store directly.
209
+ endpoint are enough to deliver arbitrary push to a device. Every other facade
210
+ read is projected the same way; the broadcast path uses the store directly. The
211
+ one place a handler does see a raw row is the return of `ctx.push.register(...)`,
212
+ which echoes back the record the caller just supplied — nothing it did not
213
+ already hold, and never another device's.
129
214
 
130
215
  ## Delivery observability
131
216
 
132
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:
133
218
 
134
- - **`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.
135
220
  - **`notify.skipped`** `{ channel, reason }` — a send that reached nobody: `no-subscriptions-matched` (empty broadcast) or `channel-not-configured`.
136
221
 
137
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).