sently 1.0.0 → 1.0.1
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.1] — 2026-08-01
|
|
6
|
+
|
|
7
|
+
### 🐛 Fixed
|
|
8
|
+
|
|
9
|
+
- **Web Push `subject` validation** — `WebPushTransport` rejects invalid
|
|
10
|
+
VAPID `subject` values at construction (`mailto:` / `https:` only) instead
|
|
11
|
+
of failing later with an opaque push-service 403
|
|
12
|
+
|
|
5
13
|
## [1.0.0] — 2026-08-01
|
|
6
14
|
|
|
7
15
|
First stable release. Channel contracts and Supported transports are semver-frozen;
|
|
@@ -10,7 +10,11 @@ export interface WebPushConfig {
|
|
|
10
10
|
* Treat as a tier-1 secret — inject from env / secrets manager only.
|
|
11
11
|
*/
|
|
12
12
|
vapidPrivateKey: string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Contact URI for the VAPID `sub` claim. Must be a `mailto:` address
|
|
15
|
+
* (e.g. `mailto:you@example.com`) or an `https:` URL
|
|
16
|
+
* (e.g. `https://example.com/contact`). Validated at construction.
|
|
17
|
+
*/
|
|
14
18
|
subject: string;
|
|
15
19
|
/**
|
|
16
20
|
* Extra exact hostnames allowed for `subscription.endpoint` beyond the
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{V as T}from"../chunk-8kpgbrba.js";import{W as S}from"../chunk-fv0bemgf.js";import{_ as W}from"../chunk-z1589fjk.js";import{ga as H,ha as V,ia as L}from"../chunk-hnzmn4s4.js";import{ja as m,ka as A}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";class M extends m{statusCode;apiError;constructor(q,z,Q){super(q,A(z),{statusCode:z,provider:"webpush",cause:Q});this.statusCode=z;this.apiError=Q;this.name="WebPushError"}}var g=2419200,K=4096,_=4096;function B(q){if(q.startsWith("mailto:")){let z=q.slice(7);if(T(z))return}else if(q.startsWith("https:"))try{let z=new URL(q);if(z.protocol==="https:"&&z.hostname.length>0)return}catch{}throw new M(`WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(q)}`,400,{field:"subject",value:q})}function J(q){return q.buffer.slice(q.byteOffset,q.byteOffset+q.byteLength)}function I(...q){let z=q.reduce((G,Z)=>G+Z.length,0),Q=new Uint8Array(z),$=0;for(let G of q)Q.set(G,$),$+=G.length;return Q}function E(q){let z=new Uint8Array(4);return new DataView(z.buffer).setUint32(0,q,!1),z}function P(q){if(q.length!==65||q[0]!==4)throw new M("Invalid P-256 uncompressed public key",400,{length:q.length});return{kty:"EC",crv:"P-256",x:H(q.subarray(1,33)),y:H(q.subarray(33,65))}}function h(q,z){return{...P(q),d:H(z)}}async function v(q,z){let Q=await crypto.subtle.importKey("raw",J(q),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),$=await crypto.subtle.sign("HMAC",Q,J(z));return new Uint8Array($)}async function C(q,z,Q,$){let G=await v(q,z);return(await v(G,I(Q,new Uint8Array([1])))).subarray(0,$)}async function f(q,z){let Q=V(q.keys.p256dh),$=V(q.keys.auth);if(Q.length!==65||Q[0]!==4)throw new M("Invalid subscription p256dh key",400,{length:Q.length});if($.length<16)throw new M("Invalid subscription auth key",400,{length:$.length});let G=await crypto.subtle.generateKey({name:"ECDH",namedCurve:"P-256"},!0,["deriveBits"]),Z=new Uint8Array(await crypto.subtle.exportKey("raw",G.publicKey)),F=await crypto.subtle.importKey("raw",J(Q),{name:"ECDH",namedCurve:"P-256"},!1,[]),X=new Uint8Array(await crypto.subtle.deriveBits({name:"ECDH",public:F},G.privateKey,256)),N=I(L("WebPush: info\x00"),Q,Z),x=await C($,X,N,32),O=crypto.getRandomValues(new Uint8Array(16)),R=L("Content-Encoding: aes128gcm\x00"),D=L("Content-Encoding: nonce\x00"),Y=await C(O,x,R,16),w=await C(O,x,D,12),k=I(z,new Uint8Array([2])),U=await crypto.subtle.importKey("raw",J(Y),"AES-GCM",!1,["encrypt"]),j=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:J(w)},U,J(k)));return I(O,E(K),new Uint8Array([Z.length]),Z,j)}async function l(q,z,Q,$){let G=V(Q),Z=V($),F=h(G,Z),X=await crypto.subtle.importKey("jwk",F,{name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),N=new URL(q).origin,x={typ:"JWT",alg:"ES256"},O={aud:N,exp:Math.floor(Date.now()/1000)+43200,sub:z},R=H(L(JSON.stringify(x))),D=H(L(JSON.stringify(O))),Y=`${R}.${D}`,w=new Uint8Array(await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},X,J(L(Y))));return`${Y}.${H(w)}`}class y{provider="webpush";vapidPublicKey;vapidPrivateKey;subject;allowedEndpointHosts;constructor(q){B(q.subject),this.vapidPublicKey=q.vapidPublicKey,this.vapidPrivateKey=q.vapidPrivateKey,this.subject=q.subject,this.allowedEndpointHosts=q.allowedEndpointHosts??[]}async send(q){if(!W(q))throw new M("WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens",400,{hint:"fcm"});try{S(q.subscription.endpoint,this.allowedEndpointHosts)}catch(X){let N;try{N=new URL(q.subscription.endpoint).hostname}catch{N=void 0}throw new M(X instanceof Error?X.message:"Unsafe push subscription endpoint",400,{endpointHost:N})}let z={title:q.title,body:q.body,...q.data!==void 0?{data:q.data}:{},...q.icon!==void 0?{icon:q.icon}:{}},Q=L(JSON.stringify(z)),$=await f(q.subscription,Q);if($.length>_)throw new M(`Encrypted push payload exceeds ${_} bytes (RFC 8291)`,413,{size:$.length});let G=await l(q.subscription.endpoint,this.subject,this.vapidPublicKey,this.vapidPrivateKey),Z=q.ttl??g,F=await fetch(q.subscription.endpoint,{method:"POST",redirect:"manual",headers:{Authorization:`vapid t=${G}, k=${this.vapidPublicKey}`,"Content-Encoding":"aes128gcm",TTL:String(Z),"Content-Type":"application/octet-stream"},body:J($)});if(F.status>=300&&F.status<400)throw new M(`Push service returned redirect ${F.status}; redirects are not followed`,F.status,{location:F.headers.get("location")});if(!F.ok){let X=await F.text().catch(()=>"");throw new M(X||`Web Push request failed with status ${F.status}`,F.status,X||{status:F.status})}return{messageId:q.messageId??crypto.randomUUID(),status:"accepted",response:String(F.status),provider:"webpush"}}async verify(){try{let q=V(this.vapidPublicKey),z=V(this.vapidPrivateKey),Q=q.length===65&&z.length===32&&this.subject.length>0;return{ok:Q,provider:"webpush",message:Q?"VAPID credentials present":"Invalid VAPID key lengths"}}catch(q){return{ok:!1,provider:"webpush",message:q instanceof Error?q.message:"Invalid VAPID credentials"}}}}export{y as WebPushTransport,M as WebPushError};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=4593CBC2A87BBFF364756E2164756E21
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/transports/webpush.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * @module\n * Web Push transport (RFC 8292 VAPID + RFC 8291 aes128gcm).\n *\n * VAPID keys are the common web-push raw format: base64url-encoded\n * uncompressed P-256 public key (65 bytes) and raw private key (32 bytes).\n *\n * Store `vapidPrivateKey` in a secrets manager / environment variable — never\n * hardcode it or commit it to source control.\n *\n * @example\n * ```ts\n * import { createPushSender } from \"sently/push\";\n * import { WebPushTransport } from \"sently/transports/webpush\";\n *\n * const push = createPushSender({\n * transport: new WebPushTransport({\n * vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,\n * vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,\n * subject: \"mailto:you@example.com\",\n * }),\n * });\n * ```\n */\nimport { decodeBase64Url, encodeBase64Url, encodeUtf8 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport { assertSafePushEndpoint } from \"../core/push-endpoint.js\";\nimport type {\n PushOptions,\n PushSendResult,\n PushSubscription,\n PushTransport,\n} from \"../core/push-types.js\";\nimport { isWebPushOptions } from \"../core/push-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\n\n/** Web Push / VAPID configuration. */\nexport interface WebPushConfig {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n vapidPublicKey: string;\n /**\n * Base64url-encoded raw P-256 private key (32 bytes).\n * Treat as a tier-1 secret — inject from env / secrets manager only.\n */\n vapidPrivateKey: string;\n /** Contact URI for the VAPID `sub` claim (`mailto:` or `https:`). */\n subject: string;\n /**\n * Extra exact hostnames allowed for `subscription.endpoint` beyond the\n * built-in FCM / Mozilla / Apple / WNS allowlist. Use for private push relays.\n */\n allowedEndpointHosts?: string[];\n}\n\n/** Error thrown when a push service rejects the request. */\nexport class WebPushError extends SentlyError {\n /** Creates a Web Push error with HTTP status and response body. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"webpush\",\n cause: apiError,\n });\n this.name = \"WebPushError\";\n }\n}\n\n/** Default TTL (28 days) in seconds. */\nconst DEFAULT_TTL_SECONDS = 2419200;\n/** RFC 8188 / Web Push record size. */\nconst RECORD_SIZE = 4096;\n/**\n * Maximum encrypted body size push services are required to accept (RFC 8291).\n * Includes salt, rs, keyid, and ciphertext+tag.\n */\nconst MAX_ENCRYPTED_BODY_BYTES = 4096;\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;\n}\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction uint32Be(value: number): Uint8Array {\n const out = new Uint8Array(4);\n new DataView(out.buffer).setUint32(0, value, false);\n return out;\n}\n\nfunction ecPublicToJwk(publicKey: Uint8Array): JsonWebKey {\n if (publicKey.length !== 65 || publicKey[0] !== 0x04) {\n throw new WebPushError(\"Invalid P-256 uncompressed public key\", 400, {\n length: publicKey.length,\n });\n }\n return {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.subarray(1, 33)),\n y: encodeBase64Url(publicKey.subarray(33, 65)),\n };\n}\n\nfunction ecKeyPairToJwk(publicKey: Uint8Array, privateKey: Uint8Array): JsonWebKey {\n return {\n ...ecPublicToJwk(publicKey),\n d: encodeBase64Url(privateKey),\n };\n}\n\nasync function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", cryptoKey, toArrayBuffer(data));\n return new Uint8Array(sig);\n}\n\n/** HKDF-Extract + Expand (HMAC-SHA-256) for a single block (L ≤ 32). */\nasync function hkdf(\n salt: Uint8Array,\n ikm: Uint8Array,\n info: Uint8Array,\n length: number,\n): Promise<Uint8Array> {\n const prk = await hmacSha256(salt, ikm);\n const block = await hmacSha256(prk, concatBytes(info, new Uint8Array([0x01])));\n return block.subarray(0, length);\n}\n\n/**\n * Encrypt a payload per RFC 8291 (aes128gcm content coding).\n *\n * Confidence note: Implemented from RFC 8291 / RFC 8188. Tests assert header\n * shapes, framing, and size limits; full known-vector fixtures were not\n * available in-repo — treat as high-scrutiny crypto.\n */\nasync function encryptAes128Gcm(\n subscription: PushSubscription,\n plaintext: Uint8Array,\n): Promise<Uint8Array> {\n const uaPublic = decodeBase64Url(subscription.keys.p256dh);\n const authSecret = decodeBase64Url(subscription.keys.auth);\n\n if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {\n throw new WebPushError(\"Invalid subscription p256dh key\", 400, {\n length: uaPublic.length,\n });\n }\n if (authSecret.length < 16) {\n throw new WebPushError(\"Invalid subscription auth key\", 400, {\n length: authSecret.length,\n });\n }\n\n const asKeyPair = await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\n \"deriveBits\",\n ]);\n const asPublicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", asKeyPair.publicKey));\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(uaPublic),\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const ecdhSecret = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, asKeyPair.privateKey, 256),\n );\n\n const keyInfo = concatBytes(encodeUtf8(\"WebPush: info\\0\"), uaPublic, asPublicRaw);\n const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32);\n\n const salt = crypto.getRandomValues(new Uint8Array(16));\n const cekInfo = encodeUtf8(\"Content-Encoding: aes128gcm\\0\");\n const nonceInfo = encodeUtf8(\"Content-Encoding: nonce\\0\");\n const cek = await hkdf(salt, ikm, cekInfo, 16);\n const nonce = await hkdf(salt, ikm, nonceInfo, 12);\n\n // RFC 8188 final-record padding delimiter\n const padded = concatBytes(plaintext, new Uint8Array([0x02]));\n\n const aesKey = await crypto.subtle.importKey(\"raw\", toArrayBuffer(cek), \"AES-GCM\", false, [\n \"encrypt\",\n ]);\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv: toArrayBuffer(nonce) },\n aesKey,\n toArrayBuffer(padded),\n ),\n );\n\n // salt || rs || idlen || keyid || ciphertext\n return concatBytes(\n salt,\n uint32Be(RECORD_SIZE),\n new Uint8Array([asPublicRaw.length]),\n asPublicRaw,\n ciphertext,\n );\n}\n\nasync function signVapidJwt(\n endpoint: string,\n subject: string,\n vapidPublicKey: string,\n vapidPrivateKey: string,\n): Promise<string> {\n const publicKey = decodeBase64Url(vapidPublicKey);\n const privateKey = decodeBase64Url(vapidPrivateKey);\n const jwk = ecKeyPairToJwk(publicKey, privateKey);\n\n const key = await crypto.subtle.importKey(\n \"jwk\",\n jwk,\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n\n const origin = new URL(endpoint).origin;\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const payload = {\n aud: origin,\n exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,\n sub: subject,\n };\n\n const encodedHeader = encodeBase64Url(encodeUtf8(JSON.stringify(header)));\n const encodedPayload = encodeBase64Url(encodeUtf8(JSON.stringify(payload)));\n const signingInput = `${encodedHeader}.${encodedPayload}`;\n\n const signature = new Uint8Array(\n await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n key,\n toArrayBuffer(encodeUtf8(signingInput)),\n ),\n );\n\n return `${signingInput}.${encodeBase64Url(signature)}`;\n}\n\n/**\n * Web Push transport — VAPID auth + RFC 8291 payload encryption.\n *\n * Endpoint URLs are validated against an allowlist before fetch to mitigate SSRF.\n * Redirects are not followed.\n */\nexport class WebPushTransport implements PushTransport {\n readonly provider = \"webpush\";\n\n private readonly vapidPublicKey: string;\n private readonly vapidPrivateKey: string;\n private readonly subject: string;\n private readonly allowedEndpointHosts: readonly string[];\n\n /** Creates a Web Push transport with VAPID credentials. */\n constructor(config: WebPushConfig) {\n this.vapidPublicKey = config.vapidPublicKey;\n this.vapidPrivateKey = config.vapidPrivateKey;\n this.subject = config.subject;\n this.allowedEndpointHosts = config.allowedEndpointHosts ?? [];\n }\n\n /** Encrypts and POSTs a notification to the subscription endpoint. */\n async send(options: PushOptions): Promise<PushSendResult> {\n if (!isWebPushOptions(options)) {\n throw new WebPushError(\n \"WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens\",\n 400,\n { hint: \"fcm\" },\n );\n }\n\n try {\n assertSafePushEndpoint(options.subscription.endpoint, this.allowedEndpointHosts);\n } catch (error) {\n let endpointHost: string | undefined;\n try {\n endpointHost = new URL(options.subscription.endpoint).hostname;\n } catch {\n endpointHost = undefined;\n }\n throw new WebPushError(\n error instanceof Error ? error.message : \"Unsafe push subscription endpoint\",\n 400,\n { endpointHost },\n );\n }\n\n const notification = {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n };\n const plaintext = encodeUtf8(JSON.stringify(notification));\n const encrypted = await encryptAes128Gcm(options.subscription, plaintext);\n\n if (encrypted.length > MAX_ENCRYPTED_BODY_BYTES) {\n throw new WebPushError(\n `Encrypted push payload exceeds ${MAX_ENCRYPTED_BODY_BYTES} bytes (RFC 8291)`,\n 413,\n { size: encrypted.length },\n );\n }\n\n const jwt = await signVapidJwt(\n options.subscription.endpoint,\n this.subject,\n this.vapidPublicKey,\n this.vapidPrivateKey,\n );\n\n const ttl = options.ttl ?? DEFAULT_TTL_SECONDS;\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers: {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n },\n body: toArrayBuffer(encrypted),\n });\n\n // Opaque redirect responses (3xx) must not be followed — SSRF mitigation.\n if (response.status >= 300 && response.status < 400) {\n throw new WebPushError(\n `Push service returned redirect ${response.status}; redirects are not followed`,\n response.status,\n { location: response.headers.get(\"location\") },\n );\n }\n\n if (!response.ok) {\n const bodyText = await response.text().catch(() => \"\");\n throw new WebPushError(\n bodyText || `Web Push request failed with status ${response.status}`,\n response.status,\n bodyText || { status: response.status },\n );\n }\n\n return {\n messageId: options.messageId ?? crypto.randomUUID(),\n status: \"accepted\",\n response: String(response.status),\n provider: \"webpush\",\n };\n }\n\n /** Lightweight VAPID credential shape check. */\n async verify(): Promise<VerifyResult> {\n try {\n const pub = decodeBase64Url(this.vapidPublicKey);\n const priv = decodeBase64Url(this.vapidPrivateKey);\n const ok = pub.length === 65 && priv.length === 32 && this.subject.length > 0;\n return {\n ok,\n provider: \"webpush\",\n message: ok ? \"VAPID credentials present\" : \"Invalid VAPID key lengths\",\n };\n } catch (error) {\n return {\n ok: false,\n provider: \"webpush\",\n message: error instanceof Error ? error.message : \"Invalid VAPID credentials\",\n };\n }\n }\n}\n"
|
|
5
|
+
"/**\n * @module\n * Web Push transport (RFC 8292 VAPID + RFC 8291 aes128gcm).\n *\n * VAPID keys are the common web-push raw format: base64url-encoded\n * uncompressed P-256 public key (65 bytes) and raw private key (32 bytes).\n *\n * Store `vapidPrivateKey` in a secrets manager / environment variable — never\n * hardcode it or commit it to source control.\n *\n * @example\n * ```ts\n * import { createPushSender } from \"sently/push\";\n * import { WebPushTransport } from \"sently/transports/webpush\";\n *\n * const push = createPushSender({\n * transport: new WebPushTransport({\n * vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,\n * vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,\n * subject: \"mailto:you@example.com\",\n * }),\n * });\n * ```\n */\nimport { isValidEmail } from \"../core/address.js\";\nimport { decodeBase64Url, encodeBase64Url, encodeUtf8 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport { assertSafePushEndpoint } from \"../core/push-endpoint.js\";\nimport type {\n PushOptions,\n PushSendResult,\n PushSubscription,\n PushTransport,\n} from \"../core/push-types.js\";\nimport { isWebPushOptions } from \"../core/push-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\n\n/** Web Push / VAPID configuration. */\nexport interface WebPushConfig {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n vapidPublicKey: string;\n /**\n * Base64url-encoded raw P-256 private key (32 bytes).\n * Treat as a tier-1 secret — inject from env / secrets manager only.\n */\n vapidPrivateKey: string;\n /**\n * Contact URI for the VAPID `sub` claim. Must be a `mailto:` address\n * (e.g. `mailto:you@example.com`) or an `https:` URL\n * (e.g. `https://example.com/contact`). Validated at construction.\n */\n subject: string;\n /**\n * Extra exact hostnames allowed for `subscription.endpoint` beyond the\n * built-in FCM / Mozilla / Apple / WNS allowlist. Use for private push relays.\n */\n allowedEndpointHosts?: string[];\n}\n\n/** Error thrown when a push service rejects the request. */\nexport class WebPushError extends SentlyError {\n /** Creates a Web Push error with HTTP status and response body. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"webpush\",\n cause: apiError,\n });\n this.name = \"WebPushError\";\n }\n}\n\n/** Default TTL (28 days) in seconds. */\nconst DEFAULT_TTL_SECONDS = 2419200;\n/** RFC 8188 / Web Push record size. */\nconst RECORD_SIZE = 4096;\n/**\n * Maximum encrypted body size push services are required to accept (RFC 8291).\n * Includes salt, rs, keyid, and ciphertext+tag.\n */\nconst MAX_ENCRYPTED_BODY_BYTES = 4096;\n\n/**\n * RFC 8292 VAPID `sub` must identify the sender as `mailto:` or `https:`.\n * Checked at construction so a bare handle like `@oke.local` fails here,\n * not later as an opaque 403 from a push service.\n */\nfunction assertVapidSubject(subject: string): void {\n if (subject.startsWith(\"mailto:\")) {\n const email = subject.slice(\"mailto:\".length);\n if (isValidEmail(email)) {\n return;\n }\n } else if (subject.startsWith(\"https:\")) {\n try {\n const url = new URL(subject);\n if (url.protocol === \"https:\" && url.hostname.length > 0) {\n return;\n }\n } catch {\n // fall through to the shared error\n }\n }\n\n throw new WebPushError(\n `WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(subject)}`,\n 400,\n { field: \"subject\", value: subject },\n );\n}\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;\n}\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction uint32Be(value: number): Uint8Array {\n const out = new Uint8Array(4);\n new DataView(out.buffer).setUint32(0, value, false);\n return out;\n}\n\nfunction ecPublicToJwk(publicKey: Uint8Array): JsonWebKey {\n if (publicKey.length !== 65 || publicKey[0] !== 0x04) {\n throw new WebPushError(\"Invalid P-256 uncompressed public key\", 400, {\n length: publicKey.length,\n });\n }\n return {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.subarray(1, 33)),\n y: encodeBase64Url(publicKey.subarray(33, 65)),\n };\n}\n\nfunction ecKeyPairToJwk(publicKey: Uint8Array, privateKey: Uint8Array): JsonWebKey {\n return {\n ...ecPublicToJwk(publicKey),\n d: encodeBase64Url(privateKey),\n };\n}\n\nasync function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", cryptoKey, toArrayBuffer(data));\n return new Uint8Array(sig);\n}\n\n/** HKDF-Extract + Expand (HMAC-SHA-256) for a single block (L ≤ 32). */\nasync function hkdf(\n salt: Uint8Array,\n ikm: Uint8Array,\n info: Uint8Array,\n length: number,\n): Promise<Uint8Array> {\n const prk = await hmacSha256(salt, ikm);\n const block = await hmacSha256(prk, concatBytes(info, new Uint8Array([0x01])));\n return block.subarray(0, length);\n}\n\n/**\n * Encrypt a payload per RFC 8291 (aes128gcm content coding).\n *\n * Confidence note: Implemented from RFC 8291 / RFC 8188. Tests assert header\n * shapes, framing, and size limits; full known-vector fixtures were not\n * available in-repo — treat as high-scrutiny crypto.\n */\nasync function encryptAes128Gcm(\n subscription: PushSubscription,\n plaintext: Uint8Array,\n): Promise<Uint8Array> {\n const uaPublic = decodeBase64Url(subscription.keys.p256dh);\n const authSecret = decodeBase64Url(subscription.keys.auth);\n\n if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {\n throw new WebPushError(\"Invalid subscription p256dh key\", 400, {\n length: uaPublic.length,\n });\n }\n if (authSecret.length < 16) {\n throw new WebPushError(\"Invalid subscription auth key\", 400, {\n length: authSecret.length,\n });\n }\n\n const asKeyPair = await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\n \"deriveBits\",\n ]);\n const asPublicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", asKeyPair.publicKey));\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(uaPublic),\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const ecdhSecret = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, asKeyPair.privateKey, 256),\n );\n\n const keyInfo = concatBytes(encodeUtf8(\"WebPush: info\\0\"), uaPublic, asPublicRaw);\n const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32);\n\n const salt = crypto.getRandomValues(new Uint8Array(16));\n const cekInfo = encodeUtf8(\"Content-Encoding: aes128gcm\\0\");\n const nonceInfo = encodeUtf8(\"Content-Encoding: nonce\\0\");\n const cek = await hkdf(salt, ikm, cekInfo, 16);\n const nonce = await hkdf(salt, ikm, nonceInfo, 12);\n\n // RFC 8188 final-record padding delimiter\n const padded = concatBytes(plaintext, new Uint8Array([0x02]));\n\n const aesKey = await crypto.subtle.importKey(\"raw\", toArrayBuffer(cek), \"AES-GCM\", false, [\n \"encrypt\",\n ]);\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv: toArrayBuffer(nonce) },\n aesKey,\n toArrayBuffer(padded),\n ),\n );\n\n // salt || rs || idlen || keyid || ciphertext\n return concatBytes(\n salt,\n uint32Be(RECORD_SIZE),\n new Uint8Array([asPublicRaw.length]),\n asPublicRaw,\n ciphertext,\n );\n}\n\nasync function signVapidJwt(\n endpoint: string,\n subject: string,\n vapidPublicKey: string,\n vapidPrivateKey: string,\n): Promise<string> {\n const publicKey = decodeBase64Url(vapidPublicKey);\n const privateKey = decodeBase64Url(vapidPrivateKey);\n const jwk = ecKeyPairToJwk(publicKey, privateKey);\n\n const key = await crypto.subtle.importKey(\n \"jwk\",\n jwk,\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n\n const origin = new URL(endpoint).origin;\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const payload = {\n aud: origin,\n exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,\n sub: subject,\n };\n\n const encodedHeader = encodeBase64Url(encodeUtf8(JSON.stringify(header)));\n const encodedPayload = encodeBase64Url(encodeUtf8(JSON.stringify(payload)));\n const signingInput = `${encodedHeader}.${encodedPayload}`;\n\n const signature = new Uint8Array(\n await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n key,\n toArrayBuffer(encodeUtf8(signingInput)),\n ),\n );\n\n return `${signingInput}.${encodeBase64Url(signature)}`;\n}\n\n/**\n * Web Push transport — VAPID auth + RFC 8291 payload encryption.\n *\n * Endpoint URLs are validated against an allowlist before fetch to mitigate SSRF.\n * Redirects are not followed.\n */\nexport class WebPushTransport implements PushTransport {\n readonly provider = \"webpush\";\n\n private readonly vapidPublicKey: string;\n private readonly vapidPrivateKey: string;\n private readonly subject: string;\n private readonly allowedEndpointHosts: readonly string[];\n\n /** Creates a Web Push transport with VAPID credentials. */\n constructor(config: WebPushConfig) {\n assertVapidSubject(config.subject);\n this.vapidPublicKey = config.vapidPublicKey;\n this.vapidPrivateKey = config.vapidPrivateKey;\n this.subject = config.subject;\n this.allowedEndpointHosts = config.allowedEndpointHosts ?? [];\n }\n\n /** Encrypts and POSTs a notification to the subscription endpoint. */\n async send(options: PushOptions): Promise<PushSendResult> {\n if (!isWebPushOptions(options)) {\n throw new WebPushError(\n \"WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens\",\n 400,\n { hint: \"fcm\" },\n );\n }\n\n try {\n assertSafePushEndpoint(options.subscription.endpoint, this.allowedEndpointHosts);\n } catch (error) {\n let endpointHost: string | undefined;\n try {\n endpointHost = new URL(options.subscription.endpoint).hostname;\n } catch {\n endpointHost = undefined;\n }\n throw new WebPushError(\n error instanceof Error ? error.message : \"Unsafe push subscription endpoint\",\n 400,\n { endpointHost },\n );\n }\n\n const notification = {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n };\n const plaintext = encodeUtf8(JSON.stringify(notification));\n const encrypted = await encryptAes128Gcm(options.subscription, plaintext);\n\n if (encrypted.length > MAX_ENCRYPTED_BODY_BYTES) {\n throw new WebPushError(\n `Encrypted push payload exceeds ${MAX_ENCRYPTED_BODY_BYTES} bytes (RFC 8291)`,\n 413,\n { size: encrypted.length },\n );\n }\n\n const jwt = await signVapidJwt(\n options.subscription.endpoint,\n this.subject,\n this.vapidPublicKey,\n this.vapidPrivateKey,\n );\n\n const ttl = options.ttl ?? DEFAULT_TTL_SECONDS;\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers: {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n },\n body: toArrayBuffer(encrypted),\n });\n\n // Opaque redirect responses (3xx) must not be followed — SSRF mitigation.\n if (response.status >= 300 && response.status < 400) {\n throw new WebPushError(\n `Push service returned redirect ${response.status}; redirects are not followed`,\n response.status,\n { location: response.headers.get(\"location\") },\n );\n }\n\n if (!response.ok) {\n const bodyText = await response.text().catch(() => \"\");\n throw new WebPushError(\n bodyText || `Web Push request failed with status ${response.status}`,\n response.status,\n bodyText || { status: response.status },\n );\n }\n\n return {\n messageId: options.messageId ?? crypto.randomUUID(),\n status: \"accepted\",\n response: String(response.status),\n provider: \"webpush\",\n };\n }\n\n /** Lightweight VAPID credential shape check. */\n async verify(): Promise<VerifyResult> {\n try {\n const pub = decodeBase64Url(this.vapidPublicKey);\n const priv = decodeBase64Url(this.vapidPrivateKey);\n const ok = pub.length === 65 && priv.length === 32 && this.subject.length > 0;\n return {\n ok,\n provider: \"webpush\",\n message: ok ? \"VAPID credentials present\" : \"Invalid VAPID key lengths\",\n };\n } catch (error) {\n return {\n ok: false,\n provider: \"webpush\",\n message: error instanceof Error ? error.message : \"Invalid VAPID credentials\",\n };\n }\n }\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "4593CBC2A87BBFF364756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sently",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Runtime-agnostic channel-delivery library for Node.js, Bun, Deno, and Cloudflare Workers. One sender shape, one error model, and one retry path across email, SMS, WhatsApp, and push — with pluggable provider transports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,7 +15,7 @@ Encrypt and send browser notifications with VAPID.
|
|
|
15
15
|
| --- | --- | --- |
|
|
16
16
|
| `vapidPublicKey` | `string` | required |
|
|
17
17
|
| `vapidPrivateKey` | `string` | required |
|
|
18
|
-
| `subject` | `string` | required |
|
|
18
|
+
| `subject` | `string` | required — `mailto:you@example.com` or `https://example.com/contact` (validated at construction) |
|
|
19
19
|
| `allowedEndpointHosts` | `string[]` | optional |
|
|
20
20
|
|
|
21
21
|
<Steps>
|
|
@@ -38,6 +38,13 @@ await sender.send({ subscription, title: "Hello", body: "World" });
|
|
|
38
38
|
</Step>
|
|
39
39
|
</Steps>
|
|
40
40
|
|
|
41
|
-
<Accordions
|
|
41
|
+
<Accordions>
|
|
42
|
+
<Accordion title="Should I call the provider SDK?">
|
|
43
|
+
No. Use the sently sender; provider-specific extras stay on the transport instance.
|
|
44
|
+
</Accordion>
|
|
45
|
+
<Accordion title="Why does construction throw on subject?">
|
|
46
|
+
VAPID `subject` must be a real `mailto:` address or `https:` URL. Values like `@oke.local` (no prefix) are rejected immediately as `WebPushError` so push services never return a confusing 403 later.
|
|
47
|
+
</Accordion>
|
|
48
|
+
</Accordions>
|
|
42
49
|
|
|
43
50
|
<Cards><Card title="Push channel" href="/docs/channels/push" /></Cards>
|