From a6ca42123703fa56c06ecc1a349c828b18d1d263 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:37:25 +0000 Subject: [PATCH 1/9] fix(client): validate the VAPID key before touching an existing subscription subscribe() compared the new key against the existing subscription's key before anything checked the key was well-formed. An empty or wrong-length key (an unset env var, the private key by mistake) never matched, so the working subscription was unsubscribed as a key rotation, and only then did pushManager.subscribe reject the key. The key is now decoded and checked as a 65-byte uncompressed P-256 point first, before the permission prompt. getCurrentSubscription() awaited navigator.serviceWorker.ready, which never settles without a registered worker, so it and unsubscribe() / unsubscribeFromPush() hung. It now uses getRegistration() and resolves null when nothing is registered. Also adds the missing @throws on subscribe and unsubscribeFromPush, collapses unsubscribe's empty try/catch to .catch(), and documents the unsubscribe() -> removeSubscriptionFromServer(endpoint) flow in the README. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 14 ++++++++++++++ README.md | 11 +++++++++++ src/client.ts | 32 ++++++++++++++++++++++--------- test/client.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3b800..619cbde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to `@mmmike/web-push` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **`subscribe` no longer discards a working subscription when given a malformed VAPID + key.** An empty or wrong-length key (an unset env var, the private key by mistake) + never matched the existing subscription's key, so it was unsubscribed as a stale-key + rotation before `pushManager.subscribe` rejected the key. The key is now validated + first and throws `VAPID public key must be a 65-byte uncompressed P-256 point` + before the permission prompt. +- **`getCurrentSubscription`, `unsubscribe` and `unsubscribeFromPush` resolve when no + service worker is registered** (`null`, `null`, `true`) instead of waiting on + `navigator.serviceWorker.ready` forever. + ## [1.3.0] - 2026-08-14 ### Added diff --git a/README.md b/README.md index 661533c..4110a1f 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,17 @@ if (result.status === "subscribed") { POST the subscription on every visit rather than gating on `isNew`. Your endpoint has to upsert by endpoint URL anyway, since the browser hands back the same subscription on every call, and gating means one failed upload strands a subscription your server never hears about. Use `isNew` for what it does tell you: counting fresh subscribes and key rotations. +To turn push off, `unsubscribe` resolves to the endpoint your server keyed the record by, or `null` when there was nothing to unsubscribe: + +```typescript +import { unsubscribe, removeSubscriptionFromServer } from "@mmmike/web-push/client"; + +const endpoint = await unsubscribe(); +if (endpoint) { + await removeSubscriptionFromServer(endpoint, "/api/push/subscribe"); +} +``` + Requirements: - a secure context (HTTPS or `localhost`) diff --git a/src/client.ts b/src/client.ts index 58a15dd..7b69cb0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -49,6 +49,16 @@ const isBoundToKey = (subscription: PushSubscription, key: Uint8Array): boolean return boundBytes.length === key.length && boundBytes.every((byte, i) => byte === key[i]); }; +// Checked before the stale-key comparison: an empty or wrong-length key never +// matches, so it would otherwise unsubscribe a working subscription as a "rotation". +const decodeVapidPublicKey = (vapidPublicKey: string): Uint8Array => { + const key = urlBase64ToUint8Array(vapidPublicKey); + if (key.length !== 65 || key[0] !== 0x04) { + throw new Error("VAPID public key must be a 65-byte uncompressed P-256 point"); + } + return key; +}; + /** * Subscribe to push notifications, prompting for permission if needed. * @@ -60,6 +70,10 @@ const isBoundToKey = (subscription: PushSubscription, key: Uint8Array): boolean * one failed request strands a subscription your server never hears about. * * @param vapidPublicKey - The server's VAPID public key (URL-safe base64) + * @throws {Error} if `vapidPublicKey` is not a 65-byte uncompressed P-256 point; + * checked before the permission prompt and before any existing subscription is touched. + * @throws {DOMException} if the browser or push service refuses the subscription, or + * the stale-key subscription can't be unsubscribed during a rotation. * @example * ```ts * const result = await subscribe(vapidPublicKey); @@ -73,14 +87,14 @@ export const subscribe = async (vapidPublicKey: string): Promise return null; } - const registration = await navigator.serviceWorker.ready; - return registration.pushManager.getSubscription(); + // Not `ready`: it never settles without a registered worker, and no + // registration means no subscription. + const registration = await navigator.serviceWorker.getRegistration(); + return registration ? registration.pushManager.getSubscription() : null; }; /** @@ -125,6 +141,8 @@ export const getCurrentSubscription = async (): Promise * @deprecated Use {@link unsubscribe} instead: it returns the * unsubscribed endpoint, which the server needs to prune its matching * subscription record — this boolean drops it. + * @throws {DOMException} if the browser rejects the unsubscribe (e.g. the push + * service is unreachable), where `unsubscribe` still resolves the endpoint. */ export const unsubscribeFromPush = async (): Promise => { const subscription = await getCurrentSubscription(); @@ -148,11 +166,7 @@ export const unsubscribe = async (): Promise => { } const { endpoint } = subscription; - try { - await subscription.unsubscribe(); - } catch { - // The endpoint is the contract; a failed deregistration must not hide it. - } + await subscription.unsubscribe().catch(() => false); return endpoint; }; diff --git a/test/client.test.ts b/test/client.test.ts index 879c05c..eeee76d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -42,6 +42,7 @@ type EnvOptions = { permission?: NotificationPermission; promptResult?: NotificationPermission; existing?: FakeSubscription | null; + registered?: boolean; }; function PushManager() {} @@ -54,6 +55,7 @@ const setupPushEnv = (opts: EnvOptions = {}) => { permission = "granted", promptResult = permission, existing = null, + registered = true, } = opts; const created = makeSubscription("https://push.example.com/sub/new"); @@ -97,8 +99,11 @@ const setupPushEnv = (opts: EnvOptions = {}) => { if (hasPushManager) { windowStub.PushManager = PushManager; } + // Unregistered mirrors the browser: `ready` never settles, `getRegistration` is undefined. if (serviceWorker) { - navigatorStub.serviceWorker = { ready: Promise.resolve(registration) }; + navigatorStub.serviceWorker = registered + ? { ready: Promise.resolve(registration), getRegistration: async () => registration } + : { ready: new Promise(() => {}), getRegistration: async () => undefined }; } vi.stubGlobal("window", windowStub); @@ -140,7 +145,11 @@ describe("feature detection & permission", () => { }); }); -const randomVapidKey = () => uint8ArrayToUrlBase64(crypto.getRandomValues(new Uint8Array(65))); +const randomVapidKey = () => { + const point = crypto.getRandomValues(new Uint8Array(65)); + point[0] = 0x04; // SEC1 uncompressed-point tag + return uint8ArrayToUrlBase64(point); +}; const subscriptionBoundTo = (vapidPublicKey: string) => makeSubscription( "https://push.example.com/existing", @@ -161,7 +170,7 @@ describe("subscribe", () => { it("reports denied when the permission prompt is declined", async () => { setupPushEnv({ permission: "default", promptResult: "denied" }); - expect(await subscribe("BPk")).toEqual({ status: "denied" }); + expect(await subscribe(randomVapidKey())).toEqual({ status: "denied" }); }); it("prompts for permission and subscribes once granted", async () => { @@ -169,7 +178,7 @@ describe("subscribe", () => { permission: "default", promptResult: "granted", }); - expect(await subscribe("BPk")).toEqual({ + expect(await subscribe(randomVapidKey())).toEqual({ status: "subscribed", subscription: created, isNew: true, @@ -225,6 +234,23 @@ describe("subscribe", () => { applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); }); + + it.each([ + { kind: "an empty", key: "" }, + { kind: "a 32-byte (private-key-sized)", key: uint8ArrayToUrlBase64(new Uint8Array(32)) }, + ])( + "rejects $kind VAPID key before prompting, keeping the existing subscription", + async ({ key }) => { + const existing = subscriptionBoundTo(randomVapidKey()); + const { pushManager, NotificationStub } = setupPushEnv({ existing }); + await expect(subscribe(key)).rejects.toThrow( + "VAPID public key must be a 65-byte uncompressed P-256 point", + ); + expect(NotificationStub.requestPermission).not.toHaveBeenCalled(); + expect(existing.unsubscribe).not.toHaveBeenCalled(); + expect(pushManager.subscribe).not.toHaveBeenCalled(); + }, + ); }); describe("unsubscribe & getCurrentSubscription", () => { @@ -285,6 +311,16 @@ describe("unsubscribe & getCurrentSubscription", () => { expect(existing.unsubscribe).toHaveBeenCalledOnce(); }); + it("unsubscribe resolves null instead of hanging when no service worker is registered", async () => { + setupPushEnv({ registered: false }); + expect(await unsubscribe()).toBeNull(); + }); + + it("getCurrentSubscription resolves null when no service worker is registered", async () => { + setupPushEnv({ registered: false }); + expect(await getCurrentSubscription()).toBeNull(); + }); + it("getCurrentSubscription returns null when unsupported", async () => { setupPushEnv({ serviceWorker: false }); expect(await getCurrentSubscription()).toBeNull(); @@ -299,7 +335,7 @@ describe("unsubscribe & getCurrentSubscription", () => { it("supports the full lifecycle: subscribe, read back, unsubscribe, gone", async () => { const { created } = setupPushEnv(); expect(await getCurrentSubscription()).toBeNull(); - expect(await subscribe("BPk")).toEqual({ + expect(await subscribe(randomVapidKey())).toEqual({ status: "subscribed", subscription: created, isNew: true, From d8bf397e074abdc4229fcc631ede2ade3224c17d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:43:35 +0000 Subject: [PATCH 2/9] feat(client): return the replaced endpoint when subscribe rotates a key When subscribe() found a subscription bound to a different VAPID key, it unsubscribed it and threw the endpoint away, so the server could not delete the stale record. It lingered until a send reported it gone, which never happens while the push service answers the new key with 401/403. SubscribeResult gains an optional replacedEndpoint, set only after a rotation. Optional rather than required so code that builds a SubscribeResult by hand keeps compiling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 7 +++++++ README.md | 15 +++++++++++---- src/client.ts | 12 ++++++++++-- test/client.test.ts | 3 ++- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 619cbde..99c5086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`SubscribeResult.replacedEndpoint`.** When `subscribe` rotates a stale-key + subscription, the old endpoint is now returned so the server can delete its record. + Before, it was discarded, and the record lingered until a send happened to report it + gone, which never happens while the push service answers the new key with 401/403. + ### Fixed - **`subscribe` no longer discards a working subscription when given a malformed VAPID diff --git a/README.md b/README.md index 4110a1f..36d970f 100644 --- a/README.md +++ b/README.md @@ -67,17 +67,24 @@ This is the receiving end of `PushPayload`: `title` and `body` become the notifi ### 3. Client: subscribe ```typescript -import { subscribe, sendSubscriptionToServer } from "@mmmike/web-push/client"; +import { + subscribe, + sendSubscriptionToServer, + removeSubscriptionFromServer, +} from "@mmmike/web-push/client"; await navigator.serviceWorker.register("/sw.js"); const result = await subscribe(vapidPublicKey); if (result.status === "subscribed") { await sendSubscriptionToServer(result.subscription, "/api/push/subscribe"); + if (result.replacedEndpoint) { + await removeSubscriptionFromServer(result.replacedEndpoint, "/api/push/subscribe"); + } } ``` -`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. +`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew, replacedEndpoint? }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. `replacedEndpoint` is set only after a rotation, and names the stale subscription your server should delete. POST the subscription on every visit rather than gating on `isNew`. Your endpoint has to upsert by endpoint URL anyway, since the browser hands back the same subscription on every call, and gating means one failed upload strands a subscription your server never hears about. Use `isNew` for what it does tell you: counting fresh subscribes and key rotations. @@ -100,7 +107,7 @@ Requirements: Encrypted-payload push works in Chrome, Edge, Firefox, Opera and Samsung Internet, and in Safari 16+ on macOS 13+ and iOS 16.4+. `isPushSupported()` is the runtime check. -Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, `subscribe` unsubscribes it and creates a fresh one (`isNew: true`), since the push service would reject the old one anyway. The one gap is a browser that doesn't expose which key a subscription was bound to (`subscription.options.applicationServerKey` is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 `WebPushError`, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look. +Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, `subscribe` unsubscribes it and creates a fresh one (`isNew: true`), since the push service would reject the old one anyway. The old endpoint comes back as `replacedEndpoint`; delete its record rather than waiting for a send to report it gone, because a push service that still holds the old subscription answers sends signed with the new key with 401/403, not 404/410. The one gap is a browser that doesn't expose which key a subscription was bound to (`subscription.options.applicationServerKey` is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 `WebPushError`, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look. ### 4. Server: send @@ -247,7 +254,7 @@ Every type ships with per-field documentation, so your editor is the reference. | Type | Purpose | Exported from | | ---------------------- | ------------------------------------------------------------------------------------- | ------------------------ | | `PushSubscriptionData` | A subscription in transit, endpoint plus the `p256dh`/`auth` keys | root, `/send`, `/client` | -| `SubscribeResult` | Outcome of `subscribe`: `subscribed` (with `isNew`), `unsupported`, or `denied` | root, `/client` | +| `SubscribeResult` | Outcome of `subscribe`: `subscribed` (`isNew`, `replacedEndpoint`), or why not | root, `/client` | | `PushPayload` | Notification contents: title (the only required field), body, click URL, grouping tag | root, `/send` | | `RawPushPayload` | A caller-serialized payload from `rawPayload`, accepted wherever `PushPayload` is | root, `/send` | | `VapidConfig` | Your VAPID key pair and contact subject | root, `/send` | diff --git a/src/client.ts b/src/client.ts index 7b69cb0..6794459 100644 --- a/src/client.ts +++ b/src/client.ts @@ -34,6 +34,8 @@ export type SubscribeResult = subscription: PushSubscription; /** Whether this call created the subscription: first subscribe, or key rotation */ isNew: boolean; + /** Endpoint a key rotation unsubscribed; delete its server record */ + replacedEndpoint?: string; } | { status: "unsupported" } | { status: "denied" }; @@ -64,7 +66,8 @@ const decodeVapidPublicKey = (vapidPublicKey: string): Uint8Array = * * Reuses an existing subscription when it was created with the same VAPID key. * If the key has changed, the stale subscription is unsubscribed and replaced, - * since the push service would reject it anyway. `isNew` reports whether this + * since the push service would reject it anyway, and its endpoint comes back as + * `replacedEndpoint` so the server can delete that record. `isNew` reports whether this * call created the subscription (first subscribe or key rotation). Send the * subscription to your server either way: gating the upload on `isNew` means * one failed request strands a subscription your server never hears about. @@ -107,7 +110,12 @@ export const subscribe = async (vapidPublicKey: string): Promise { expect(pushManager.subscribe).not.toHaveBeenCalled(); }); - it("rotates a subscription bound to a different VAPID key and flags it as new", async () => { + it("rotates a subscription bound to a different VAPID key, returning the replaced endpoint", async () => { const existing = subscriptionBoundTo(randomVapidKey()); const { pushManager, created } = setupPushEnv({ existing }); expect(await subscribe(randomVapidKey())).toEqual({ status: "subscribed", subscription: created, isNew: true, + replacedEndpoint: existing.endpoint, }); expect(existing.unsubscribe).toHaveBeenCalledOnce(); expect(pushManager.subscribe).toHaveBeenCalledOnce(); From ff19708f7e3b675e0e8dd9204742423aa1fb3a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:14:17 +0000 Subject: [PATCH 3/9] fix(client): report replacedEndpoint only when the endpoint changed If a push service reissued the same endpoint after a key rotation, the README flow (save the new subscription, then delete replacedEndpoint) deleted the record it had just saved. The property is now omitted in that case, and also omitted, rather than present as undefined, when nothing was replaced, matching "set only after a rotation". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 2 ++ README.md | 2 +- src/client.ts | 14 +++++++------- test/client.test.ts | 17 ++++++++++++++++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c5086..770ad29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). subscription, the old endpoint is now returned so the server can delete its record. Before, it was discarded, and the record lingered until a send happened to report it gone, which never happens while the push service answers the new key with 401/403. + The property is absent when nothing was replaced, or when the push service reissued + the same endpoint (deleting it would remove the record just saved). ### Fixed diff --git a/README.md b/README.md index 36d970f..0743f13 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ if (result.status === "subscribed") { } ``` -`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew, replacedEndpoint? }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. `replacedEndpoint` is set only after a rotation, and names the stale subscription your server should delete. +`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew, replacedEndpoint? }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. `replacedEndpoint` is present only after a rotation that issued a new endpoint, and names the stale subscription your server should delete. POST the subscription on every visit rather than gating on `isNew`. Your endpoint has to upsert by endpoint URL anyway, since the browser hands back the same subscription on every call, and gating means one failed upload strands a subscription your server never hears about. Use `isNew` for what it does tell you: counting fresh subscribes and key rotations. diff --git a/src/client.ts b/src/client.ts index 6794459..5e2979e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -34,7 +34,7 @@ export type SubscribeResult = subscription: PushSubscription; /** Whether this call created the subscription: first subscribe, or key rotation */ isNew: boolean; - /** Endpoint a key rotation unsubscribed; delete its server record */ + /** Endpoint a key rotation unsubscribed, when it differs from the new one; delete its record */ replacedEndpoint?: string; } | { status: "unsupported" } @@ -110,12 +110,12 @@ export const subscribe = async (vapidPublicKey: string): Promise { expect(pushManager.subscribe).toHaveBeenCalledOnce(); }); + it("omits replacedEndpoint when a rotation reissues the same endpoint", async () => { + const existing = makeSubscription( + "https://push.example.com/sub/new", + undefined, + undefined, + urlBase64ToUint8Array(randomVapidKey()).buffer, + ); + const { created } = setupPushEnv({ existing }); + const result = await subscribe(randomVapidKey()); + + expect(result).toStrictEqual({ status: "subscribed", subscription: created, isNew: true }); + expect(result).not.toHaveProperty("replacedEndpoint"); + }); + it("subscribes with the decoded applicationServerKey when none exists", async () => { const vapidPublicKey = randomVapidKey(); const { pushManager, created } = setupPushEnv({ existing: null }); const result = await subscribe(vapidPublicKey); - expect(result).toEqual({ status: "subscribed", subscription: created, isNew: true }); + expect(result).toStrictEqual({ status: "subscribed", subscription: created, isNew: true }); + expect(result).not.toHaveProperty("replacedEndpoint"); expect(pushManager.subscribe).toHaveBeenCalledOnce(); expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, From 134977862446bfa4dc51aeb99ad59b97033bd6b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:16:28 +0000 Subject: [PATCH 4/9] refactor: share P-256 public key decoding, with one error for non-base64 The 65-byte/0x04 public key check lived in three places (client.ts, vapid.ts, encrypt.ts). It now lives once in decodeP256PublicKey, in a new internal src/encoding.ts that also takes the base64url codec; vapid.ts re-exports the codec, so the public surface is unchanged. The decoder also maps atob's InvalidCharacterError onto the documented message, so a still-quoted env var or other non-base64 key reads as a bad VAPID or p256dh key rather than a decoding failure. The client bundle grows by 46 B, which moves the README size figures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 3 ++ README.md | 2 +- src/client.ts | 14 ++------ src/encoding.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++ src/encrypt.ts | 16 +++------- src/vapid.ts | 41 ++---------------------- test/client.test.ts | 1 + test/vapid.test.ts | 12 +++++++ 8 files changed, 106 insertions(+), 61 deletions(-) create mode 100644 src/encoding.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 770ad29..3f930df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`getCurrentSubscription`, `unsubscribe` and `unsubscribeFromPush` resolve when no service worker is registered** (`null`, `null`, `true`) instead of waiting on `navigator.serviceWorker.ready` forever. +- **A VAPID public key or subscription `p256dh` key that is not base64url** (an env + var with its quotes left on, say) now throws the same descriptive error as a + wrong-length key, instead of `atob`'s `InvalidCharacterError`. ## [1.3.0] - 2026-08-14 diff --git a/README.md b/README.md index 0743f13..7a2e8f8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Sending a push notification should not require Node, a compatibility shim, or th - **All four pieces.** VAPID key generation, a copy-paste service worker to display the notification, the client subscribe helpers, and the server send, one at a time or fanned out to a list. - **No Node built-ins, no polyfills.** If it has `fetch` and `crypto.subtle`, it works. No `node:crypto`, no `node:https`, no compat layer. Cloudflare Workers, Bun, Deno, Node, and the browser. - **Ratified specs.** RFC 8291 `aes128gcm` encryption and RFC 8292 VAPID, pinned byte-for-byte against RFC 8291's Appendix A test vector. -- **Small.** Minified and gzipped per subpath entry: 1.0 kB for the browser client, 2.9 kB for the server. The badge above measures the whole package, ~3.6 kB. +- **Small.** Minified and gzipped per subpath entry: 1.1 kB for the browser client, 2.9 kB for the server. The badge above measures the whole package, ~3.7 kB. ## Installation diff --git a/src/client.ts b/src/client.ts index 5e2979e..74c4fb5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ */ import type { PushSubscriptionData } from "./types"; -import { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./vapid"; +import { decodeVapidPublicKey, uint8ArrayToUrlBase64 } from "./encoding"; export type { PushSubscriptionData }; @@ -51,16 +51,6 @@ const isBoundToKey = (subscription: PushSubscription, key: Uint8Array): boolean return boundBytes.length === key.length && boundBytes.every((byte, i) => byte === key[i]); }; -// Checked before the stale-key comparison: an empty or wrong-length key never -// matches, so it would otherwise unsubscribe a working subscription as a "rotation". -const decodeVapidPublicKey = (vapidPublicKey: string): Uint8Array => { - const key = urlBase64ToUint8Array(vapidPublicKey); - if (key.length !== 65 || key[0] !== 0x04) { - throw new Error("VAPID public key must be a 65-byte uncompressed P-256 point"); - } - return key; -}; - /** * Subscribe to push notifications, prompting for permission if needed. * @@ -90,6 +80,8 @@ export const subscribe = async (vapidPublicKey: string): Promise => { + if (!base64String) { + return new Uint8Array(0); + } + + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +}; + +/** + * Convert a Uint8Array to a URL-safe base64 string (no padding). + */ +export const uint8ArrayToUrlBase64 = (array: Uint8Array): string => { + // Build the binary string in chunks — String.fromCharCode(...array) spreads + // every byte as a call argument and overflows the stack for large inputs. + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < array.length; i += chunkSize) { + binary += String.fromCharCode(...array.subarray(i, i + chunkSize)); + } + const base64 = btoa(binary); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +const decodeOrUndefined = (base64: string): Uint8Array | undefined => { + try { + return urlBase64ToUint8Array(base64); + } catch { + return undefined; + } +}; + +/** + * Decode a URL-safe base64 uncompressed P-256 public key: 65 bytes, `0x04` first. + * + * @param errorMessage Thrown for anything else, including input that is not base64url. + * @throws {Error} with `errorMessage` if the input does not decode to such a key. + */ +export const decodeP256PublicKey = ( + base64: string, + errorMessage: string, +): Uint8Array => { + const key = decodeOrUndefined(base64); + if (key?.length !== P256_PUBLIC_KEY_LENGTH || key[0] !== UNCOMPRESSED_POINT_TAG) { + throw new Error(errorMessage); + } + return key; +}; + +/** + * Decode an application server's VAPID public key. + * + * @throws {Error} if it is not a base64url 65-byte uncompressed P-256 point. + */ +export const decodeVapidPublicKey = (publicKey: string): Uint8Array => + decodeP256PublicKey(publicKey, "VAPID public key must be a 65-byte uncompressed P-256 point"); diff --git a/src/encrypt.ts b/src/encrypt.ts index 6be41e7..9fbb3dc 100644 --- a/src/encrypt.ts +++ b/src/encrypt.ts @@ -8,7 +8,7 @@ * directly — never by consumers of the published package. */ -import { urlBase64ToUint8Array } from "./vapid"; +import { decodeP256PublicKey, urlBase64ToUint8Array } from "./encoding"; const SALT_LENGTH = 16; const RECORD_SIZE_LENGTH = 4; @@ -16,7 +16,6 @@ const ID_LENGTH_LENGTH = 1; const KEY_ID_LENGTH = 65; const GCM_TAG_LENGTH = 16; const PADDING_DELIMITER = 0x02; -const UNCOMPRESSED_POINT_TAG = 0x04; /** aes128gcm content-coding header length, 86 octets (RFC 8188 §2.1). */ const HEADER_LENGTH = SALT_LENGTH + RECORD_SIZE_LENGTH + ID_LENGTH_LENGTH + KEY_ID_LENGTH; @@ -78,15 +77,10 @@ export const validatePushInputs = ( ): { clientPublicKeyBytes: Uint8Array; authSecretBytes: Uint8Array } => { assertPayloadWithinLimit(payload); - const clientPublicKeyBytes = urlBase64ToUint8Array(p256dhKey); - if ( - clientPublicKeyBytes.length !== KEY_ID_LENGTH || - clientPublicKeyBytes[0] !== UNCOMPRESSED_POINT_TAG - ) { - throw new Error( - "Invalid subscription p256dh key: expected a 65-byte uncompressed P-256 public key", - ); - } + const clientPublicKeyBytes = decodeP256PublicKey( + p256dhKey, + "Invalid subscription p256dh key: expected a 65-byte uncompressed P-256 public key", + ); const authSecretBytes = urlBase64ToUint8Array(authSecret); if (authSecretBytes.length < 16) { diff --git a/src/vapid.ts b/src/vapid.ts index 6d7ac8f..ab02b7b 100644 --- a/src/vapid.ts +++ b/src/vapid.ts @@ -4,41 +4,9 @@ * service, so it will accept pushes for its subscriptions. */ -/** - * Convert a URL-safe base64 string to a Uint8Array. - */ -export const urlBase64ToUint8Array = (base64String: string): Uint8Array => { - if (!base64String) { - return new Uint8Array(0); - } - - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - - const rawData = atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } +import { decodeVapidPublicKey, uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./encoding"; - return outputArray; -}; - -/** - * Convert a Uint8Array to a URL-safe base64 string (no padding). - */ -export const uint8ArrayToUrlBase64 = (array: Uint8Array): string => { - // Build the binary string in chunks — String.fromCharCode(...array) spreads - // every byte as a call argument and overflows the stack for large inputs. - let binary = ""; - const chunkSize = 0x8000; - for (let i = 0; i < array.length; i += chunkSize) { - binary += String.fromCharCode(...array.subarray(i, i + chunkSize)); - } - const base64 = btoa(binary); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -}; +export { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./encoding"; /** * Generate a new VAPID key pair using ECDSA P-256. @@ -101,11 +69,8 @@ const encodeJwtSegment = (segment: object): string => * @throws {Error} if either key is the wrong length or encoding. */ const importSigningKey = (publicKey: string, privateKey: string): Promise => { - const publicKeyArray = urlBase64ToUint8Array(publicKey); + const publicKeyArray = decodeVapidPublicKey(publicKey); const privateKeyArray = urlBase64ToUint8Array(privateKey); - if (publicKeyArray.length !== 65 || publicKeyArray[0] !== 0x04) { - throw new Error("VAPID public key must be a 65-byte uncompressed P-256 point"); - } if (privateKeyArray.length !== 32) { throw new Error("VAPID private key must be a 32-byte P-256 scalar"); } diff --git a/test/client.test.ts b/test/client.test.ts index d9cfc7a..c78737f 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -254,6 +254,7 @@ describe("subscribe", () => { it.each([ { kind: "an empty", key: "" }, { kind: "a 32-byte (private-key-sized)", key: uint8ArrayToUrlBase64(new Uint8Array(32)) }, + { kind: "a still-quoted (not base64url)", key: `"${randomVapidKey()}"` }, ])( "rejects $kind VAPID key before prompting, keeping the existing subscription", async ({ key }) => { diff --git a/test/vapid.test.ts b/test/vapid.test.ts index 923d975..958f341 100644 --- a/test/vapid.test.ts +++ b/test/vapid.test.ts @@ -251,6 +251,18 @@ describe("createVapidJwt", () => { ).rejects.toThrow("VAPID public key must be a 65-byte uncompressed P-256 point"); }); + it("rejects a public key that is not base64url with the same message, not atob's", async () => { + const { publicKey, privateKey } = await generateVapidKeys(); + await expect( + createVapidJwt({ + audience: "https://example.com", + subject: "mailto:a@b.com", + publicKey: `"${publicKey}"`, + privateKey, + }), + ).rejects.toThrow("VAPID public key must be a 65-byte uncompressed P-256 point"); + }); + it("rejects a 65-byte public key without the 0x04 uncompressed prefix", async () => { const { privateKey } = await generateVapidKeys(); const compressedPrefix = new Uint8Array(65); From 9872f52eb4f520a2777621bf4b960d7e620fd3c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:17:56 +0000 Subject: [PATCH 5/9] fix(send): validate timeoutMs, ttl and vapidExpiration up front - timeoutMs accepted any finite positive number, but AbortSignal.timeout in Node clamps anything above 2^31 - 1 to 1ms and throws on fractions, so 3e9 aborted every request and 1500.5 threw on every send. - ttl was never checked, so NaN or -1 went into the TTL header and every request drew a 400. - The vapidExpiration range check let NaN through (both comparisons are false), signing a JWT with "exp": null. Each now throws once before anything is sent. The three send-option checks share one assertValidSendOptions in both send functions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 7 +++++++ src/send.ts | 35 +++++++++++++++++++++++++++-------- src/types.ts | 8 ++++---- src/vapid.ts | 4 ++-- test/send-push-batch.test.ts | 25 ++++++++++++++++++++++--- test/send.test.ts | 30 +++++++++++++++++++++++++----- test/vapid.test.ts | 13 +++++++++++++ 7 files changed, 100 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f930df..84f51a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,13 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **A VAPID public key or subscription `p256dh` key that is not base64url** (an env var with its quotes left on, say) now throws the same descriptive error as a wrong-length key, instead of `atob`'s `InvalidCharacterError`. +- **Send options that could only fail on the wire are now rejected up front.** + `timeoutMs` must be a whole number of milliseconds up to 2^31 - 1: Node fired a + larger one after 1ms and threw per send on a fraction. `ttl` must be a whole number + of seconds, 0 or more, instead of reaching the `TTL` header as `NaN` or `-1` and + drawing a 400 per subscription. A NaN `vapidExpiration` no longer signs a token + with `"exp": null` that every push service rejects. `sendPushBatch` throws once for + each of these, before any send, rather than filling `failed`. ## [1.3.0] - 2026-08-14 diff --git a/src/send.ts b/src/send.ts index 20b75ac..b270438 100644 --- a/src/send.ts +++ b/src/send.ts @@ -168,12 +168,33 @@ const isSubscriptionGone = (status: number): boolean => status === 404 || status const DEFAULT_TIMEOUT_MS = 30000; +// setTimeout's ceiling, which AbortSignal.timeout shares: Node silently fires +// anything larger after 1ms, and throws on fractions. +const MAX_TIMEOUT_MS = 2 ** 31 - 1; + const assertValidTimeout = (timeoutMs: number): void => { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error("timeoutMs must be a positive number of milliseconds"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error(`timeoutMs must be a whole number of milliseconds from 1 to ${MAX_TIMEOUT_MS}`); + } +}; + +/** RFC 8030 §5.2: `TTL` is delta-seconds, a non-negative integer. */ +const assertValidTtl = (ttl: number | undefined): void => { + if (ttl !== undefined && (!Number.isSafeInteger(ttl) || ttl < 0)) { + throw new Error("ttl must be a whole number of seconds, 0 or more"); } }; +const assertValidSendOptions = ({ + topic, + ttl, + timeoutMs = DEFAULT_TIMEOUT_MS, +}: SendPushOptions): void => { + assertValidTopic(topic); + assertValidTtl(ttl); + assertValidTimeout(timeoutMs); +}; + const requestSignal = (signal: AbortSignal | undefined, timeoutMs: number): AbortSignal => signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) @@ -263,7 +284,7 @@ const postToPushService = async ( * wrapper is encrypted and sent verbatim * @returns true if successful, false if subscription is invalid (should be deleted) * @throws {WebPushError} on rate limits (429) and other push service errors - * @throws {Error} on invalid input: VAPID config, payload size, `topic`, + * @throws {Error} on invalid input: VAPID config, payload size, `topic`, `ttl`, * `timeoutMs`, or a non-`https:` endpoint * @example * ```ts @@ -277,8 +298,7 @@ export const sendPushNotification = async ( vapid: VapidConfig, options: SendPushOptions = {}, ): Promise => { - assertValidTopic(options.topic); - assertValidTimeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + assertValidSendOptions(options); // Reject bad input *before* signing the VAPID JWT — no point paying for an // ECDSA signature on a request we'll refuse. @@ -312,7 +332,7 @@ export const sendPushNotification = async ( * @param payload A `PushPayload` is JSON-serialized; a {@link rawPayload} * wrapper is encrypted and sent verbatim * @throws {Error} on caller input — invalid VAPID config, oversized payload, - * invalid `topic`, `concurrency`, or `timeoutMs` — before anything is sent. + * invalid `topic`, `ttl`, `concurrency`, or `timeoutMs` — before anything is sent. * A non-`https:` endpoint is per-subscription data and lands in `failed`. * @example * ```ts @@ -331,8 +351,7 @@ export const sendPushBatch = async ( if (!Number.isInteger(concurrency) || concurrency < 1) { throw new Error("concurrency must be a positive integer"); } - assertValidTopic(sendOptions.topic); - assertValidTimeout(sendOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS); + assertValidSendOptions(sendOptions); const payloadBytes = encodePayload(payload); assertPayloadWithinLimit(payloadBytes); diff --git a/src/types.ts b/src/types.ts index 858b4b0..953309a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,7 +82,7 @@ export type SendPushBatchResult = { */ export type SendPushOptions = { logger?: Logger; - /** TTL in seconds (default: 86400 = 24 hours) */ + /** TTL in whole seconds, 0 or more (default: 86400 = 24 hours) */ ttl?: number; /** * VAPID JWT expiration in seconds from now (default: 43200 = 12 hours). @@ -101,9 +101,9 @@ export type SendPushOptions = { /** Aborts in-flight sends; a batch also stops starting new ones. */ signal?: AbortSignal; /** - * Per-request timeout in milliseconds (default: 30000). Without one, a push - * service that accepts the connection and never responds holds its pool - * slot for as long as the platform allows. + * Per-request timeout in whole milliseconds, at most 2^31 - 1 (default: + * 30000). Without one, a push service that accepts the connection and never + * responds holds its pool slot for as long as the platform allows. */ timeoutMs?: number; }; diff --git a/src/vapid.ts b/src/vapid.ts index ab02b7b..344ceaf 100644 --- a/src/vapid.ts +++ b/src/vapid.ts @@ -90,14 +90,14 @@ const importSigningKey = (publicKey: string, privateKey: string): Promise => { const { audience, subject, publicKey, privateKey, expiration = 43200 } = options; // VAPID JWT lifetime is capped at 24h (RFC 8292 §2); services reject longer. - if (expiration <= 0 || expiration > 86400) { + if (Number.isNaN(expiration) || expiration <= 0 || expiration > 86400) { throw new Error("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); } // RFC 8292 §2.1: the subject must be a contact URI (mailto: or https:). diff --git a/test/send-push-batch.test.ts b/test/send-push-batch.test.ts index 8873abb..fea6620 100644 --- a/test/send-push-batch.test.ts +++ b/test/send-push-batch.test.ts @@ -317,11 +317,30 @@ describe("sendPushBatch — caller input rejects up front", () => { expect(mock).not.toHaveBeenCalled(); }); - it("throws on a non-positive timeoutMs before any send", async () => { + it("throws on a timeoutMs AbortSignal.timeout would mishandle before any send", async () => { const mock = stubFetch(201); await expect( - sendPushBatch([subscription("https://push.example/a")], payload, vapid, { timeoutMs: 0 }), - ).rejects.toThrow("timeoutMs must be a positive number of milliseconds"); + sendPushBatch([subscription("https://push.example/a")], payload, vapid, { timeoutMs: 3e9 }), + ).rejects.toThrow("timeoutMs must be a whole number of milliseconds from 1 to 2147483647"); + expect(mock).not.toHaveBeenCalled(); + }); + + it("throws on an invalid ttl once, not once per subscription", async () => { + const mock = stubFetch(201); + const subs = [1, 2, 3].map((i) => subscription(`https://push.example/sub-${i}`)); + await expect(sendPushBatch(subs, payload, vapid, { ttl: -1 })).rejects.toThrow( + "ttl must be a whole number of seconds, 0 or more", + ); + expect(mock).not.toHaveBeenCalled(); + }); + + it("throws on a NaN vapidExpiration once, instead of signing exp: null", async () => { + const mock = stubFetch(201); + await expect( + sendPushBatch([subscription("https://push.example/a")], payload, vapid, { + vapidExpiration: Number.NaN, + }), + ).rejects.toThrow("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); expect(mock).not.toHaveBeenCalled(); }); }); diff --git a/test/send.test.ts b/test/send.test.ts index c968517..40aeca8 100644 --- a/test/send.test.ts +++ b/test/send.test.ts @@ -195,12 +195,21 @@ describe("sendPushNotification — abort & timeout", () => { ).rejects.toThrow(/aborted/i); }); - it("rejects a non-positive timeoutMs before sending", async () => { + it.each([0, -1, 1500.5, 3e9, Number.NaN])( + "rejects timeoutMs %s before sending, since AbortSignal.timeout takes whole ms up to 2^31 - 1", + async (timeoutMs) => { + const mock = stubFetch(201); + await expect( + sendPushNotification(subscription(), payload, vapid, { timeoutMs }), + ).rejects.toThrow("timeoutMs must be a whole number of milliseconds from 1 to 2147483647"); + expect(mock).not.toHaveBeenCalled(); + }, + ); + + it("accepts the largest timeoutMs AbortSignal.timeout honours", async () => { const mock = stubFetch(201); - await expect( - sendPushNotification(subscription(), payload, vapid, { timeoutMs: 0 }), - ).rejects.toThrow("timeoutMs must be a positive number of milliseconds"); - expect(mock).not.toHaveBeenCalled(); + await sendPushNotification(subscription(), payload, vapid, { timeoutMs: 2 ** 31 - 1 }); + expect((mock.mock.calls[0][1] as RequestInit).signal?.aborted).toBe(false); }); }); @@ -217,6 +226,17 @@ describe("sendPushNotification — options", () => { expect(headersOf(mock).TTL).toBe("0"); }); + it.each([-1, 1.5, Number.NaN, 1e21])( + "rejects ttl %s before sending: TTL is delta-seconds (RFC 8030 §5.2)", + async (ttl) => { + const mock = stubFetch(201); + await expect(sendPushNotification(subscription(), payload, vapid, { ttl })).rejects.toThrow( + "ttl must be a whole number of seconds, 0 or more", + ); + expect(mock).not.toHaveBeenCalled(); + }, + ); + it("logs the response via logger.debug", async () => { stubFetch(201); const debug = vi.fn<(message: string, data?: Record) => void>(); diff --git a/test/vapid.test.ts b/test/vapid.test.ts index 958f341..475a1e6 100644 --- a/test/vapid.test.ts +++ b/test/vapid.test.ts @@ -204,6 +204,19 @@ describe("createVapidJwt", () => { ).rejects.toThrow("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); }); + it("rejects a NaN expiration, as Number() of an unset env var gives", async () => { + const { publicKey, privateKey } = await generateVapidKeys(); + await expect( + createVapidJwt({ + audience: "https://example.com", + subject: "mailto:a@b.com", + publicKey, + privateKey, + expiration: Number.NaN, + }), + ).rejects.toThrow("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); + }); + it("accepts an expiration of exactly 24 hours (86400s)", async () => { const { publicKey, privateKey } = await generateVapidKeys(); const jwt = await createVapidJwt({ From 3cc275ff05f831c51de75a2582c72312b9a87600 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:19:13 +0000 Subject: [PATCH 6/9] fix(send): re-sign batch VAPID tokens, and survive null rows sendPushBatch cached one JWT per push-service origin for the whole batch, so a batch outlasting vapidExpiration (a short expiry, or a long fan-out) sent expired tokens. Each cached token is now re-signed once it reaches half its lifetime. A null row (data cast from a database or JSON) threw a TypeError inside the try, and then again in the catch when it read subscription.endpoint. That rejected the batch, lost every other result, and left the other workers sending unobserved. The row now lands in failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 6 +++++ src/send.ts | 22 ++++++++++------- test/send-push-batch.test.ts | 46 ++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f51a8..4b5b97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,12 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). drawing a 400 per subscription. A NaN `vapidExpiration` no longer signs a token with `"exp": null` that every push service rejects. `sendPushBatch` throws once for each of these, before any send, rather than filling `failed`. +- **`sendPushBatch` re-signs its per-origin VAPID token at half its lifetime.** One + token was cached for the whole batch, so a batch that outlasted `vapidExpiration` + sent expired tokens and filled `failed` with 401/403s. +- **A `null` row no longer rejects the whole `sendPushBatch`.** The error handler read + `.endpoint` off the row, threw, and rejected the batch while its workers kept + sending unobserved. The row now lands in `failed` with an empty `endpoint`. ## [1.3.0] - 2026-08-14 diff --git a/src/send.ts b/src/send.ts index b270438..f3b82f5 100644 --- a/src/send.ts +++ b/src/send.ts @@ -325,9 +325,10 @@ export const sendPushNotification = async ( * sends the push service accepted, endpoints it reported gone (404/410) come * back in `gone` for deletion, and every other failed send comes back in * `failed` with its error. One VAPID JWT is signed per push-service origin - * rather than per message, since the token is scoped to the origin and valid - * for the whole batch. Aborting `options.signal` stops workers from starting - * new sends; subscriptions never attempted count toward none of the three. + * rather than per message, since the token is scoped to the origin, and + * re-signed at half its lifetime, so a batch that outlasts `vapidExpiration` + * never sends an expired one. Aborting `options.signal` stops workers from + * starting new sends; subscriptions never attempted count toward none of the three. * * @param payload A `PushPayload` is JSON-serialized; a {@link rawPayload} * wrapper is encrypted and sent verbatim @@ -356,13 +357,14 @@ export const sendPushBatch = async ( const payloadBytes = encodePayload(payload); assertPayloadWithinLimit(payloadBytes); + const vapidExpiration = sendOptions.vapidExpiration ?? 43200; const vapidJwtFor = (audience: string): Promise => createVapidJwt({ audience, subject: vapid.subject, publicKey: vapid.publicKey, privateKey: vapid.privateKey, - expiration: sendOptions.vapidExpiration ?? 43200, + expiration: vapidExpiration, }); // Sign a throwaway token so a bad VAPID config (subject, expiration, key @@ -371,14 +373,14 @@ export const sendPushBatch = async ( // Caching the promise, not the token, so concurrent workers hitting the // same origin share one signature instead of racing to sign their own. - const jwtByOrigin = new Map>(); + const jwtByOrigin = new Map; refreshAt: number }>(); const cachedJwtFor = (origin: string): Promise => { const cached = jwtByOrigin.get(origin); - if (cached) { - return cached; + if (cached && Date.now() < cached.refreshAt) { + return cached.jwt; } const jwt = vapidJwtFor(origin); - jwtByOrigin.set(origin, jwt); + jwtByOrigin.set(origin, { jwt, refreshAt: Date.now() + (vapidExpiration * 1000) / 2 }); return jwt; }; @@ -403,7 +405,9 @@ export const sendPushBatch = async ( gone.push(subscription.endpoint); } } catch (error) { - failed.push({ endpoint: subscription.endpoint, error }); + // Rows are often cast from a database or JSON; reading `.endpoint` off a + // null one here would reject the whole batch. + failed.push({ endpoint: subscription?.endpoint ?? "", error }); } }; diff --git a/test/send-push-batch.test.ts b/test/send-push-batch.test.ts index fea6620..7d72b77 100644 --- a/test/send-push-batch.test.ts +++ b/test/send-push-batch.test.ts @@ -36,6 +36,7 @@ const authHeaderOf = (call: [string | URL | Request, RequestInit?]): string => afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe("sendPushBatch — result partitioning", () => { @@ -139,6 +140,22 @@ describe("sendPushBatch — result partitioning", () => { expect(mock.mock.calls[0][0]).toBe("https://push.example/ok"); }); + it("puts a null row in failed instead of rejecting the whole batch", async () => { + stubFetch(201); + const rows: PushSubscriptionData[] = JSON.parse( + JSON.stringify([ + subscription("https://push.example/a"), + null, + subscription("https://push.example/b"), + ]), + ); + + const result = await sendPushBatch(rows, payload, vapid); + + expect(result.delivered).toBe(2); + expect(result.failed).toEqual([{ endpoint: "", error: expect.any(TypeError) }]); + }); + it("delivers a rawPayload-wrapped payload", async () => { const mock = stubFetch(201); const { delivered } = await sendPushBatch( @@ -204,6 +221,35 @@ describe("sendPushBatch — VAPID JWT reuse", () => { ); expect(new Set(mock.mock.calls.map(authHeaderOf)).size).toBe(2); }); + + it("never sends an expired JWT, even when the batch outlasts vapidExpiration", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const sends: { sentAt: number; exp: number }[] = []; + const mock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + async (_input, init) => { + const jwt = ((init as RequestInit).headers as Record).Authorization.slice( + "vapid t=".length, + ).split(", k=")[0]; + const { exp } = decodeJwtSegment(jwt.split(".")[1]) as { exp: number }; + sends.push({ sentAt: Date.now(), exp: exp * 1000 }); + vi.setSystemTime(Date.now() + 31_000); + return new Response(null, { status: 201 }); + }, + ); + vi.stubGlobal("fetch", mock); + + await sendPushBatch( + [1, 2, 3].map((i) => subscription(`https://push.example/sub-${i}`)), + payload, + vapid, + { vapidExpiration: 60, concurrency: 1 }, + ); + + expect(sends).toHaveLength(3); + for (const { sentAt, exp } of sends) { + expect(exp).toBeGreaterThan(sentAt); + } + }); }); describe("sendPushBatch — concurrency", () => { From 14d31843b225d69ef514edfd406caa6ff1907cf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:23:44 +0000 Subject: [PATCH 7/9] fix(send): cap push service response bodies; non-extractable ECDH key postToPushService read the whole response with response.text(). The endpoint is subscriber-supplied, so a hostile one could stream an endless body into memory on every send, up to 100 at once in a batch. It now reads at most 4 KB and cancels the rest of the stream. Against the old code the new endless-body test crashes the test worker rather than failing. The per-message ephemeral key pair was generated extractable. Only its public half is exported, and Web Crypto public keys are exportable either way, so it is now generated with extractable: false. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- CHANGELOG.md | 8 ++++++++ src/encrypt.ts | 3 ++- src/send.ts | 33 ++++++++++++++++++++++++++++++++- test/send.test.ts | 21 +++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5b97a..d42ff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,14 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `.endpoint` off the row, threw, and rejected the batch while its workers kept sending unobserved. The row now lands in `failed` with an empty `endpoint`. +### Security + +- **Push service responses are read up to 4 KB, then the stream is cancelled.** + Endpoints come from subscribers, so a hostile one could stream an unbounded body that + was buffered whole on every send. `WebPushError.body` now holds at most the first 4 KB. +- **The per-message ECDH key pair is generated non-extractable.** Only its public + half is ever exported, and public keys stay exportable regardless. + ## [1.3.0] - 2026-08-14 ### Added diff --git a/src/encrypt.ts b/src/encrypt.ts index 9fbb3dc..ab958cd 100644 --- a/src/encrypt.ts +++ b/src/encrypt.ts @@ -192,9 +192,10 @@ export const encryptPayload = async ( p256dhKey: string, authSecret: string, ): Promise> => { + // Public keys are always exportable; `false` locks only the private half. const serverKeyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, - true, + false, ["deriveBits"], ); const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); diff --git a/src/send.ts b/src/send.ts index f3b82f5..e9de1dc 100644 --- a/src/send.ts +++ b/src/send.ts @@ -44,6 +44,7 @@ const parseRetryAfterMs = (retryAfter: string | null): number | null => { */ export class WebPushError extends Error { readonly statusCode: number; + /** The push service's response body, cut off after its first 4 KB */ readonly body: string; /** * The full subscription endpoint, a capability URL: anyone holding it can @@ -224,6 +225,36 @@ const buildPushHeaders = ({ return headers; }; +/** + * Push services answer in a few hundred bytes. The endpoint is chosen by the + * subscriber, so without a cap a hostile one could stream an unbounded body + * into memory on every send. + */ +const MAX_RESPONSE_BODY_BYTES = 4096; + +const readBodyPrefix = async (response: Response): Promise => { + if (!response.body) { + return ""; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let received = 0; + while (received < MAX_RESPONSE_BODY_BYTES) { + // oxlint-disable-next-line no-await-in-loop -- a stream yields its chunks in order + const { done, value } = await reader.read(); + if (done) { + return text + decoder.decode(); + } + text += decoder.decode(value.subarray(0, MAX_RESPONSE_BODY_BYTES - received), { + stream: true, + }); + received += value.length; + } + await reader.cancel(); + return text + decoder.decode(); +}; + const postToPushService = async ( subscription: PushSubscriptionData, payloadBytes: Uint8Array, @@ -246,7 +277,7 @@ const postToPushService = async ( signal: requestSignal(signal, timeoutMs), }); - const responseText = await response.text(); + const responseText = await readBodyPrefix(response); logger?.debug?.("Push response", { endpoint: subscription.endpoint.slice(0, 50), diff --git a/test/send.test.ts b/test/send.test.ts index 40aeca8..96a385e 100644 --- a/test/send.test.ts +++ b/test/send.test.ts @@ -125,6 +125,27 @@ describe("sendPushNotification — status handling", () => { expect(err.message).toMatch(/rate limit/i); }); + it("keeps only the first 4 KB of an endless error body, then cancels the stream", async () => { + let cancelled = false; + const endless = new ReadableStream({ + pull: (controller) => controller.enqueue(new Uint8Array(1000).fill(0x78)), + cancel: () => { + cancelled = true; + }, + }); + vi.stubGlobal( + "fetch", + vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + async () => new Response(endless, { status: 500 }), + ), + ); + + const err = await sendPushNotification(subscription(), payload, vapid).catch((e) => e); + expect(err).toBeInstanceOf(WebPushError); + expect(err.body).toBe("x".repeat(4096)); + expect(cancelled).toBe(true); + }); + it("throws WebPushError with the status on other server errors", async () => { stubFetch(500, "Internal Server Error"); const err = await sendPushNotification(subscription(), payload, vapid).catch((e) => e); From a44996df777dd50245e4c7677802a77cb5999cee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:25:53 +0000 Subject: [PATCH 8/9] refactor(send): decode subscription keys once per send validatePushInputs ran twice on every send: as the pre-check before signing the VAPID JWT, then again inside the encryption core, which threw the decoded bytes away and base64-decoded both keys a second time. In a batch it also re-checked the payload size per subscription after the up-front check. decodeSubscriptionKeys now decodes the keys once, the encryption core takes the decoded bytes, and the payload limit is checked once per send or once per batch. postToPushService becomes plain HTTP over an encrypted body. README size figures move with this series: the server entry is 212 B larger than before it, mostly the response-body reader. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- README.md | 2 +- src/encrypt.ts | 44 +++++++++++++++---------------------- src/send.ts | 30 +++++++++++-------------- test/rfc8291-vector.test.ts | 5 +++-- test/send.test.ts | 7 ++++-- 5 files changed, 40 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 7a2e8f8..93dc338 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Sending a push notification should not require Node, a compatibility shim, or th - **All four pieces.** VAPID key generation, a copy-paste service worker to display the notification, the client subscribe helpers, and the server send, one at a time or fanned out to a list. - **No Node built-ins, no polyfills.** If it has `fetch` and `crypto.subtle`, it works. No `node:crypto`, no `node:https`, no compat layer. Cloudflare Workers, Bun, Deno, Node, and the browser. - **Ratified specs.** RFC 8291 `aes128gcm` encryption and RFC 8292 VAPID, pinned byte-for-byte against RFC 8291's Appendix A test vector. -- **Small.** Minified and gzipped per subpath entry: 1.1 kB for the browser client, 2.9 kB for the server. The badge above measures the whole package, ~3.7 kB. +- **Small.** Minified and gzipped per subpath entry: 1.1 kB for the browser client, 3.1 kB for the server. The badge above measures the whole package, ~3.9 kB. ## Installation diff --git a/src/encrypt.ts b/src/encrypt.ts index ab958cd..e69c6f4 100644 --- a/src/encrypt.ts +++ b/src/encrypt.ts @@ -36,8 +36,9 @@ const MAX_PAYLOAD_BYTES = RECORD_SIZE - HEADER_LENGTH - 1 - GCM_TAG_LENGTH; const encoder = new TextEncoder(); /** - * Split from {@link validatePushInputs} so a batch send can reject an - * oversized payload once, up front, rather than once per subscription. + * Kept apart from {@link decodeSubscriptionKeys} so a batch send rejects an + * oversized payload once, up front, rather than once per subscription. The + * encryption core trusts its caller to have run it. * * @throws {Error} if the payload exceeds the single-record limit. */ @@ -61,22 +62,22 @@ const concat = (...parts: Uint8Array[]): Uint8Array => { return out; }; +/** A subscription's `p256dh` key and auth secret, decoded and checked. */ +export type SubscriptionKeyBytes = { + clientPublicKeyBytes: Uint8Array; + authSecretBytes: Uint8Array; +}; + /** - * Validate the payload size and the subscription's public key and auth secret, - * returning the decoded key bytes. Exported so the send path can reject invalid - * input *before* paying for the ECDSA VAPID signature (see `send.ts`). The - * encryption core re-checks as a self-contained guard for direct callers. + * Decode and check a subscription's public key and auth secret, once per send, + * before the send path pays for the ECDSA VAPID signature (see `send.ts`). * - * @throws {Error} if the payload exceeds the single-record limit, or either - * subscription key is malformed. + * @throws {Error} if either subscription key is malformed. */ -export const validatePushInputs = ( - payload: Uint8Array, +export const decodeSubscriptionKeys = ( p256dhKey: string, authSecret: string, -): { clientPublicKeyBytes: Uint8Array; authSecretBytes: Uint8Array } => { - assertPayloadWithinLimit(payload); - +): SubscriptionKeyBytes => { const clientPublicKeyBytes = decodeP256PublicKey( p256dhKey, "Invalid subscription p256dh key: expected a 65-byte uncompressed P-256 public key", @@ -184,13 +185,11 @@ const contentCodingHeader = ( * Encrypt a payload as a single aes128gcm record (RFC 8291 / RFC 8188), * generating a fresh ephemeral key pair and salt for this message. * - * @throws {Error} if the payload is too large or the subscription keys are - * malformed (see {@link validatePushInputs}). + * Expects a payload already held to {@link assertPayloadWithinLimit}. */ export const encryptPayload = async ( payload: Uint8Array, - p256dhKey: string, - authSecret: string, + keys: SubscriptionKeyBytes, ): Promise> => { // Public keys are always exportable; `false` locks only the private half. const serverKeyPair = await crypto.subtle.generateKey( @@ -199,7 +198,7 @@ export const encryptPayload = async ( ["deriveBits"], ); const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); - return encryptRecord(payload, p256dhKey, authSecret, serverKeyPair, salt); + return encryptRecord(payload, keys, serverKeyPair, salt); }; /** @@ -213,17 +212,10 @@ export const encryptPayload = async ( */ export const encryptRecord = async ( payload: Uint8Array, - p256dhKey: string, - authSecret: string, + { clientPublicKeyBytes, authSecretBytes }: SubscriptionKeyBytes, serverKeyPair: CryptoKeyPair, salt: Uint8Array, ): Promise> => { - const { clientPublicKeyBytes, authSecretBytes } = validatePushInputs( - payload, - p256dhKey, - authSecret, - ); - const clientPublicKey = await importClientPublicKey(clientPublicKeyBytes); const sharedSecret = await deriveSharedSecret(serverKeyPair.privateKey, clientPublicKey); const serverPublicKey = await exportRawPublicKey(serverKeyPair.publicKey); diff --git a/src/send.ts b/src/send.ts index e9de1dc..a588408 100644 --- a/src/send.ts +++ b/src/send.ts @@ -3,7 +3,7 @@ * body (RFC 8291), plus the status handling that decides delivered / gone / error. */ -import { assertPayloadWithinLimit, encryptPayload, validatePushInputs } from "./encrypt"; +import { assertPayloadWithinLimit, decodeSubscriptionKeys, encryptPayload } from "./encrypt"; import type { PushPayload, PushSubscriptionData, @@ -256,21 +256,15 @@ const readBodyPrefix = async (response: Response): Promise => { }; const postToPushService = async ( - subscription: PushSubscriptionData, - payloadBytes: Uint8Array, + endpoint: string, + encryptedPayload: Uint8Array, jwt: string, vapidPublicKey: string, options: SendPushOptions, ): Promise => { const { logger, ttl = 86400, urgency, topic, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = options; - const encryptedPayload = await encryptPayload( - payloadBytes, - subscription.keys.p256dh, - subscription.keys.auth, - ); - - const response = await fetch(subscription.endpoint, { + const response = await fetch(endpoint, { method: "POST", headers: buildPushHeaders({ jwt, vapidPublicKey, ttl, urgency, topic }), body: encryptedPayload, @@ -280,7 +274,7 @@ const postToPushService = async ( const responseText = await readBodyPrefix(response); logger?.debug?.("Push response", { - endpoint: subscription.endpoint.slice(0, 50), + endpoint: endpoint.slice(0, 50), status: response.status, statusText: response.statusText, body: responseText.slice(0, 200), @@ -300,7 +294,7 @@ const postToPushService = async ( : `Push service error: ${response.status} ${response.statusText}`, response.status, responseText, - subscription.endpoint, + endpoint, response.headers.get("retry-after"), ); }; @@ -334,7 +328,8 @@ export const sendPushNotification = async ( // Reject bad input *before* signing the VAPID JWT — no point paying for an // ECDSA signature on a request we'll refuse. const payloadBytes = encodePayload(payload); - validatePushInputs(payloadBytes, subscription.keys.p256dh, subscription.keys.auth); + assertPayloadWithinLimit(payloadBytes); + const keys = decodeSubscriptionKeys(subscription.keys.p256dh, subscription.keys.auth); // JWT expiry is independent of the message TTL: push services reject tokens // valid for more than 24h, whereas TTL (message retention) is routinely longer. @@ -346,7 +341,8 @@ export const sendPushNotification = async ( expiration: options.vapidExpiration ?? 43200, }); - return postToPushService(subscription, payloadBytes, jwt, vapid.publicKey, options); + const encryptedPayload = await encryptPayload(payloadBytes, keys); + return postToPushService(subscription.endpoint, encryptedPayload, jwt, vapid.publicKey, options); }; /** @@ -421,11 +417,11 @@ export const sendPushBatch = async ( const sendOne = async (subscription: PushSubscriptionData): Promise => { try { - validatePushInputs(payloadBytes, subscription.keys.p256dh, subscription.keys.auth); + const keys = decodeSubscriptionKeys(subscription.keys.p256dh, subscription.keys.auth); const jwt = await cachedJwtFor(pushServiceOrigin(subscription.endpoint)); const accepted = await postToPushService( - subscription, - payloadBytes, + subscription.endpoint, + await encryptPayload(payloadBytes, keys), jwt, vapid.publicKey, sendOptions, diff --git a/test/rfc8291-vector.test.ts b/test/rfc8291-vector.test.ts index 17bb969..7b23faa 100644 --- a/test/rfc8291-vector.test.ts +++ b/test/rfc8291-vector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { encryptRecord } from "../src/encrypt"; +import { decodeSubscriptionKeys, encryptRecord } from "../src/encrypt"; import { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "../src/vapid"; /** @@ -62,7 +62,8 @@ describe("RFC 8291 Appendix A vector", () => { const salt = urlBase64ToUint8Array(SALT); const payload = new TextEncoder().encode(PLAINTEXT); - const body = await encryptRecord(payload, UA_PUBLIC, AUTH_SECRET, keyPair, salt); + const keys = decodeSubscriptionKeys(UA_PUBLIC, AUTH_SECRET); + const body = await encryptRecord(payload, keys, keyPair, salt); expect(uint8ArrayToUrlBase64(body)).toBe(EXPECTED_BODY); }); diff --git a/test/send.test.ts b/test/send.test.ts index 96a385e..b4a2443 100644 --- a/test/send.test.ts +++ b/test/send.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; -import { encryptPayload } from "../src/encrypt"; +import { decodeSubscriptionKeys, encryptPayload } from "../src/encrypt"; import { rawPayload, sendPushNotification, topicFromString, WebPushError } from "../src/send"; import type { PushSubscriptionData, VapidConfig } from "../src/types"; import { generateVapidKeys, uint8ArrayToUrlBase64 } from "../src/vapid"; @@ -499,7 +499,10 @@ describe("encryptPayload — round-trip across payload sizes", () => { // random binary bytes rule out any ASCII/JSON assumption in the record path. it.each([0, 1, 1337, 3992, 3993])("round-trips a %i-byte random payload", async (size) => { const plaintext = crypto.getRandomValues(new Uint8Array(size)); - const body = await encryptPayload(plaintext, client.p256dh, client.auth); + const body = await encryptPayload( + plaintext, + decodeSubscriptionKeys(client.p256dh, client.auth), + ); expect(await decryptAes128gcm(body, client)).toEqual(plaintext); }); }); From 4c00b8c5a87c47d69ff53574848f1ce39985dec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:26:42 +0000 Subject: [PATCH 9/9] docs: add the missing @throws across the client and VAPID entries getCurrentSubscription and unsubscribe can reject from the registration or subscription lookup; sendSubscriptionToServer throws on a subscription without keys, and it and removeSubscriptionFromServer reject when fetch cannot reach the server; generateVapidKeys throws if the runtime exports a private key without its scalar. unsubscribeFromPush's @throws implied unsubscribe never rejects; it now says which failure each one surfaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V --- src/client.ts | 15 +++++++++++++-- src/vapid.ts | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 74c4fb5..593e675 100644 --- a/src/client.ts +++ b/src/client.ts @@ -120,6 +120,8 @@ export const subscribeToPush = subscribe; /** * Get the current push subscription if it exists. + * + * @throws {DOMException} if the browser refuses the registration or subscription lookup. */ export const getCurrentSubscription = async (): Promise => { if (!isPushSupported()) { @@ -141,8 +143,9 @@ export const getCurrentSubscription = async (): Promise * @deprecated Use {@link unsubscribe} instead: it returns the * unsubscribed endpoint, which the server needs to prune its matching * subscription record — this boolean drops it. - * @throws {DOMException} if the browser rejects the unsubscribe (e.g. the push - * service is unreachable), where `unsubscribe` still resolves the endpoint. + * @throws {DOMException} if the browser refuses the subscription lookup, or + * rejects the unsubscribe itself (e.g. the push service is unreachable). In + * that second case `unsubscribe` resolves the endpoint instead. */ export const unsubscribeFromPush = async (): Promise => { const subscription = await getCurrentSubscription(); @@ -158,6 +161,9 @@ export const unsubscribeFromPush = async (): Promise => { * returned even if the browser reports the unsubscribe as failed or rejects * (e.g. the push service is unreachable): the caller's intent is to stop * receiving pushes, and removing the server record achieves that regardless. + * + * @throws {DOMException} if the browser refuses the subscription lookup, before + * anything is unsubscribed. */ export const unsubscribe = async (): Promise => { const subscription = await getCurrentSubscription(); @@ -202,6 +208,9 @@ const sendJson = async (endpoint: string, method: string, body: unknown): Promis /** * Send a push subscription to a server endpoint. + * + * @throws {Error} if the subscription is missing its `p256dh` or `auth` key. + * @throws {TypeError} if the request never reaches the server. */ export const sendSubscriptionToServer = async ( subscription: PushSubscription, @@ -210,6 +219,8 @@ export const sendSubscriptionToServer = async ( /** * Remove a push subscription from a server endpoint. + * + * @throws {TypeError} if the request never reaches the server. */ export const removeSubscriptionFromServer = ( subscriptionEndpoint: string, diff --git a/src/vapid.ts b/src/vapid.ts index 344ceaf..c82d664 100644 --- a/src/vapid.ts +++ b/src/vapid.ts @@ -12,6 +12,7 @@ export { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./encoding"; * Generate a new VAPID key pair using ECDSA P-256. * Returns keys as URL-safe base64 strings. * + * @throws {Error} if the runtime exports the private key without its scalar. * @example * ```ts * const { publicKey, privateKey } = await generateVapidKeys();