@lunora/notify 1.0.0-alpha.8 → 1.0.0-alpha.81

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
@@ -22,7 +22,7 @@ export default defineNotify({
22
22
  });
23
23
  ```
24
24
 
25
- `.dev.vars` (scaffolded by `lunora dev` from `@lunora/config`'s package-secrets registry):
25
+ `.dev.vars` (scaffolded by `lunora dev` from `@lunora/config`'s package-secrets registry — only for the channels `defineNotify` configures):
26
26
 
27
27
  ```
28
28
  VAPID_PUBLIC_KEY=<your-vapid-public-key>
@@ -39,40 +39,105 @@ 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
+ Without `serviceWorkerUrl`, `subscribeToPush` uses the page's existing
47
+ registration: await your own `navigator.serviceWorker.register()` first, or the
48
+ call rejects with "no service worker is registered" instead of waiting for it.
49
+
50
+ `replacedEndpoint` is set only after a **VAPID key rotation**: the stale browser
51
+ subscription is dropped and a new one minted, and the new one has a new endpoint
52
+ — hence a new store id — so it never upserts over the old row. Every send to that
53
+ row now answers `403 VapidPkHashMismatch`, which is (correctly) not a "gone"
54
+ signal, so nothing prunes it either. Forward it and unregister it:
55
+
46
56
  ```ts
47
57
  // 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 }) => {
58
+ import { webPushId } from "@lunora/notify";
59
+
60
+ export const registerDevice = mutation
61
+ .input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
62
+ .mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
63
+ if (replacedEndpoint !== undefined) {
64
+ await ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId });
65
+ }
66
+
51
67
  await ctx.push.register({ subscription, userId: ctx.auth?.userId });
52
- },
68
+ });
69
+ ```
70
+
71
+ `unregister`'s owner argument is **required**, and the row is removed only when
72
+ it carries that same owner. A subscription id is derived from the endpoint, so
73
+ `replacedEndpoint` is a caller-controlled key and nothing about it proves the
74
+ browser that sent it ever held the subscription it names — without the scope,
75
+ anyone who could guess or observe another user's endpoint could silence that
76
+ device. A row owned by someone else is left alone silently, so the call cannot be
77
+ used to probe which endpoints exist. Register with the same `userId` you
78
+ unregister with; devices registered anonymously (`userId` absent) all share the
79
+ one anonymous scope and get no separation from this check.
80
+
81
+ `register` is scoped the same way, because an unguarded upsert closes only half
82
+ of that: re-registering a victim's endpoint under your own `userId` (with keys of
83
+ your choosing) takes their device dark just as effectively, and hands you
84
+ `unregister` over it. An endpoint already registered to another user is
85
+ **refused** (`FORBIDDEN`) rather than re-owned — unowned rows stay claimable (the
86
+ device signed in), and a device that legitimately changes hands unregisters as
87
+ its current owner first.
88
+
89
+ **Unregister on sign-out, or the next account on that browser cannot register.**
90
+ `subscribeToPush` REUSES the browser's existing subscription while the VAPID key
91
+ is unchanged, so the endpoint — and the store id derived from it — is the same
92
+ for every account that signs in on that browser. Without the sign-out call, user
93
+ B's `register` hits user A's row and throws `FORBIDDEN`; since `register` is
94
+ usually fire-and-forget on sign-in, that surfaces as a failed mutation and B
95
+ silently never receives a push. Release the row where you clear the session:
96
+
97
+ ```ts
98
+ // lunora/registerDevice.ts — the same file as above
99
+ export const unregisterDevice = mutation.input({ endpoint: v.string() }).mutation(async ({ args: { endpoint }, ctx }) => {
100
+ await ctx.push.unregister(webPushId(endpoint), { userId: ctx.auth?.userId });
53
101
  });
54
102
  ```
55
103
 
104
+ ```ts
105
+ // wherever you sign out. `subscription.endpoint` is on the object subscribeToPush returned.
106
+ await client.mutation("unregisterDevice", { endpoint: subscription.endpoint });
107
+ await auth.signOut();
108
+ ```
109
+
110
+ Keep the browser subscription itself (don't call `unsubscribeFromPush`) unless
111
+ the user is turning notifications off: dropping it re-prompts for permission on
112
+ the next sign-in. Note that only the owner can release a row — B cannot
113
+ `unregister` A's — so a sign-out that never runs (the tab was closed, the session
114
+ expired) leaves the next account refused until A signs in again on that browser
115
+ or the row is removed server-side. On a browser several people sign in on, treat
116
+ the sign-out unregister as required, not as cleanup.
117
+
56
118
  ## Send (from an action)
57
119
 
58
120
  Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
59
121
 
60
122
  ```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
- },
123
+ export const announce = action.input({ title: v.string(), body: v.string() }).action(async ({ ctx, args: { title, body } }) => {
124
+ const result = await ctx.push.broadcast({ title, body });
125
+ // result: { total, sent, pruned, failed, outcomes }
67
126
  });
68
127
  ```
69
128
 
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:
129
+ `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
130
 
72
131
  ```ts
73
132
  await ctx.push.send(subscriptionId, { title: "Hi", body: "…" });
74
133
  ```
75
134
 
135
+ `send` and `broadcast` take per-message delivery hints, overriding the channel's `urgency` / `ttl`. Web Push sends them as the `Urgency` / `TTL` headers; FCM maps them to `android.priority` / `android.ttl`:
136
+
137
+ ```ts
138
+ await ctx.push.broadcast({ title: "Approval needed", body: "…", urgency: "high", ttl: 3600 }, { userId });
139
+ ```
140
+
76
141
  Multi-channel through `ctx.notify.send`:
77
142
 
78
143
  ```ts
@@ -90,8 +155,36 @@ Move a large broadcast off the request path with `@lunora/queue`:
90
155
  // producer (mutation/action)
91
156
  await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
92
157
 
93
- // consumer (lunora/queues.ts)
94
- for (const message of batch.messages) await runPushBroadcastJob(ctx.push, message.body);
158
+ // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
159
+ // `ctx.push` and `ctx.queues` exist.
160
+ export const deliverPage = internalAction.input({ job: v.any() }).action(async ({ args: { job }, ctx }) => {
161
+ const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
162
+
163
+ // One message = ONE bounded page. Discarding `nextFilter` delivers only the
164
+ // first page (default 250 devices) and reports success for the whole audience.
165
+ // Pass it verbatim — it carries the cursor AND the remaining `filter.limit`.
166
+ if (nextFilter !== undefined) {
167
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: nextFilter });
168
+ }
169
+
170
+ // Redeliver ONLY the recipients that failed — a retry of the whole page would
171
+ // re-POST everyone it already reached.
172
+ if (failedIds.length > 0) {
173
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
174
+ }
175
+ });
176
+
177
+ // lunora/queues.ts — a `QueueRunContext` is exactly `{ env, log, run }`: no
178
+ // `ctx.push`, no `ctx.queues`. The consumer hands each message to the action above.
179
+ // (Note the handler signature: `(context, batch)`, in that order.)
180
+ export const push = defineQueue<PushBroadcastJob>({
181
+ handler: async (context, batch) => {
182
+ for (const message of batch.messages) {
183
+ await message.run(internal.notifyFanout.deliverPage, { job: message.body });
184
+ message.ack();
185
+ }
186
+ },
187
+ });
95
188
  ```
96
189
 
97
190
  ## Subscription storage
@@ -123,15 +216,17 @@ client-supplied data, so the facade enforces two boundaries:
123
216
  - **No secrets on the app facade.** `ctx.push.list()`
124
217
  returns the registered devices with the delivery **secrets stripped** — the Web
125
218
  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.
219
+ endpoint are enough to deliver arbitrary push to a device. Every other facade
220
+ read is projected the same way; the broadcast path uses the store directly. The
221
+ one place a handler does see a raw row is the return of `ctx.push.register(...)`,
222
+ which echoes back the record the caller just supplied — nothing it did not
223
+ already hold, and never another device's.
129
224
 
130
225
  ## Delivery observability
131
226
 
132
227
  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
228
 
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.
229
+ - **`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
230
  - **`notify.skipped`** `{ channel, reason }` — a send that reached nobody: `no-subscriptions-matched` (empty broadcast) or `channel-not-configured`.
136
231
 
137
232
  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).