sently 1.0.0 → 1.1.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/AGENTS.md +3 -2
- package/CHANGELOG.md +97 -0
- package/README.md +20 -5
- package/dist/chunk-z1589fjk.js.map +2 -2
- package/dist/core/push-types.d.ts +53 -4
- package/dist/transports/inbucket.d.ts +196 -0
- package/dist/transports/inbucket.js +3 -0
- package/dist/transports/inbucket.js.map +10 -0
- package/dist/transports/mailpit.d.ts +108 -8
- package/dist/transports/mailpit.js +2 -2
- package/dist/transports/mailpit.js.map +3 -3
- package/dist/transports/taqnyat-sms.d.ts +85 -0
- package/dist/transports/taqnyat-sms.js +2 -2
- package/dist/transports/taqnyat-sms.js.map +3 -3
- package/dist/transports/taqnyat-whatsapp.d.ts +112 -4
- package/dist/transports/taqnyat-whatsapp.js +2 -2
- package/dist/transports/taqnyat-whatsapp.js.map +3 -3
- package/dist/transports/webpush.d.ts +24 -1
- package/dist/transports/webpush.js +2 -2
- package/dist/transports/webpush.js.map +3 -3
- package/dist/webhooks/sndr.js +2 -2
- package/dist/webhooks/sndr.js.map +3 -3
- package/package.json +7 -2
- package/site/content/docs/ai/llms-txt.mdx +2 -0
- package/site/content/docs/channels/email.mdx +2 -1
- package/site/content/docs/channels/push.mdx +3 -1
- package/site/content/docs/decorators/preview.mdx +2 -1
- package/site/content/docs/get-started/entrypoints.mdx +1 -1
- package/site/content/docs/get-started/support-matrix.mdx +2 -2
- package/site/content/docs/guides/vendor-extras-otp.mdx +2 -1
- package/site/content/docs/guides/webhooks.mdx +2 -0
- package/site/content/docs/guides/webpush-interop.mdx +9 -1
- package/site/content/docs/reference/exports.mdx +1 -1
- package/site/content/docs/reference/push-options.mdx +22 -5
- package/site/content/docs/transports/inbucket.mdx +200 -0
- package/site/content/docs/transports/index.mdx +2 -3
- package/site/content/docs/transports/mailpit.mdx +114 -14
- package/site/content/docs/transports/meta.json +5 -4
- package/site/content/docs/transports/smtp.mdx +3 -2
- package/site/content/docs/transports/sndr.mdx +68 -5
- package/site/content/docs/transports/taqnyat.mdx +361 -0
- package/site/content/docs/transports/webpush.mdx +208 -17
- package/site/content/docs/transports/taqnyat-mail.mdx +0 -41
- package/site/content/docs/transports/taqnyat-sms.mdx +0 -41
- package/site/content/docs/transports/taqnyat-whatsapp.mdx +0 -40
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Template: `{ to, type: "template", template: { name, language: { code } } }`.
|
|
8
8
|
* Session text: `{ to, type: "text", text: { body } }`.
|
|
9
9
|
*
|
|
10
|
+
* Vendor extras on this class: templates CRUD, opt-in/out, failover send.
|
|
11
|
+
*
|
|
10
12
|
* Sently-first: wire into {@link createWhatsAppSender}.
|
|
11
13
|
*
|
|
12
14
|
* @example
|
|
@@ -14,11 +16,12 @@
|
|
|
14
16
|
* import { createWhatsAppSender } from "sently/whatsapp";
|
|
15
17
|
* import { TaqnyatWhatsAppTransport } from "sently/transports/taqnyat-whatsapp";
|
|
16
18
|
*
|
|
17
|
-
* const
|
|
18
|
-
*
|
|
19
|
-
* bearerToken: process.env.TAQNYAT_WHATSAPP_TOKEN!,
|
|
20
|
-
* }),
|
|
19
|
+
* const transport = new TaqnyatWhatsAppTransport({
|
|
20
|
+
* bearerToken: process.env.TAQNYAT_WHATSAPP_TOKEN!,
|
|
21
21
|
* });
|
|
22
|
+
* const wa = createWhatsAppSender({ transport });
|
|
23
|
+
* await transport.optIn("+9665xxxxxxxx");
|
|
24
|
+
* await wa.send({ to: "+9665xxxxxxxx", template: { name: "welcome", language: "ar" } });
|
|
22
25
|
* ```
|
|
23
26
|
*/
|
|
24
27
|
import { SentlyError } from "../core/errors.js";
|
|
@@ -29,6 +32,59 @@ export interface TaqnyatWhatsAppConfig {
|
|
|
29
32
|
/** Bearer token from a Taqnyat application enabled for WhatsApp. */
|
|
30
33
|
bearerToken: string;
|
|
31
34
|
}
|
|
35
|
+
/** One template row from {@link TaqnyatWhatsAppTransport.listTemplates}. */
|
|
36
|
+
export interface TaqnyatWhatsAppTemplate {
|
|
37
|
+
/** Template name. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Language code (e.g. `ar`, `en`). */
|
|
40
|
+
language: string;
|
|
41
|
+
/** Status (`approved`, `pending`, …). */
|
|
42
|
+
status: string;
|
|
43
|
+
/** Category when present (`UTILITY`, `MARKETING`, …). */
|
|
44
|
+
category?: string;
|
|
45
|
+
/** Provider template id when present. */
|
|
46
|
+
id?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Options for {@link TaqnyatWhatsAppTransport.createTemplate}. */
|
|
49
|
+
export interface TaqnyatCreateTemplateOptions {
|
|
50
|
+
/** Unique template name without spaces. */
|
|
51
|
+
name: string;
|
|
52
|
+
/** Language code. */
|
|
53
|
+
language: string;
|
|
54
|
+
/** Template category (`UTILITY`, `MARKETING`, `AUTHENTICATION`, …). */
|
|
55
|
+
category: string;
|
|
56
|
+
/** Component array as required by Taqnyat / Meta. */
|
|
57
|
+
components: unknown[];
|
|
58
|
+
/** Allow Meta to adjust category. Defaults to `true`. */
|
|
59
|
+
allowCategoryChange?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** Email fallback nested under SMS/mail failover payloads. */
|
|
62
|
+
export interface TaqnyatFailoverEmailOnFail {
|
|
63
|
+
from: string;
|
|
64
|
+
to: string;
|
|
65
|
+
}
|
|
66
|
+
/** SMS failover branch for {@link TaqnyatWhatsAppTransport.sendWithFailover}. */
|
|
67
|
+
export interface TaqnyatWhatsAppSmsFailover {
|
|
68
|
+
sender: string;
|
|
69
|
+
campaign: string;
|
|
70
|
+
body: string;
|
|
71
|
+
sendEmailIfFail?: TaqnyatFailoverEmailOnFail;
|
|
72
|
+
}
|
|
73
|
+
/** Mail failover branch for {@link TaqnyatWhatsAppTransport.sendWithFailover}. */
|
|
74
|
+
export interface TaqnyatWhatsAppMailFailover {
|
|
75
|
+
from: string;
|
|
76
|
+
to: string;
|
|
77
|
+
campaign: string;
|
|
78
|
+
subject: string;
|
|
79
|
+
msg: string;
|
|
80
|
+
cc?: string;
|
|
81
|
+
sendEmailIfFail?: TaqnyatFailoverEmailOnFail;
|
|
82
|
+
}
|
|
83
|
+
/** Failover channels for {@link TaqnyatWhatsAppTransport.sendWithFailover}. */
|
|
84
|
+
export interface TaqnyatWhatsAppFailover {
|
|
85
|
+
sms?: TaqnyatWhatsAppSmsFailover;
|
|
86
|
+
mail?: TaqnyatWhatsAppMailFailover;
|
|
87
|
+
}
|
|
32
88
|
/** Error thrown when the Taqnyat WhatsApp API reports a failure. */
|
|
33
89
|
export declare class TaqnyatWhatsAppError extends SentlyError {
|
|
34
90
|
readonly statusCode: number;
|
|
@@ -46,6 +102,58 @@ export declare class TaqnyatWhatsAppTransport implements WhatsAppTransport {
|
|
|
46
102
|
constructor(config: TaqnyatWhatsAppConfig);
|
|
47
103
|
/** Sends a WhatsApp template or session text message. */
|
|
48
104
|
send(options: WhatsAppOptions): Promise<WhatsAppSendResult>;
|
|
105
|
+
/**
|
|
106
|
+
* Vendor extra: send with optional SMS / email failover branches
|
|
107
|
+
* (same `POST /wa/v2/messages/` payload shape as Taqnyat failover docs).
|
|
108
|
+
* Not part of {@link WhatsAppTransport}.
|
|
109
|
+
*/
|
|
110
|
+
sendWithFailover(options: WhatsAppOptions, failover: TaqnyatWhatsAppFailover): Promise<WhatsAppSendResult>;
|
|
111
|
+
/**
|
|
112
|
+
* Vendor extra: list templates via `GET /wa/v2/templates/`.
|
|
113
|
+
* Not part of {@link WhatsAppTransport}.
|
|
114
|
+
*/
|
|
115
|
+
listTemplates(): Promise<TaqnyatWhatsAppTemplate[]>;
|
|
116
|
+
/**
|
|
117
|
+
* Vendor extra: create a template via `POST /wa/v2/templates/`.
|
|
118
|
+
* Not part of {@link WhatsAppTransport}.
|
|
119
|
+
*/
|
|
120
|
+
createTemplate(options: TaqnyatCreateTemplateOptions): Promise<{
|
|
121
|
+
id?: string;
|
|
122
|
+
category?: string;
|
|
123
|
+
status: string;
|
|
124
|
+
provider: "taqnyat-whatsapp";
|
|
125
|
+
}>;
|
|
126
|
+
/**
|
|
127
|
+
* Vendor extra: delete a template via `DELETE /wa/v2/templates/`.
|
|
128
|
+
* Not part of {@link WhatsAppTransport}.
|
|
129
|
+
*/
|
|
130
|
+
deleteTemplate(options: {
|
|
131
|
+
name: string;
|
|
132
|
+
id: string | number;
|
|
133
|
+
}): Promise<{
|
|
134
|
+
ok: true;
|
|
135
|
+
provider: "taqnyat-whatsapp";
|
|
136
|
+
}>;
|
|
137
|
+
/**
|
|
138
|
+
* Vendor extra: record WhatsApp opt-in via `POST /wa/v1/provision/optin/`.
|
|
139
|
+
* Not part of {@link WhatsAppTransport}.
|
|
140
|
+
*/
|
|
141
|
+
optIn(to: string | readonly string[]): Promise<{
|
|
142
|
+
ok: true;
|
|
143
|
+
numbers: string[];
|
|
144
|
+
provider: "taqnyat-whatsapp";
|
|
145
|
+
}>;
|
|
146
|
+
/**
|
|
147
|
+
* Vendor extra: record WhatsApp opt-out via `DELETE /wa/v1/provision/optin/`.
|
|
148
|
+
* Not part of {@link WhatsAppTransport}.
|
|
149
|
+
*/
|
|
150
|
+
optOut(to: string | readonly string[]): Promise<{
|
|
151
|
+
ok: true;
|
|
152
|
+
numbers: string[];
|
|
153
|
+
provider: "taqnyat-whatsapp";
|
|
154
|
+
}>;
|
|
49
155
|
/** Lightweight credential check. */
|
|
50
156
|
verify(): Promise<VerifyResult>;
|
|
157
|
+
private postMessage;
|
|
158
|
+
private postOptProvision;
|
|
51
159
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{j as
|
|
1
|
+
import{j as K}from"../chunk-5es0v1j7.js";import{ja as V,ka as X}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";class Y extends V{statusCode;apiError;constructor(k,D,F){super(k,X(D>=100&&D<600?D:400),{statusCode:D,provider:"taqnyat-whatsapp",cause:F});this.statusCode=D;this.apiError=F;this.name="TaqnyatWhatsAppError"}}function Z(k){return"template"in k}function _(k){let D=k.statuses;if(Array.isArray(D)){let F=D[0];if(typeof F?.message_id==="string")return F.message_id}else if(D&&typeof D==="object"){let F=D.message_id;if(typeof F==="string")return F;for(let G of Object.values(D))if(G&&typeof G==="object"){let H=G.message_id;if(typeof H==="string")return H}}return}function $(k){return k.statuses==="PENDING"||typeof k.type==="string"&&(k.type==="template"||k.type==="text")&&k.message===void 0}function x(k){if(typeof k.statuses==="string")return k.statuses;if(typeof k.type==="string")return k.type;return"Message sent"}function J(k,D){let F=typeof D.reason==="string"?D.reason:typeof D.message==="string"?`Taqnyat WhatsApp error ${D.message}`:"Taqnyat WhatsApp API error",G=typeof D.message==="string"&&/^\d+$/.test(D.message)?Number(D.message):k.status;throw new Y(F,G||k.status,D)}function U(k){let D=K(k.to);return Z(k)?{to:D,type:"template",template:{name:k.template.name,language:{code:k.template.language},...k.template.components?{components:k.template.components}:{}}}:{to:D,type:"text",text:{body:k.text}}}function N(k){return(typeof k==="string"?[k]:[...k]).map(K)}class Q{provider="taqnyat-whatsapp";bearerToken;constructor(k){this.bearerToken=k.bearerToken}async send(k){return this.postMessage(U(k),k)}async sendWithFailover(k,D){let F=U(k);if(D.sms)F.sms={sender:D.sms.sender,campaign:D.sms.campaign,body:D.sms.body,...D.sms.sendEmailIfFail?{sendEmailIfFail:D.sms.sendEmailIfFail}:{}};if(D.mail)F.mail={from:D.mail.from,to:D.mail.to,campaign:D.mail.campaign,subject:D.mail.subject,msg:D.mail.msg,...D.mail.cc!==void 0?{cc:D.mail.cc}:{},...D.mail.sendEmailIfFail?{sendEmailIfFail:D.mail.sendEmailIfFail}:{}};return this.postMessage(F,k)}async listTemplates(){let k=await fetch("https://api.taqnyat.sa/wa/v2/templates/?limit=5000",{headers:{Authorization:`Bearer ${this.bearerToken}`,Accept:"application/json"}}),D=await k.json();if(!k.ok||!Array.isArray(D.waba_templates))J(k,D);return D.waba_templates.map((F)=>({name:String(F.name??""),language:String(F.language??""),status:String(F.status??""),...typeof F.category==="string"?{category:F.category}:{},...F.id!==void 0?{id:String(F.id)}:{}}))}async createTemplate(k){let D=await fetch("https://api.taqnyat.sa/wa/v2/templates/",{method:"POST",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify({name:k.name,language:k.language,allow_category_change:k.allowCategoryChange??!0,category:k.category,components:k.components})}),F=await D.json(),G=typeof F.statuses==="string"?F.statuses:typeof F.status==="string"?F.status:void 0;if(!D.ok||F.message!==void 0&&G===void 0)J(D,F);return{...F.id!==void 0?{id:String(F.id)}:{},...typeof F.category==="string"?{category:F.category}:{},status:G??"PENDING",provider:"taqnyat-whatsapp"}}async deleteTemplate(k){let D=await fetch("https://api.taqnyat.sa/wa/v2/templates/",{method:"DELETE",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify({name:k.name,id:k.id})}),F=await D.json();if(!(D.ok&&(F.message==="201"||F.reason==="success"||F.message===void 0)))J(D,F);return{ok:!0,provider:"taqnyat-whatsapp"}}async optIn(k){return this.postOptProvision("POST",k)}async optOut(k){return this.postOptProvision("DELETE",k)}async verify(){return{ok:Boolean(this.bearerToken),provider:"taqnyat-whatsapp",message:this.bearerToken?"Credentials present":"Missing credentials"}}async postMessage(k,D){let F=await fetch("https://api.taqnyat.sa/wa/v2/messages/",{method:"POST",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify(k)}),G=await F.json(),H=_(G),L=$(G);if(!F.ok||G.message!==void 0&&H===void 0&&!L)J(F,G);return{messageId:H??D.messageId??"",to:D.to,status:"accepted",response:x(G),provider:"taqnyat-whatsapp"}}async postOptProvision(k,D){let F=N(D),G=await fetch("https://api.taqnyat.sa/wa/v1/provision/optin/",{method:k,headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify({numbers:F})}),H=await G.json();if(!(typeof H.type==="string"&&H.statuses!==void 0))J(G,H);return{ok:!0,numbers:F,provider:"taqnyat-whatsapp"}}}export{Q as TaqnyatWhatsAppTransport,Y as TaqnyatWhatsAppError};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=C2A75FE9E50F4F9C64756E2164756E21
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/transports/taqnyat-whatsapp.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * @module\n * Taqnyat WhatsApp Business API transport.\n *\n * Follows [Taqnyat WhatsApp](https://dev.taqnyat.sa/en/doc/whatsapp/):\n * `POST https://api.taqnyat.sa/wa/v2/messages/` with Bearer + JSON.\n * Template: `{ to, type: \"template\", template: { name, language: { code } } }`.\n * Session text: `{ to, type: \"text\", text: { body } }`.\n *\n * Sently-first: wire into {@link createWhatsAppSender}.\n *\n * @example\n * ```ts\n * import { createWhatsAppSender } from \"sently/whatsapp\";\n * import { TaqnyatWhatsAppTransport } from \"sently/transports/taqnyat-whatsapp\";\n *\n * const wa = createWhatsAppSender({\n * transport: new TaqnyatWhatsAppTransport({\n * bearerToken: process.env.TAQNYAT_WHATSAPP_TOKEN!,\n * }),\n * });\n * ```\n */\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type { VerifyResult } from \"../core/types.js\";\nimport type {\n WhatsAppOptions,\n WhatsAppSendResult,\n WhatsAppTransport,\n} from \"../core/whatsapp-types.js\";\nimport { normalizeTaqnyatPhone } from \"./taqnyat-phone.js\";\n\n/** Taqnyat WhatsApp API configuration. */\nexport interface TaqnyatWhatsAppConfig {\n /** Bearer token from a Taqnyat application enabled for WhatsApp. */\n bearerToken: string;\n}\n\n/** Error thrown when the Taqnyat WhatsApp API reports a failure. */\nexport class TaqnyatWhatsAppError extends SentlyError {\n /** Creates a Taqnyat WhatsApp API error with status code and response payload. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(\n message,\n httpStatusToSentlyCode(statusCode >= 100 && statusCode < 600 ? statusCode : 400),\n {\n statusCode,\n provider: \"taqnyat-whatsapp\",\n cause: apiError,\n },\n );\n this.name = \"TaqnyatWhatsAppError\";\n }\n}\n\nfunction isTemplateMessage(\n options: WhatsAppOptions,\n): options is Extract<WhatsAppOptions, { template: unknown }> {\n return \"template\" in options;\n}\n\nfunction extractMessageId(payload: Record<string, unknown>): string | undefined {\n const statuses = payload.statuses;\n if (Array.isArray(statuses)) {\n const first = statuses[0] as Record<string, unknown> | undefined;\n if (typeof first?.message_id === \"string\") return first.message_id;\n } else if (statuses && typeof statuses === \"object\") {\n for (const value of Object.values(statuses as Record<string, unknown>)) {\n if (value && typeof value === \"object\") {\n const id = (value as Record<string, unknown>).message_id;\n if (typeof id === \"string\") return id;\n }\n }\n }\n return undefined;\n}\n\n/**\n * Taqnyat WhatsApp transport via JSON Bearer `wa/v2/messages/`.\n */\nexport class TaqnyatWhatsAppTransport implements WhatsAppTransport {\n readonly provider = \"taqnyat-whatsapp\";\n\n private readonly bearerToken: string;\n\n /** Creates a Taqnyat WhatsApp transport. */\n constructor(config: TaqnyatWhatsAppConfig) {\n this.bearerToken = config.bearerToken;\n }\n\n /** Sends a WhatsApp template or session text message. */\n async send(options: WhatsAppOptions): Promise<WhatsAppSendResult> {\n const to = normalizeTaqnyatPhone(options.to);\n const body = isTemplateMessage(options)\n ? {\n to,\n type: \"template\" as const,\n template: {\n name: options.template.name,\n language: { code: options.template.language },\n ...(options.template.components ? { components: options.template.components } : {}),\n },\n }\n : {\n to,\n type: \"text\" as const,\n text: { body: options.text },\n };\n\n // Docs: base URL must end with `/`.\n const response = await fetch(\"https://api.taqnyat.sa/wa/v2/messages/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const payload = (await response.json()) as Record<string, unknown>;\n const messageId = extractMessageId(payload);\n\n // Docs return `{ message, reason }` on failure (often still HTTP 200).\n if (!response.ok || (payload.message !== undefined && messageId === undefined)) {\n const reason =\n typeof payload.reason === \"string\"\n ? payload.reason\n : typeof payload.message === \"string\"\n ? `Taqnyat WhatsApp error ${payload.message}`\n : \"Taqnyat WhatsApp API error\";\n const status =\n typeof payload.message === \"string\" && /^\\d+$/.test(payload.message)\n ? Number(payload.message)\n : response.status;\n throw new TaqnyatWhatsAppError(reason, status || response.status, payload);\n }\n\n return {\n messageId: messageId ?? options.messageId ?? \"\",\n to: options.to,\n status: \"accepted\",\n response: typeof payload.type === \"string\" ? payload.type : \"Message sent\",\n provider: \"taqnyat-whatsapp\",\n };\n }\n\n /** Lightweight credential check. */\n async verify(): Promise<VerifyResult> {\n return {\n ok: Boolean(this.bearerToken),\n provider: \"taqnyat-whatsapp\",\n message: this.bearerToken ? \"Credentials present\" : \"Missing credentials\",\n };\n }\n}\n"
|
|
5
|
+
"/**\n * @module\n * Taqnyat WhatsApp Business API transport.\n *\n * Follows [Taqnyat WhatsApp](https://dev.taqnyat.sa/en/doc/whatsapp/):\n * `POST https://api.taqnyat.sa/wa/v2/messages/` with Bearer + JSON.\n * Template: `{ to, type: \"template\", template: { name, language: { code } } }`.\n * Session text: `{ to, type: \"text\", text: { body } }`.\n *\n * Vendor extras on this class: templates CRUD, opt-in/out, failover send.\n *\n * Sently-first: wire into {@link createWhatsAppSender}.\n *\n * @example\n * ```ts\n * import { createWhatsAppSender } from \"sently/whatsapp\";\n * import { TaqnyatWhatsAppTransport } from \"sently/transports/taqnyat-whatsapp\";\n *\n * const transport = new TaqnyatWhatsAppTransport({\n * bearerToken: process.env.TAQNYAT_WHATSAPP_TOKEN!,\n * });\n * const wa = createWhatsAppSender({ transport });\n * await transport.optIn(\"+9665xxxxxxxx\");\n * await wa.send({ to: \"+9665xxxxxxxx\", template: { name: \"welcome\", language: \"ar\" } });\n * ```\n */\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type { VerifyResult } from \"../core/types.js\";\nimport type {\n WhatsAppOptions,\n WhatsAppSendResult,\n WhatsAppTransport,\n} from \"../core/whatsapp-types.js\";\nimport { normalizeTaqnyatPhone } from \"./taqnyat-phone.js\";\n\n/** Taqnyat WhatsApp API configuration. */\nexport interface TaqnyatWhatsAppConfig {\n /** Bearer token from a Taqnyat application enabled for WhatsApp. */\n bearerToken: string;\n}\n\n/** One template row from {@link TaqnyatWhatsAppTransport.listTemplates}. */\nexport interface TaqnyatWhatsAppTemplate {\n /** Template name. */\n name: string;\n /** Language code (e.g. `ar`, `en`). */\n language: string;\n /** Status (`approved`, `pending`, …). */\n status: string;\n /** Category when present (`UTILITY`, `MARKETING`, …). */\n category?: string;\n /** Provider template id when present. */\n id?: string;\n}\n\n/** Options for {@link TaqnyatWhatsAppTransport.createTemplate}. */\nexport interface TaqnyatCreateTemplateOptions {\n /** Unique template name without spaces. */\n name: string;\n /** Language code. */\n language: string;\n /** Template category (`UTILITY`, `MARKETING`, `AUTHENTICATION`, …). */\n category: string;\n /** Component array as required by Taqnyat / Meta. */\n components: unknown[];\n /** Allow Meta to adjust category. Defaults to `true`. */\n allowCategoryChange?: boolean;\n}\n\n/** Email fallback nested under SMS/mail failover payloads. */\nexport interface TaqnyatFailoverEmailOnFail {\n from: string;\n to: string;\n}\n\n/** SMS failover branch for {@link TaqnyatWhatsAppTransport.sendWithFailover}. */\nexport interface TaqnyatWhatsAppSmsFailover {\n sender: string;\n campaign: string;\n body: string;\n sendEmailIfFail?: TaqnyatFailoverEmailOnFail;\n}\n\n/** Mail failover branch for {@link TaqnyatWhatsAppTransport.sendWithFailover}. */\nexport interface TaqnyatWhatsAppMailFailover {\n from: string;\n to: string;\n campaign: string;\n subject: string;\n msg: string;\n cc?: string;\n sendEmailIfFail?: TaqnyatFailoverEmailOnFail;\n}\n\n/** Failover channels for {@link TaqnyatWhatsAppTransport.sendWithFailover}. */\nexport interface TaqnyatWhatsAppFailover {\n sms?: TaqnyatWhatsAppSmsFailover;\n mail?: TaqnyatWhatsAppMailFailover;\n}\n\n/** Error thrown when the Taqnyat WhatsApp API reports a failure. */\nexport class TaqnyatWhatsAppError extends SentlyError {\n /** Creates a Taqnyat WhatsApp API error with status code and response payload. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(\n message,\n httpStatusToSentlyCode(statusCode >= 100 && statusCode < 600 ? statusCode : 400),\n {\n statusCode,\n provider: \"taqnyat-whatsapp\",\n cause: apiError,\n },\n );\n this.name = \"TaqnyatWhatsAppError\";\n }\n}\n\nfunction isTemplateMessage(\n options: WhatsAppOptions,\n): options is Extract<WhatsAppOptions, { template: unknown }> {\n return \"template\" in options;\n}\n\nfunction extractMessageId(payload: Record<string, unknown>): string | undefined {\n const statuses = payload.statuses;\n if (Array.isArray(statuses)) {\n const first = statuses[0] as Record<string, unknown> | undefined;\n if (typeof first?.message_id === \"string\") return first.message_id;\n } else if (statuses && typeof statuses === \"object\") {\n const direct = (statuses as Record<string, unknown>).message_id;\n if (typeof direct === \"string\") return direct;\n for (const value of Object.values(statuses as Record<string, unknown>)) {\n if (value && typeof value === \"object\") {\n const id = (value as Record<string, unknown>).message_id;\n if (typeof id === \"string\") return id;\n }\n }\n }\n return undefined;\n}\n\n/** Docs sometimes return `statuses: \"PENDING\"` (queued) with no message_id yet. */\nfunction isQueuedAcceptance(payload: Record<string, unknown>): boolean {\n return (\n payload.statuses === \"PENDING\" ||\n (typeof payload.type === \"string\" &&\n (payload.type === \"template\" || payload.type === \"text\") &&\n payload.message === undefined)\n );\n}\n\nfunction describeWhatsAppResponse(payload: Record<string, unknown>): string {\n if (typeof payload.statuses === \"string\") return payload.statuses;\n if (typeof payload.type === \"string\") return payload.type;\n return \"Message sent\";\n}\n\nfunction throwWhatsAppError(response: Response, payload: Record<string, unknown>): never {\n const reason =\n typeof payload.reason === \"string\"\n ? payload.reason\n : typeof payload.message === \"string\"\n ? `Taqnyat WhatsApp error ${payload.message}`\n : \"Taqnyat WhatsApp API error\";\n const status =\n typeof payload.message === \"string\" && /^\\d+$/.test(payload.message)\n ? Number(payload.message)\n : response.status;\n throw new TaqnyatWhatsAppError(reason, status || response.status, payload);\n}\n\nfunction buildMessageBody(options: WhatsAppOptions): Record<string, unknown> {\n const to = normalizeTaqnyatPhone(options.to);\n return isTemplateMessage(options)\n ? {\n to,\n type: \"template\" as const,\n template: {\n name: options.template.name,\n language: { code: options.template.language },\n ...(options.template.components ? { components: options.template.components } : {}),\n },\n }\n : {\n to,\n type: \"text\" as const,\n text: { body: options.text },\n };\n}\n\nfunction normalizeNumbers(to: string | readonly string[]): string[] {\n const list = typeof to === \"string\" ? [to] : [...to];\n return list.map(normalizeTaqnyatPhone);\n}\n\n/**\n * Taqnyat WhatsApp transport via JSON Bearer `wa/v2/messages/`.\n */\nexport class TaqnyatWhatsAppTransport implements WhatsAppTransport {\n readonly provider = \"taqnyat-whatsapp\";\n\n private readonly bearerToken: string;\n\n /** Creates a Taqnyat WhatsApp transport. */\n constructor(config: TaqnyatWhatsAppConfig) {\n this.bearerToken = config.bearerToken;\n }\n\n /** Sends a WhatsApp template or session text message. */\n async send(options: WhatsAppOptions): Promise<WhatsAppSendResult> {\n return this.postMessage(buildMessageBody(options), options);\n }\n\n /**\n * Vendor extra: send with optional SMS / email failover branches\n * (same `POST /wa/v2/messages/` payload shape as Taqnyat failover docs).\n * Not part of {@link WhatsAppTransport}.\n */\n async sendWithFailover(\n options: WhatsAppOptions,\n failover: TaqnyatWhatsAppFailover,\n ): Promise<WhatsAppSendResult> {\n const body: Record<string, unknown> = buildMessageBody(options);\n if (failover.sms) {\n body.sms = {\n sender: failover.sms.sender,\n campaign: failover.sms.campaign,\n body: failover.sms.body,\n ...(failover.sms.sendEmailIfFail ? { sendEmailIfFail: failover.sms.sendEmailIfFail } : {}),\n };\n }\n if (failover.mail) {\n body.mail = {\n from: failover.mail.from,\n to: failover.mail.to,\n campaign: failover.mail.campaign,\n subject: failover.mail.subject,\n msg: failover.mail.msg,\n ...(failover.mail.cc !== undefined ? { cc: failover.mail.cc } : {}),\n ...(failover.mail.sendEmailIfFail\n ? { sendEmailIfFail: failover.mail.sendEmailIfFail }\n : {}),\n };\n }\n return this.postMessage(body, options);\n }\n\n /**\n * Vendor extra: list templates via `GET /wa/v2/templates/`.\n * Not part of {@link WhatsAppTransport}.\n */\n async listTemplates(): Promise<TaqnyatWhatsAppTemplate[]> {\n const response = await fetch(\"https://api.taqnyat.sa/wa/v2/templates/?limit=5000\", {\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n Accept: \"application/json\",\n },\n });\n const payload = (await response.json()) as {\n waba_templates?: Array<Record<string, unknown>>;\n message?: string;\n reason?: string;\n };\n\n if (!response.ok || !Array.isArray(payload.waba_templates)) {\n throwWhatsAppError(response, payload as Record<string, unknown>);\n }\n\n return payload.waba_templates.map((row) => ({\n name: String(row.name ?? \"\"),\n language: String(row.language ?? \"\"),\n status: String(row.status ?? \"\"),\n ...(typeof row.category === \"string\" ? { category: row.category } : {}),\n ...(row.id !== undefined ? { id: String(row.id) } : {}),\n }));\n }\n\n /**\n * Vendor extra: create a template via `POST /wa/v2/templates/`.\n * Not part of {@link WhatsAppTransport}.\n */\n async createTemplate(\n options: TaqnyatCreateTemplateOptions,\n ): Promise<{ id?: string; category?: string; status: string; provider: \"taqnyat-whatsapp\" }> {\n const response = await fetch(\"https://api.taqnyat.sa/wa/v2/templates/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n name: options.name,\n language: options.language,\n allow_category_change: options.allowCategoryChange ?? true,\n category: options.category,\n components: options.components,\n }),\n });\n const payload = (await response.json()) as Record<string, unknown>;\n const status =\n typeof payload.statuses === \"string\"\n ? payload.statuses\n : typeof payload.status === \"string\"\n ? payload.status\n : undefined;\n\n if (!response.ok || (payload.message !== undefined && status === undefined)) {\n throwWhatsAppError(response, payload);\n }\n\n return {\n ...(payload.id !== undefined ? { id: String(payload.id) } : {}),\n ...(typeof payload.category === \"string\" ? { category: payload.category } : {}),\n status: status ?? \"PENDING\",\n provider: \"taqnyat-whatsapp\",\n };\n }\n\n /**\n * Vendor extra: delete a template via `DELETE /wa/v2/templates/`.\n * Not part of {@link WhatsAppTransport}.\n */\n async deleteTemplate(options: {\n name: string;\n id: string | number;\n }): Promise<{ ok: true; provider: \"taqnyat-whatsapp\" }> {\n const response = await fetch(\"https://api.taqnyat.sa/wa/v2/templates/\", {\n method: \"DELETE\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ name: options.name, id: options.id }),\n });\n const payload = (await response.json()) as Record<string, unknown>;\n const ok =\n response.ok &&\n (payload.message === \"201\" || payload.reason === \"success\" || payload.message === undefined);\n\n if (!ok) {\n throwWhatsAppError(response, payload);\n }\n\n return { ok: true, provider: \"taqnyat-whatsapp\" };\n }\n\n /**\n * Vendor extra: record WhatsApp opt-in via `POST /wa/v1/provision/optin/`.\n * Not part of {@link WhatsAppTransport}.\n */\n async optIn(\n to: string | readonly string[],\n ): Promise<{ ok: true; numbers: string[]; provider: \"taqnyat-whatsapp\" }> {\n return this.postOptProvision(\"POST\", to);\n }\n\n /**\n * Vendor extra: record WhatsApp opt-out via `DELETE /wa/v1/provision/optin/`.\n * Not part of {@link WhatsAppTransport}.\n */\n async optOut(\n to: string | readonly string[],\n ): Promise<{ ok: true; numbers: string[]; provider: \"taqnyat-whatsapp\" }> {\n return this.postOptProvision(\"DELETE\", to);\n }\n\n /** Lightweight credential check. */\n async verify(): Promise<VerifyResult> {\n return {\n ok: Boolean(this.bearerToken),\n provider: \"taqnyat-whatsapp\",\n message: this.bearerToken ? \"Credentials present\" : \"Missing credentials\",\n };\n }\n\n private async postMessage(\n body: Record<string, unknown>,\n options: WhatsAppOptions,\n ): Promise<WhatsAppSendResult> {\n const response = await fetch(\"https://api.taqnyat.sa/wa/v2/messages/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const payload = (await response.json()) as Record<string, unknown>;\n const messageId = extractMessageId(payload);\n const queued = isQueuedAcceptance(payload);\n\n if (!response.ok || (payload.message !== undefined && messageId === undefined && !queued)) {\n throwWhatsAppError(response, payload);\n }\n\n return {\n messageId: messageId ?? options.messageId ?? \"\",\n to: options.to,\n status: \"accepted\",\n response: describeWhatsAppResponse(payload),\n provider: \"taqnyat-whatsapp\",\n };\n }\n\n private async postOptProvision(\n method: \"POST\" | \"DELETE\",\n to: string | readonly string[],\n ): Promise<{ ok: true; numbers: string[]; provider: \"taqnyat-whatsapp\" }> {\n const numbers = normalizeNumbers(to);\n const response = await fetch(\"https://api.taqnyat.sa/wa/v1/provision/optin/\", {\n method,\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ numbers }),\n });\n const payload = (await response.json()) as Record<string, unknown>;\n // Success: `{ type: \"Opt-In\"|\"Opt-Out\", statuses: [...] }`. Errors use `message` + `reason`.\n const looksOk = typeof payload.type === \"string\" && payload.statuses !== undefined;\n if (!looksOk) {\n throwWhatsAppError(response, payload);\n }\n\n return { ok: true, numbers, provider: \"taqnyat-whatsapp\" };\n }\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "C2A75FE9E50F4F9C64756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -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
|
|
@@ -18,6 +22,13 @@ export interface WebPushConfig {
|
|
|
18
22
|
*/
|
|
19
23
|
allowedEndpointHosts?: string[];
|
|
20
24
|
}
|
|
25
|
+
/** Generated VAPID key pair in the common web-push raw format. */
|
|
26
|
+
export interface VapidKeys {
|
|
27
|
+
/** Base64url-encoded uncompressed P-256 public key (65 bytes). */
|
|
28
|
+
publicKey: string;
|
|
29
|
+
/** Base64url-encoded raw P-256 private key (32 bytes) — treat as a secret. */
|
|
30
|
+
privateKey: string;
|
|
31
|
+
}
|
|
21
32
|
/** Error thrown when a push service rejects the request. */
|
|
22
33
|
export declare class WebPushError extends SentlyError {
|
|
23
34
|
readonly statusCode: number;
|
|
@@ -25,6 +36,18 @@ export declare class WebPushError extends SentlyError {
|
|
|
25
36
|
/** Creates a Web Push error with HTTP status and response body. */
|
|
26
37
|
constructor(message: string, statusCode: number, apiError: unknown);
|
|
27
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Generate a VAPID key pair in the common web-push raw format
|
|
41
|
+
* (base64url public + base64url private `d`).
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { generateVapidKeys } from "sently/transports/webpush";
|
|
46
|
+
*
|
|
47
|
+
* const { publicKey, privateKey } = await generateVapidKeys();
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare function generateVapidKeys(): Promise<VapidKeys>;
|
|
28
51
|
/**
|
|
29
52
|
* Web Push transport — VAPID auth + RFC 8291 payload encryption.
|
|
30
53
|
*
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{W as T}from"../chunk-fv0bemgf.js";import{_ as
|
|
1
|
+
import{V as w}from"../chunk-8kpgbrba.js";import{W as T}from"../chunk-fv0bemgf.js";import{_ as U}from"../chunk-z1589fjk.js";import{ga as V,ha as x,ia as H}from"../chunk-hnzmn4s4.js";import{ja as A,ka as S}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";var g=new Set(["very-low","low","normal","high"]),B=/^[\x21-\x7E]{1,32}$/;class F extends A{statusCode;apiError;constructor(q,z,Q){super(q,S(z),{statusCode:z,provider:"webpush",cause:Q});this.statusCode=z;this.apiError=Q;this.name="WebPushError"}}var W=2419200,f=4096,_=4096;function E(q){if(q.startsWith("mailto:")){let z=q.slice(7);if(w(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 F(`WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(q)}`,400,{field:"subject",value:q})}async function s(){let q=await crypto.subtle.generateKey({name:"ECDSA",namedCurve:"P-256"},!0,["sign","verify"]),z=new Uint8Array(await crypto.subtle.exportKey("raw",q.publicKey)),Q=await crypto.subtle.exportKey("jwk",q.privateKey);if(typeof Q.d!=="string"||Q.d.length===0)throw new F("Failed to export VAPID private key",500,{field:"privateKey"});return{publicKey:V(z),privateKey:Q.d}}function P(q){if(!g.has(q))throw new F(`Web Push urgency must be very-low, low, normal, or high, got: ${JSON.stringify(q)}`,400,{field:"urgency",value:q})}function h(q){if(!B.test(q))throw new F(`Web Push topic must be 1–32 printable ASCII characters, got: ${JSON.stringify(q)}`,400,{field:"topic",value:q})}function l(q){if(q.silent){if(q.data===void 0)throw new F("silent Web Push requires data",400,{field:"data"});return{data:q.data}}let z=typeof q.title==="string"&&q.title.length>0,Q=typeof q.body==="string"&&q.body.length>0;if(!z&&!Q){if(q.data===void 0)throw new F("Web Push requires title and body, or data for a data-only / silent send",400,{fields:["title","body","data"]});return{data:q.data}}if(!z||!Q)throw new F("Visible Web Push notifications require both title and body",400,{fields:["title","body"]});return{title:q.title,body:q.body,...q.data!==void 0?{data:q.data}:{},...q.icon!==void 0?{icon:q.icon}:{},...q.badge!==void 0?{badge:q.badge}:{},...q.image!==void 0?{image:q.image}:{},...q.tag!==void 0?{tag:q.tag}:{},...q.actions!==void 0?{actions:q.actions}:{},...q.requireInteraction!==void 0?{requireInteraction:q.requireInteraction}:{},...q.renotify!==void 0?{renotify:q.renotify}:{}}}function L(q){return q.buffer.slice(q.byteOffset,q.byteOffset+q.byteLength)}function D(...q){let z=q.reduce((G,M)=>G+M.length,0),Q=new Uint8Array(z),$=0;for(let G of q)Q.set(G,$),$+=G.length;return Q}function y(q){let z=new Uint8Array(4);return new DataView(z.buffer).setUint32(0,q,!1),z}function d(q){if(q.length!==65||q[0]!==4)throw new F("Invalid P-256 uncompressed public key",400,{length:q.length});return{kty:"EC",crv:"P-256",x:V(q.subarray(1,33)),y:V(q.subarray(33,65))}}function b(q,z){return{...d(q),d:V(z)}}async function m(q,z){let Q=await crypto.subtle.importKey("raw",L(q),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),$=await crypto.subtle.sign("HMAC",Q,L(z));return new Uint8Array($)}async function v(q,z,Q,$){let G=await m(q,z);return(await m(G,D(Q,new Uint8Array([1])))).subarray(0,$)}async function u(q,z){let Q=x(q.keys.p256dh),$=x(q.keys.auth);if(Q.length!==65||Q[0]!==4)throw new F("Invalid subscription p256dh key",400,{length:Q.length});if($.length<16)throw new F("Invalid subscription auth key",400,{length:$.length});let G=await crypto.subtle.generateKey({name:"ECDH",namedCurve:"P-256"},!0,["deriveBits"]),M=new Uint8Array(await crypto.subtle.exportKey("raw",G.publicKey)),N=await crypto.subtle.importKey("raw",L(Q),{name:"ECDH",namedCurve:"P-256"},!1,[]),Z=new Uint8Array(await crypto.subtle.deriveBits({name:"ECDH",public:N},G.privateKey,256)),X=D(H("WebPush: info\x00"),Q,M),J=await v($,Z,X,32),O=crypto.getRandomValues(new Uint8Array(16)),I=H("Content-Encoding: aes128gcm\x00"),R=H("Content-Encoding: nonce\x00"),Y=await v(O,J,I,16),C=await v(O,J,R,12),j=D(z,new Uint8Array([2])),k=await crypto.subtle.importKey("raw",L(Y),"AES-GCM",!1,["encrypt"]),K=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:L(C)},k,L(j)));return D(O,y(f),new Uint8Array([M.length]),M,K)}async function a(q,z,Q,$){let G=x(Q),M=x($),N=b(G,M),Z=await crypto.subtle.importKey("jwk",N,{name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),X=new URL(q).origin,J={typ:"JWT",alg:"ES256"},O={aud:X,exp:Math.floor(Date.now()/1000)+43200,sub:z},I=V(H(JSON.stringify(J))),R=V(H(JSON.stringify(O))),Y=`${I}.${R}`,C=new Uint8Array(await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},Z,L(H(Y))));return`${Y}.${V(C)}`}class c{provider="webpush";vapidPublicKey;vapidPrivateKey;subject;allowedEndpointHosts;constructor(q){E(q.subject),this.vapidPublicKey=q.vapidPublicKey,this.vapidPrivateKey=q.vapidPrivateKey,this.subject=q.subject,this.allowedEndpointHosts=q.allowedEndpointHosts??[]}async send(q){if(!U(q))throw new F("WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens",400,{hint:"fcm"});try{T(q.subscription.endpoint,this.allowedEndpointHosts)}catch(X){let J;try{J=new URL(q.subscription.endpoint).hostname}catch{J=void 0}throw new F(X instanceof Error?X.message:"Unsafe push subscription endpoint",400,{endpointHost:J})}if(q.urgency!==void 0)P(q.urgency);if(q.topic!==void 0)h(q.topic);let z=l(q),Q=H(JSON.stringify(z)),$=await u(q.subscription,Q);if($.length>_)throw new F(`Encrypted push payload exceeds ${_} bytes (RFC 8291)`,413,{size:$.length});let G=await a(q.subscription.endpoint,this.subject,this.vapidPublicKey,this.vapidPrivateKey),M=q.ttl??W,N={Authorization:`vapid t=${G}, k=${this.vapidPublicKey}`,"Content-Encoding":"aes128gcm",TTL:String(M),"Content-Type":"application/octet-stream"};if(q.urgency!==void 0)N.Urgency=q.urgency;if(q.topic!==void 0)N.Topic=q.topic;let Z=await fetch(q.subscription.endpoint,{method:"POST",redirect:"manual",headers:N,body:L($)});if(Z.status>=300&&Z.status<400)throw new F(`Push service returned redirect ${Z.status}; redirects are not followed`,Z.status,{location:Z.headers.get("location")});if(!Z.ok){let X=await Z.text().catch(()=>"");throw new F(X||`Web Push request failed with status ${Z.status}`,Z.status,X||{status:Z.status})}return{messageId:q.messageId??crypto.randomUUID(),status:"accepted",response:String(Z.status),provider:"webpush"}}async verify(){try{let q=x(this.vapidPublicKey),z=x(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{s as generateVapidKeys,c as WebPushTransport,F as WebPushError};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=4FE1E8A522321CE464756E2164756E21
|
|
@@ -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 WebPushOptions,\n WebPushUrgency,\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/** Generated VAPID key pair in the common web-push raw format. */\nexport interface VapidKeys {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n publicKey: string;\n /** Base64url-encoded raw P-256 private key (32 bytes) — treat as a secret. */\n privateKey: string;\n}\n\nconst WEB_PUSH_URGENCIES: ReadonlySet<WebPushUrgency> = new Set([\n \"very-low\",\n \"low\",\n \"normal\",\n \"high\",\n]);\n\n/** RFC 8030 Topic: printable ASCII, max 32 characters. */\nconst TOPIC_PATTERN = /^[\\x21-\\x7E]{1,32}$/;\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\n/**\n * Generate a VAPID key pair in the common web-push raw format\n * (base64url public + base64url private `d`).\n *\n * @example\n * ```ts\n * import { generateVapidKeys } from \"sently/transports/webpush\";\n *\n * const { publicKey, privateKey } = await generateVapidKeys();\n * ```\n */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const keyPair = await crypto.subtle.generateKey({ name: \"ECDSA\", namedCurve: \"P-256\" }, true, [\n \"sign\",\n \"verify\",\n ]);\n const publicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", keyPair.publicKey));\n const jwk = (await crypto.subtle.exportKey(\"jwk\", keyPair.privateKey)) as JsonWebKey;\n if (typeof jwk.d !== \"string\" || jwk.d.length === 0) {\n throw new WebPushError(\"Failed to export VAPID private key\", 500, { field: \"privateKey\" });\n }\n return {\n publicKey: encodeBase64Url(publicRaw),\n privateKey: jwk.d,\n };\n}\n\nfunction assertUrgency(urgency: string): asserts urgency is WebPushUrgency {\n if (!WEB_PUSH_URGENCIES.has(urgency as WebPushUrgency)) {\n throw new WebPushError(\n `Web Push urgency must be very-low, low, normal, or high, got: ${JSON.stringify(urgency)}`,\n 400,\n { field: \"urgency\", value: urgency },\n );\n }\n}\n\nfunction assertTopic(topic: string): void {\n if (!TOPIC_PATTERN.test(topic)) {\n throw new WebPushError(\n `Web Push topic must be 1–32 printable ASCII characters, got: ${JSON.stringify(topic)}`,\n 400,\n { field: \"topic\", value: topic },\n );\n }\n}\n\n/**\n * Build the JSON object encrypted for the service worker.\n * Visible notifications need `title` + `body`; silent / data-only send `data` alone.\n */\nfunction buildWebPushPayload(options: WebPushOptions): Record<string, unknown> {\n if (options.silent) {\n if (options.data === undefined) {\n throw new WebPushError(\"silent Web Push requires data\", 400, { field: \"data\" });\n }\n return { data: options.data };\n }\n\n const hasTitle = typeof options.title === \"string\" && options.title.length > 0;\n const hasBody = typeof options.body === \"string\" && options.body.length > 0;\n\n if (!hasTitle && !hasBody) {\n if (options.data === undefined) {\n throw new WebPushError(\n \"Web Push requires title and body, or data for a data-only / silent send\",\n 400,\n { fields: [\"title\", \"body\", \"data\"] },\n );\n }\n return { data: options.data };\n }\n\n if (!hasTitle || !hasBody) {\n throw new WebPushError(\"Visible Web Push notifications require both title and body\", 400, {\n fields: [\"title\", \"body\"],\n });\n }\n\n return {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n ...(options.badge !== undefined ? { badge: options.badge } : {}),\n ...(options.image !== undefined ? { image: options.image } : {}),\n ...(options.tag !== undefined ? { tag: options.tag } : {}),\n ...(options.actions !== undefined ? { actions: options.actions } : {}),\n ...(options.requireInteraction !== undefined\n ? { requireInteraction: options.requireInteraction }\n : {}),\n ...(options.renotify !== undefined ? { renotify: options.renotify } : {}),\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 if (options.urgency !== undefined) {\n assertUrgency(options.urgency);\n }\n if (options.topic !== undefined) {\n assertTopic(options.topic);\n }\n\n const notification = buildWebPushPayload(options);\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 headers: Record<string, string> = {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n };\n if (options.urgency !== undefined) {\n headers.Urgency = options.urgency;\n }\n if (options.topic !== undefined) {\n headers.Topic = options.topic;\n }\n\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers,\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": "4FE1E8A522321CE464756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/dist/webhooks/sndr.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{a as W,c as _}from"../chunk-pqp843mh.js";import{f as U,g as X,i as j}from"../chunk-sbgt4165.js";import"../chunk-th4cwrpb.js";var x={"email.delivered":"delivered","email.bounced":"bounced","email.unsubscribed":"unknown"};function P(K){if(typeof K!=="object"||K===null)return[];let I=K,z=typeof I.type==="string"?I.type:"",L=typeof I.data==="object"&&I.data!==null?I.data:{},G=L.to??L.addresses,Q=Array.isArray(G)?typeof G[0]==="string"?G[0]:void 0:typeof G==="string"?G:void 0,Z=X(I.created_at);return[j({provider:"sndr",type:U(z,x),...typeof L.email_id==="string"?{messageId:L.email_id}:{},...Q!==void 0?{recipient:Q}:{},...Z!==void 0?{timestamp:Z}:{},raw:K})]}async function R(K,I,z,L){if(!I||!z)return!1;let G,Q;for(let Y of I.split(",")){let $=Y.indexOf("=");if($<0)continue;let J=Y.slice(0,$).trim(),O=Y.slice($+1).trim();if(J==="t")G=O;else if(J==="v1")Q=O}if(!G||!Q)return!1;let Z=L?.toleranceSeconds??300;if(Z>0){let Y=Number(G);if(!Number.isFinite(Y))return!1;let $=Math.floor(Date.now()/1000);if(Math.abs($-Y)>Z)return!1}let F=_(Q);if(F===null)return!1;let C=await crypto.subtle.importKey("raw",new TextEncoder().encode(z),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),D=new TextEncoder().encode(`${G}.${K}`),V=await crypto.subtle.sign("HMAC",C,D);return W(new Uint8Array(V),F)}export{R as verifySignature,P as parse};
|
|
1
|
+
import{a as W,c as _}from"../chunk-pqp843mh.js";import{f as U,g as X,i as j}from"../chunk-sbgt4165.js";import"../chunk-th4cwrpb.js";var x={"email.queued":"deferred","email.delivered":"delivered","email.bounced":"bounced","email.failed":"unknown","email.complained":"complained","email.opened":"opened","email.clicked":"clicked","email.unsubscribed":"unknown"};function P(K){if(typeof K!=="object"||K===null)return[];let I=K,z=typeof I.type==="string"?I.type:"",L=typeof I.data==="object"&&I.data!==null?I.data:{},G=L.to??L.addresses,Q=Array.isArray(G)?typeof G[0]==="string"?G[0]:void 0:typeof G==="string"?G:void 0,Z=X(I.created_at);return[j({provider:"sndr",type:U(z,x),...typeof L.email_id==="string"?{messageId:L.email_id}:{},...Q!==void 0?{recipient:Q}:{},...Z!==void 0?{timestamp:Z}:{},raw:K})]}async function R(K,I,z,L){if(!I||!z)return!1;let G,Q;for(let Y of I.split(",")){let $=Y.indexOf("=");if($<0)continue;let J=Y.slice(0,$).trim(),O=Y.slice($+1).trim();if(J==="t")G=O;else if(J==="v1")Q=O}if(!G||!Q)return!1;let Z=L?.toleranceSeconds??300;if(Z>0){let Y=Number(G);if(!Number.isFinite(Y))return!1;let $=Math.floor(Date.now()/1000);if(Math.abs($-Y)>Z)return!1}let F=_(Q);if(F===null)return!1;let C=await crypto.subtle.importKey("raw",new TextEncoder().encode(z),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),D=new TextEncoder().encode(`${G}.${K}`),V=await crypto.subtle.sign("HMAC",C,D);return W(new Uint8Array(V),F)}export{R as verifySignature,P as parse};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=6092528FFA26D54664756E2164756E21
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/webhooks/sndr.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * @module\n * SNDR webhook parser and signature verification.\n *\n * Signature scheme (from `@rkiza/sndr` / SNDR dispatcher):\n * `X-Sndr-Signature: t=<unix_seconds>,v1=<hex(HMAC-SHA256(secret, `${t}.${rawBody}`))>`\n */\nimport { decodeHexBytes, timingSafeEqual } from \"./timing-safe-equal.js\";\nimport type { EmailEvent } from \"./types.js\";\nimport { emailEvent, mapEventType, parseTimestamp } from \"./types.js\";\n\nconst SNDR_EVENT_MAP: Record<string, EmailEvent[\"type\"]> = {\n \"email.delivered\": \"delivered\",\n \"email.bounced\": \"bounced\",\n \"email.unsubscribed\": \"unknown\",\n};\n\n/** Parse an SNDR webhook payload into normalized {@link EmailEvent} records. */\nexport function parse(payload: unknown): EmailEvent[] {\n if (typeof payload !== \"object\" || payload === null) {\n return [];\n }\n\n const record = payload as Record<string, unknown>;\n const type = typeof record.type === \"string\" ? record.type : \"\";\n const data =\n typeof record.data === \"object\" && record.data !== null\n ? (record.data as Record<string, unknown>)\n : {};\n\n const to = data.to ?? data.addresses;\n const recipient = Array.isArray(to)\n ? typeof to[0] === \"string\"\n ? to[0]\n : undefined\n : typeof to === \"string\"\n ? to\n : undefined;\n\n const timestamp = parseTimestamp(record.created_at);\n\n return [\n emailEvent({\n provider: \"sndr\",\n type: mapEventType(type, SNDR_EVENT_MAP),\n ...(typeof data.email_id === \"string\" ? { messageId: data.email_id } : {}),\n ...(recipient !== undefined ? { recipient } : {}),\n ...(timestamp !== undefined ? { timestamp } : {}),\n raw: payload,\n }),\n ];\n}\n\n/**\n * Verify an SNDR webhook signature using HMAC-SHA256 (Web Crypto).\n *\n * Pass the **exact** raw request body string — re-serializing JSON breaks the HMAC.\n *\n * @param rawBody — Raw HTTP body bytes as UTF-8 string (or the string used when signing).\n * @param signatureHeader — Value of the `X-Sndr-Signature` header (`t=…,v1=…`).\n * @param secret — Webhook endpoint signing secret from SNDR.\n * @param opts.toleranceSeconds — Max age of `t` in seconds (default `300`). Pass `0` to skip.\n */\nexport async function verifySignature(\n rawBody: string,\n signatureHeader: string,\n secret: string,\n opts?: { toleranceSeconds?: number },\n): Promise<boolean> {\n if (!signatureHeader || !secret) {\n return false;\n }\n\n let timestamp: string | undefined;\n let v1: string | undefined;\n for (const part of signatureHeader.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = value;\n else if (key === \"v1\") v1 = value;\n }\n\n if (!timestamp || !v1) {\n return false;\n }\n\n const tolerance = opts?.toleranceSeconds ?? 300;\n if (tolerance > 0) {\n const tsNum = Number(timestamp);\n if (!Number.isFinite(tsNum)) {\n return false;\n }\n const nowSec = Math.floor(Date.now() / 1000);\n if (Math.abs(nowSec - tsNum) > tolerance) {\n return false;\n }\n }\n\n const providedBytes = decodeHexBytes(v1);\n if (providedBytes === null) {\n return false;\n }\n\n const key = await crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n\n const signed = new TextEncoder().encode(`${timestamp}.${rawBody}`);\n const expected = await crypto.subtle.sign(\"HMAC\", key, signed);\n return timingSafeEqual(new Uint8Array(expected), providedBytes);\n}\n"
|
|
5
|
+
"/**\n * @module\n * SNDR webhook parser and signature verification.\n *\n * Signature scheme (from `@rkiza/sndr` / SNDR dispatcher):\n * `X-Sndr-Signature: t=<unix_seconds>,v1=<hex(HMAC-SHA256(secret, `${t}.${rawBody}`))>`\n */\nimport { decodeHexBytes, timingSafeEqual } from \"./timing-safe-equal.js\";\nimport type { EmailEvent } from \"./types.js\";\nimport { emailEvent, mapEventType, parseTimestamp } from \"./types.js\";\n\nconst SNDR_EVENT_MAP: Record<string, EmailEvent[\"type\"]> = {\n \"email.queued\": \"deferred\",\n \"email.delivered\": \"delivered\",\n \"email.bounced\": \"bounced\",\n \"email.failed\": \"unknown\",\n \"email.complained\": \"complained\",\n \"email.opened\": \"opened\",\n \"email.clicked\": \"clicked\",\n \"email.unsubscribed\": \"unknown\",\n};\n\n/** Parse an SNDR webhook payload into normalized {@link EmailEvent} records. */\nexport function parse(payload: unknown): EmailEvent[] {\n if (typeof payload !== \"object\" || payload === null) {\n return [];\n }\n\n const record = payload as Record<string, unknown>;\n const type = typeof record.type === \"string\" ? record.type : \"\";\n const data =\n typeof record.data === \"object\" && record.data !== null\n ? (record.data as Record<string, unknown>)\n : {};\n\n const to = data.to ?? data.addresses;\n const recipient = Array.isArray(to)\n ? typeof to[0] === \"string\"\n ? to[0]\n : undefined\n : typeof to === \"string\"\n ? to\n : undefined;\n\n const timestamp = parseTimestamp(record.created_at);\n\n return [\n emailEvent({\n provider: \"sndr\",\n type: mapEventType(type, SNDR_EVENT_MAP),\n ...(typeof data.email_id === \"string\" ? { messageId: data.email_id } : {}),\n ...(recipient !== undefined ? { recipient } : {}),\n ...(timestamp !== undefined ? { timestamp } : {}),\n raw: payload,\n }),\n ];\n}\n\n/**\n * Verify an SNDR webhook signature using HMAC-SHA256 (Web Crypto).\n *\n * Pass the **exact** raw request body string — re-serializing JSON breaks the HMAC.\n *\n * @param rawBody — Raw HTTP body bytes as UTF-8 string (or the string used when signing).\n * @param signatureHeader — Value of the `X-Sndr-Signature` header (`t=…,v1=…`).\n * @param secret — Webhook endpoint signing secret from SNDR.\n * @param opts.toleranceSeconds — Max age of `t` in seconds (default `300`). Pass `0` to skip.\n */\nexport async function verifySignature(\n rawBody: string,\n signatureHeader: string,\n secret: string,\n opts?: { toleranceSeconds?: number },\n): Promise<boolean> {\n if (!signatureHeader || !secret) {\n return false;\n }\n\n let timestamp: string | undefined;\n let v1: string | undefined;\n for (const part of signatureHeader.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = value;\n else if (key === \"v1\") v1 = value;\n }\n\n if (!timestamp || !v1) {\n return false;\n }\n\n const tolerance = opts?.toleranceSeconds ?? 300;\n if (tolerance > 0) {\n const tsNum = Number(timestamp);\n if (!Number.isFinite(tsNum)) {\n return false;\n }\n const nowSec = Math.floor(Date.now() / 1000);\n if (Math.abs(nowSec - tsNum) > tolerance) {\n return false;\n }\n }\n\n const providedBytes = decodeHexBytes(v1);\n if (providedBytes === null) {\n return false;\n }\n\n const key = await crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n\n const signed = new TextEncoder().encode(`${timestamp}.${rawBody}`);\n const expected = await crypto.subtle.sign(\"HMAC\", key, signed);\n return timingSafeEqual(new Uint8Array(expected), providedBytes);\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "8IAWA,DAAM,HAAqD,CACzD,kBAAmB,YACnB,gBAAiB,UACjB,qBAAsB,SACxB,EAGO,SAAS,CAAK,CAAC,EAAgC,CACpD,GAAI,OAAO,IAAY,UAAY,IAAY,KAC7C,MAAO,CAAC,EAGV,IAAM,EAAS,EACT,EAAO,OAAO,EAAO,OAAS,SAAW,EAAO,KAAO,GACvD,EACJ,OAAO,EAAO,OAAS,UAAY,EAAO,OAAS,KAC9C,EAAO,KACR,CAAC,EAED,EAAK,EAAK,IAAM,EAAK,UACrB,EAAY,MAAM,QAAQ,CAAE,EAC9B,OAAO,EAAG,KAAO,SACf,EAAG,GACH,OACF,OAAO,IAAO,SACZ,EACA,OAEA,EAAY,EAAe,EAAO,UAAU,EAElD,MAAO,CACL,EAAW,CACT,SAAU,OACV,KAAM,EAAa,EAAM,CAAc,KACnC,OAAO,EAAK,WAAa,SAAW,CAAE,UAAW,EAAK,QAAS,EAAI,CAAC,KACpE,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,KAC3C,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,EAC/C,IAAK,CACP,CAAC,CACH,EAaF,eAAsB,CAAe,CACnC,EACA,EACA,EACA,EACkB,CAClB,GAAI,CAAC,GAAmB,CAAC,EACvB,MAAO,GAGT,IAAI,EACA,EACJ,QAAW,KAAQ,EAAgB,MAAM,GAAG,EAAG,CAC7C,IAAM,EAAK,EAAK,QAAQ,GAAG,EAC3B,GAAI,EAAK,EAAG,SACZ,IAAM,EAAM,EAAK,MAAM,EAAG,CAAE,EAAE,KAAK,EAC7B,EAAQ,EAAK,MAAM,EAAK,CAAC,EAAE,KAAK,EACtC,GAAI,IAAQ,IAAK,EAAY,EACxB,QAAI,IAAQ,KAAM,EAAK,EAG9B,GAAI,CAAC,GAAa,CAAC,EACjB,MAAO,GAGT,IAAM,EAAY,GAAM,kBAAoB,IAC5C,GAAI,EAAY,EAAG,CACjB,IAAM,EAAQ,OAAO,CAAS,EAC9B,GAAI,CAAC,OAAO,SAAS,CAAK,EACxB,MAAO,GAET,IAAM,EAAS,KAAK,MAAM,KAAK,IAAI,EAAI,IAAI,EAC3C,GAAI,KAAK,IAAI,EAAS,CAAK,EAAI,EAC7B,MAAO,GAIX,IAAM,EAAgB,EAAe,CAAE,EACvC,GAAI,IAAkB,KACpB,MAAO,GAGT,IAAM,EAAM,MAAM,OAAO,OAAO,UAC9B,MACA,IAAI,YAAY,EAAE,OAAO,CAAM,EAC/B,CAAE,KAAM,OAAQ,KAAM,SAAU,EAChC,GACA,CAAC,MAAM,CACT,EAEM,EAAS,IAAI,YAAY,EAAE,OAAO,GAAG,KAAa,GAAS,EAC3D,EAAW,MAAM,OAAO,OAAO,KAAK,OAAQ,EAAK,CAAM,EAC7D,OAAO,EAAgB,IAAI,WAAW,CAAQ,EAAG,CAAa",
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "8IAWA,DAAM,HAAqD,CACzD,eAAgB,WAChB,kBAAmB,YACnB,gBAAiB,UACjB,eAAgB,UAChB,mBAAoB,aACpB,eAAgB,SAChB,gBAAiB,UACjB,qBAAsB,SACxB,EAGO,SAAS,CAAK,CAAC,EAAgC,CACpD,GAAI,OAAO,IAAY,UAAY,IAAY,KAC7C,MAAO,CAAC,EAGV,IAAM,EAAS,EACT,EAAO,OAAO,EAAO,OAAS,SAAW,EAAO,KAAO,GACvD,EACJ,OAAO,EAAO,OAAS,UAAY,EAAO,OAAS,KAC9C,EAAO,KACR,CAAC,EAED,EAAK,EAAK,IAAM,EAAK,UACrB,EAAY,MAAM,QAAQ,CAAE,EAC9B,OAAO,EAAG,KAAO,SACf,EAAG,GACH,OACF,OAAO,IAAO,SACZ,EACA,OAEA,EAAY,EAAe,EAAO,UAAU,EAElD,MAAO,CACL,EAAW,CACT,SAAU,OACV,KAAM,EAAa,EAAM,CAAc,KACnC,OAAO,EAAK,WAAa,SAAW,CAAE,UAAW,EAAK,QAAS,EAAI,CAAC,KACpE,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,KAC3C,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,EAC/C,IAAK,CACP,CAAC,CACH,EAaF,eAAsB,CAAe,CACnC,EACA,EACA,EACA,EACkB,CAClB,GAAI,CAAC,GAAmB,CAAC,EACvB,MAAO,GAGT,IAAI,EACA,EACJ,QAAW,KAAQ,EAAgB,MAAM,GAAG,EAAG,CAC7C,IAAM,EAAK,EAAK,QAAQ,GAAG,EAC3B,GAAI,EAAK,EAAG,SACZ,IAAM,EAAM,EAAK,MAAM,EAAG,CAAE,EAAE,KAAK,EAC7B,EAAQ,EAAK,MAAM,EAAK,CAAC,EAAE,KAAK,EACtC,GAAI,IAAQ,IAAK,EAAY,EACxB,QAAI,IAAQ,KAAM,EAAK,EAG9B,GAAI,CAAC,GAAa,CAAC,EACjB,MAAO,GAGT,IAAM,EAAY,GAAM,kBAAoB,IAC5C,GAAI,EAAY,EAAG,CACjB,IAAM,EAAQ,OAAO,CAAS,EAC9B,GAAI,CAAC,OAAO,SAAS,CAAK,EACxB,MAAO,GAET,IAAM,EAAS,KAAK,MAAM,KAAK,IAAI,EAAI,IAAI,EAC3C,GAAI,KAAK,IAAI,EAAS,CAAK,EAAI,EAC7B,MAAO,GAIX,IAAM,EAAgB,EAAe,CAAE,EACvC,GAAI,IAAkB,KACpB,MAAO,GAGT,IAAM,EAAM,MAAM,OAAO,OAAO,UAC9B,MACA,IAAI,YAAY,EAAE,OAAO,CAAM,EAC/B,CAAE,KAAM,OAAQ,KAAM,SAAU,EAChC,GACA,CAAC,MAAM,CACT,EAEM,EAAS,IAAI,YAAY,EAAE,OAAO,GAAG,KAAa,GAAS,EAC3D,EAAW,MAAM,OAAO,OAAO,KAAK,OAAQ,EAAK,CAAM,EAC7D,OAAO,EAAgB,IAAI,WAAW,CAAQ,EAAG,CAAa",
|
|
8
|
+
"debugId": "6092528FFA26D54664756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sently",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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,
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "bun run build.ts",
|
|
27
|
-
"test": "bun test",
|
|
27
|
+
"test": "TAQNYAT_LIVE= SNDR_LIVE= INBUCKET_LIVE= bun test --path-ignore-patterns='**/*.live.test.ts'",
|
|
28
28
|
"test:watch": "bun test --watch",
|
|
29
|
+
"test:live": "bun -e \"if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error('test:live is local-only'); process.exit(1); }\" && bun test tests/integration/*.live.test.ts",
|
|
29
30
|
"lint": "biome check src",
|
|
30
31
|
"format": "biome format src --write",
|
|
31
32
|
"typecheck": "tsc --noEmit",
|
|
@@ -192,6 +193,10 @@
|
|
|
192
193
|
"import": "./dist/transports/mailpit.js",
|
|
193
194
|
"types": "./dist/transports/mailpit.d.ts"
|
|
194
195
|
},
|
|
196
|
+
"./transports/inbucket": {
|
|
197
|
+
"import": "./dist/transports/inbucket.js",
|
|
198
|
+
"types": "./dist/transports/inbucket.d.ts"
|
|
199
|
+
},
|
|
195
200
|
"./transports/loops": {
|
|
196
201
|
"import": "./dist/transports/loops.js",
|
|
197
202
|
"types": "./dist/transports/loops.d.ts"
|
|
@@ -8,6 +8,8 @@ source: "site/app/llms.txt/route.ts"
|
|
|
8
8
|
The docs site serves `/llms.txt` and `/llms-full.txt` for concise and complete machine-readable documentation.
|
|
9
9
|
Use the live site indexes when an agent needs current handbook navigation.
|
|
10
10
|
|
|
11
|
+
Site `/llms.txt` opens with a short agent preamble (what sently is — notification and messaging infrastructure for TypeScript, not a message queue), then the Fumadocs handbook index.
|
|
12
|
+
|
|
11
13
|
<Callout title="The one rule">
|
|
12
14
|
Prefer the docs site `/llms.txt` over the repo-root `llms.txt` file when both exist — the site index is generated from the handbook MDX.
|
|
13
15
|
</Callout>
|
|
@@ -92,12 +92,13 @@ const mailer = await createSMTPMailer({
|
|
|
92
92
|
- [Transport contracts](../reference/transport-contracts)
|
|
93
93
|
- [Hooks](./hooks)
|
|
94
94
|
- [Mailpit](../transports/mailpit) — local catcher for development
|
|
95
|
+
- [Inbucket](../transports/inbucket) — local catcher with mailbox REST API
|
|
95
96
|
- [Preview](../decorators/preview) — write `.eml` files to disk
|
|
96
97
|
|
|
97
98
|
## Next
|
|
98
99
|
|
|
99
100
|
<Cards>
|
|
100
101
|
<Card title="Transports" href="/docs/transports" />
|
|
101
|
-
<Card title="
|
|
102
|
+
<Card title="Inbucket" href="/docs/transports/inbucket" />
|
|
102
103
|
<Card title="Send in bulk" href="/docs/guides/send-bulk" />
|
|
103
104
|
</Cards>
|
|
@@ -70,7 +70,9 @@ await push.send({
|
|
|
70
70
|
| Web Push | `subscription` | `WebPushTransport` |
|
|
71
71
|
| FCM | `token` | `FcmTransport` |
|
|
72
72
|
|
|
73
|
-
Shared fields
|
|
73
|
+
Shared fields include `title`, `body`, and optional `data` / `icon` / `ttl`.
|
|
74
|
+
Web Push also accepts `urgency`, `topic`, rich Notification fields, and silent /
|
|
75
|
+
data-only sends — see [Push options](../reference/push-options).
|
|
74
76
|
|
|
75
77
|
## Troubleshooting
|
|
76
78
|
|
|
@@ -50,7 +50,7 @@ Files land under `outDir` (default `./.emails`). With `open: true`, the OS opens
|
|
|
50
50
|
|
|
51
51
|
`provider` is `"preview"`. `verify()` always succeeds.
|
|
52
52
|
|
|
53
|
-
For a real SMTP catcher with a web UI, use [Mailpit](/docs/transports/mailpit) instead.
|
|
53
|
+
For a real SMTP catcher with a web UI, use [Mailpit](/docs/transports/mailpit) or [Inbucket](/docs/transports/inbucket) instead.
|
|
54
54
|
|
|
55
55
|
## Troubleshooting
|
|
56
56
|
|
|
@@ -66,6 +66,7 @@ For a real SMTP catcher with a web UI, use [Mailpit](/docs/transports/mailpit) i
|
|
|
66
66
|
## Learn more
|
|
67
67
|
|
|
68
68
|
- [Mailpit](/docs/transports/mailpit) — local SMTP catcher with inbox API
|
|
69
|
+
- [Inbucket](/docs/transports/inbucket) — local SMTP catcher with mailbox REST API
|
|
69
70
|
- [Email channel](/docs/channels/email) — `createMailer` contract
|
|
70
71
|
|
|
71
72
|
## Next
|
|
@@ -88,7 +88,7 @@ No. `createMailer` requires a transport. Import `createSMTPMailer` from `sently/
|
|
|
88
88
|
|
|
89
89
|
<Accordion title="Where do provider transports come from?">
|
|
90
90
|
|
|
91
|
-
Import the concrete transport from its exported `sently/transports/*` subpath, such as `sently/transports/sndr`, `sently/transports/resend`, or `sently/transports/
|
|
91
|
+
Import the concrete transport from its exported `sently/transports/*` subpath, such as `sently/transports/sndr`, `sently/transports/resend`, or `sently/transports/inbucket`.
|
|
92
92
|
|
|
93
93
|
</Accordion>
|
|
94
94
|
|