Initial Commit
This commit is contained in:
117
packages/communication/messaging/sms-providers/README.md
Normal file
117
packages/communication/messaging/sms-providers/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# @suaveplan/sms-providers - Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam.
|
||||
|
||||
> Real provider clients for `@suaveplan/sms` — Twilio, Vonage, MessageBird, Plivo, AWS SNS. Universal (Bun, Node, browsers, Workers, Deno) over an injectable `FetchLike` seam; no provider SDKs in the dep tree.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [aws-sns](./src/aws-sns/aws-sns.md) — Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the r…
|
||||
- [aws-sns](./src/aws-sns/aws-sns.md) — Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the r…
|
||||
- [sigv4](./src/aws-sns/sigv4.md) — Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB)…
|
||||
- [crypto](./src/crypto/default-hmac.md) — Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of u…
|
||||
- [default-hmac](./src/crypto/default-hmac.md) — Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of u…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subc…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subc…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, se…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, se…
|
||||
- [keywords](./src/keywords/keywords.md) — Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOu…
|
||||
- [keywords](./src/keywords/keywords.md) — Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOu…
|
||||
- [messagebird](./src/messagebird/messagebird.md) — Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorizatio…
|
||||
- [messagebird](./src/messagebird/messagebird.md) — Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorizatio…
|
||||
- [otel](./src/otel/otel.md) — Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider cl…
|
||||
- [otel](./src/otel/otel.md) — Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider cl…
|
||||
- [plivo](./src/plivo/plivo.md) — Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` auth…
|
||||
- [plivo](./src/plivo/plivo.md) — Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` auth…
|
||||
- [registry](./src/registry/registry.md) — `ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling p…
|
||||
- [registry](./src/registry/registry.md) — `ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling p…
|
||||
- [twilio](./src/twilio/twilio.md) — Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-enco…
|
||||
- [twilio](./src/twilio/twilio.md) — Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-enco…
|
||||
- [types](./src/types/provider.md) — Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `Hm…
|
||||
- [provider](./src/types/provider.md) — Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `Hm…
|
||||
- [vonage](./src/vonage/vonage.md) — Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `…
|
||||
- [vonage](./src/vonage/vonage.md) — Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Five real provider clients**, every one implementing the `SmsProvider` contract from `@suaveplan/sms`:
|
||||
- `TwilioProvider` — `POST /Messages.json`, HTTP Basic auth, MMS `MediaUrl[]`, X-Twilio-Signature HMAC-SHA1 webhook verifier.
|
||||
- `VonageProvider` — `POST /sms/json`, body auth, auto `type=unicode`, HMAC-SHA256 webhook verifier.
|
||||
- `MessageBirdProvider` — `AccessKey` auth, JSON body, JWT HS256 webhook verifier with `iat`/`exp`/`nbf` enforcement + optional `payload_hash` binding.
|
||||
- `PlivoProvider` — Basic auth, JSON body, X-Plivo-Signature-V3 HMAC-SHA256 verifier.
|
||||
- `AwsSnsProvider` — inline AWS SigV4-signed `Action=Publish`, RSA-SHA1 SNS notification verifier with injectable callback.
|
||||
- **`ProviderRegistry`** indexes provider instances by name; strict register/lookup, frozen `list()` snapshot.
|
||||
- **`SmsProvidersError`** discriminated subclass of `SuaveplanError` with eight namespaced codes (`SMS_PROVIDERS.HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `CONFIG_MISSING`, `SIGNATURE_INVALID`, `INVALID_NUMBER`, `PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED`).
|
||||
- **`parseOptOutKeyword`** universal CTIA/TCPA opt-in/opt-out/help keyword classifier.
|
||||
- **Five OTel instruments** — `send_total`, `webhook_total`, `signature_failures_total`, `send_duration_ms`, `webhook_verify_duration_ms`.
|
||||
- **Real `node:http` round-trip tests** per provider — no mocks; tests bind a server on a random port and inject a `FetchLike` pointing at it.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
AwsSnsProvider,
|
||||
MessageBirdProvider,
|
||||
PlivoProvider,
|
||||
ProviderRegistry,
|
||||
TwilioProvider,
|
||||
VonageProvider,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new TwilioProvider({ accountSid: "AC…", authToken: "…" }));
|
||||
registry.register(new VonageProvider({ apiKey: "k", apiSecret: "s" }));
|
||||
registry.register(new MessageBirdProvider({ accessKey: "K" }));
|
||||
registry.register(new PlivoProvider({ authId: "A", authToken: "T" }));
|
||||
registry.register(
|
||||
new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
})
|
||||
);
|
||||
|
||||
const twilio = registry.get("twilio");
|
||||
const report = await twilio.send({
|
||||
to: "+15551234567",
|
||||
from: "+15550000000",
|
||||
body: "Hello",
|
||||
});
|
||||
console.log(report.messageId, report.status);
|
||||
```
|
||||
|
||||
## Webhook verification
|
||||
|
||||
Every provider exposes a signature verifier matching its documented scheme:
|
||||
|
||||
```ts
|
||||
// Twilio HMAC-SHA1 over url + sorted form
|
||||
await twilio.verifyWebhookSignature(callbackUrl, params, signature);
|
||||
|
||||
// Vonage HMAC-SHA256 hex over sorted &key=value (excluding sig)
|
||||
await vonage.verifyWebhookSignature(params, sigHex, signatureSecret);
|
||||
|
||||
// MessageBird JWT HS256 with iat/exp/nbf window + payload_hash binding
|
||||
await mb.verifyWebhookSignature(jwt, signingKey, { nowMs, bodyHashHex });
|
||||
|
||||
// Plivo HMAC-SHA256 base64 over url + nonce + sorted key=value,...
|
||||
await plivo.verifyWebhookSignature(url, nonce, params, signature);
|
||||
|
||||
// AWS SNS RSA-SHA1 — pass an injectable verifier callback
|
||||
await sns.verifyNotification(payload, async (pem, msg, sigB64) => {
|
||||
/* createVerify("RSA-SHA1")…verify(...) */
|
||||
return true;
|
||||
});
|
||||
```
|
||||
|
||||
## Tier
|
||||
|
||||
`src/` ships zero `node:*` / `bun:*` / platform imports. Every HTTP call routes through an injectable `FetchLike`; every HMAC routes through an injectable `HmacSign` (defaulting to `globalThis.crypto.subtle`). Tests use real `node:http` + `node:crypto` from under `tests/setup/` and inside `*.test.ts` files only.
|
||||
|
||||
## Pre-publish gate
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test build --filter=@suaveplan/sms-providers
|
||||
bunx turbo test:coverage --filter=@suaveplan/sms-providers # 100/100/100/100
|
||||
```
|
||||
|
||||
88
packages/communication/messaging/sms-providers/package.json
Normal file
88
packages/communication/messaging/sms-providers/package.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "@suaveplan/sms-providers",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" },
|
||||
"require": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
|
||||
},
|
||||
"./http/http": {
|
||||
"import": { "types": "./dist/http/http.d.ts", "default": "./dist/http/http.js" },
|
||||
"require": { "types": "./dist/http/http.d.ts", "default": "./dist/http/http.js" }
|
||||
},
|
||||
"./registry/registry": {
|
||||
"import": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" },
|
||||
"require": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" }
|
||||
},
|
||||
"./twilio/twilio": {
|
||||
"import": { "types": "./dist/twilio/twilio.d.ts", "default": "./dist/twilio/twilio.js" },
|
||||
"require": { "types": "./dist/twilio/twilio.d.ts", "default": "./dist/twilio/twilio.js" }
|
||||
},
|
||||
"./vonage/vonage": {
|
||||
"import": { "types": "./dist/vonage/vonage.d.ts", "default": "./dist/vonage/vonage.js" },
|
||||
"require": { "types": "./dist/vonage/vonage.d.ts", "default": "./dist/vonage/vonage.js" }
|
||||
},
|
||||
"./messagebird/messagebird": {
|
||||
"import": { "types": "./dist/messagebird/messagebird.d.ts", "default": "./dist/messagebird/messagebird.js" },
|
||||
"require": { "types": "./dist/messagebird/messagebird.d.ts", "default": "./dist/messagebird/messagebird.js" }
|
||||
},
|
||||
"./plivo/plivo": {
|
||||
"import": { "types": "./dist/plivo/plivo.d.ts", "default": "./dist/plivo/plivo.js" },
|
||||
"require": { "types": "./dist/plivo/plivo.d.ts", "default": "./dist/plivo/plivo.js" }
|
||||
},
|
||||
"./aws-sns/aws-sns": {
|
||||
"import": { "types": "./dist/aws-sns/aws-sns.d.ts", "default": "./dist/aws-sns/aws-sns.js" },
|
||||
"require": { "types": "./dist/aws-sns/aws-sns.d.ts", "default": "./dist/aws-sns/aws-sns.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/sms": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "catalog:observability",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# AWS SNS — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the regional SNS endpoint (`https://sns.<region>.amazonaws.com/`) signed with inline AWS Signature V4 (see `./sigv4`); SMS-specific message attributes (`AWS.SNS.SMS.SMSType` and `AWS.SNS.SMS.SenderID`) are forwarded when supplied on the provider config. Inbound SNS HTTP/S notifications — subscription confirmation, delivery callbacks, unsubscribe confirmation — are validated through `verifyNotification` which delegates the RSA-SHA1 verification step to an injectable callback so this package never imports `node:crypto`.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- Inline AWS Signature V4 signing (no `@aws-sdk` dep tree).
|
||||
- `AWS.SNS.SMS.SMSType` defaults to `Transactional`; override via `config.smsType: "Promotional"`.
|
||||
- `AWS.SNS.SMS.SenderID` forwarded from the message `from` or the config `senderId`.
|
||||
- Optional `sessionToken` for STS-issued credentials (`X-Amz-Security-Token`).
|
||||
- Status mapping covers `pending` / `success` / `failure` / `optout`.
|
||||
- `verifyNotification(payload, rsaVerify)` validates the documented SNS signing chain — fetches the signing cert URL through the injectable fetch, builds the canonical string-to-sign (different field order for `Notification` vs `SubscriptionConfirmation`/`UnsubscribeConfirmation`), and delegates the RSA verification to the supplied callback.
|
||||
- `parseInbound` discriminates between delivery-receipt notifications (Subject = `"AWS SMS Delivery Receipt"`) and inbound mobile-originated SMS payloads; recognises CTIA opt-out keywords on the inbound body.
|
||||
- OTel — counter + histogram on every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AwsSnsProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const sns = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
senderId: "SUAVEPLAN",
|
||||
});
|
||||
const r = await sns.send({ to: "+15551234567", body: "Hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AwsSnsProvider } from "@suaveplan/sms-providers";
|
||||
import { createVerify } from "node:crypto";
|
||||
|
||||
const sns = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
sessionToken: "FwoGZXIvYXdz…",
|
||||
smsType: "Transactional",
|
||||
});
|
||||
|
||||
// Node-side RSA-SHA1 verifier callback.
|
||||
const rsaVerify = async (pem: string, msg: string, sigB64: string) =>
|
||||
createVerify("RSA-SHA1").update(msg).end().verify(pem, sigB64, "base64");
|
||||
|
||||
const ok = await sns.verifyNotification(
|
||||
/* payload */ {
|
||||
Type: "Notification",
|
||||
MessageId: "abc",
|
||||
TopicArn: "arn:aws:sns:…",
|
||||
Subject: "AWS SMS Delivery Receipt",
|
||||
Message: JSON.stringify({ status: "SUCCESS" }),
|
||||
Timestamp: new Date().toISOString(),
|
||||
Signature: "<base64>",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL:
|
||||
"https://sns.us-east-1.amazonaws.com/SimpleNotificationService-XXX.pem",
|
||||
},
|
||||
rsaVerify
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `AwsSnsProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | SigV4-signed POST `Action=Publish`. |
|
||||
| `getStatus(id)` | method | Returns a synthetic `queued` report (SNS does not expose per-message status). |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyNotification(payload, rsaVerify)` | method | Delegates RSA-SHA1 verification to the supplied callback. |
|
||||
| `parseInbound(payload)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
| `buildSnsStringToSign(payload)` | helper | Exported for advanced use. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getStatus` deliberately returns a synthetic `queued` report rather than synthesise a fake `delivered` — SNS reports SMS lifecycle exclusively via the configured delivery-status SNS topic (typically wired into CloudWatch Logs in production), and lying about provider behaviour would mislead consumers. The recommended production pipeline is to subscribe to the delivery-status topic, route the notifications back through the provider's `parseInbound`, and feed the resulting `delivery_receipt` events to `@suaveplan/sms`'s `DeliveryTracker`. The `senderId` config overrides the per-message `from`; this matches the AWS console UX where the account-level Sender ID is the production default.
|
||||
@@ -0,0 +1,522 @@
|
||||
import { createVerify } from "node:crypto";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "../../tests/setup/http-server.js";
|
||||
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
||||
import { AwsSnsProvider, buildSnsStringToSign } from "./aws-sns.js";
|
||||
|
||||
let server!: RecordingServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function makeProvider(
|
||||
config: {
|
||||
region?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
sessionToken?: string;
|
||||
senderId?: string;
|
||||
now?: () => Date;
|
||||
smsType?: "Transactional" | "Promotional";
|
||||
} = {}
|
||||
): AwsSnsProvider {
|
||||
return new AwsSnsProvider({
|
||||
region: config.region ?? "us-east-1",
|
||||
accessKeyId: config.accessKeyId ?? "AKIAEXAMPLE",
|
||||
secretAccessKey: config.secretAccessKey ?? "SECRETKEY",
|
||||
...(config.sessionToken !== undefined
|
||||
? { sessionToken: config.sessionToken }
|
||||
: {}),
|
||||
...(config.senderId !== undefined ? { senderId: config.senderId } : {}),
|
||||
...(config.smsType !== undefined ? { smsType: config.smsType } : {}),
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: config.now ?? ((): Date => new Date("2026-04-24T10:00:00Z")),
|
||||
});
|
||||
}
|
||||
|
||||
const PUBLISH_XML = `<?xml version="1.0"?>
|
||||
<PublishResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
||||
<PublishResult><MessageId>sns-msg-1</MessageId></PublishResult>
|
||||
<ResponseMetadata><RequestId>req-1</RequestId></ResponseMetadata>
|
||||
</PublishResponse>`;
|
||||
|
||||
describe("AwsSnsProvider — construction", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/xml" });
|
||||
res.end(PUBLISH_XML);
|
||||
});
|
||||
});
|
||||
|
||||
it("requires region + accessKeyId + secretAccessKey", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => makeProvider({ region: "" })).toThrow();
|
||||
expect(() => makeProvider({ accessKeyId: "" })).toThrow();
|
||||
expect(() => makeProvider({ secretAccessKey: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("exposes name 'aws-sns'", { timeout: 5000 }, () => {
|
||||
expect(makeProvider().name).toBe("aws-sns");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AwsSnsProvider — send", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/xml" });
|
||||
res.end(PUBLISH_XML);
|
||||
});
|
||||
});
|
||||
|
||||
it("POSTs a SigV4-signed Action=Publish form to sns.<region>.amazonaws.com", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider({ senderId: "SUAVEPLAN" });
|
||||
const r = await p.send({
|
||||
to: "+15551234567",
|
||||
body: "Hello",
|
||||
});
|
||||
const req = server.requests[0]!;
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/");
|
||||
expect(req.body).toContain("Action=Publish");
|
||||
expect(req.body).toContain("PhoneNumber=%2B15551234567");
|
||||
expect(req.body).toContain("Message=Hello");
|
||||
expect(req.body).toContain("AWS.SNS.SMS.SMSType");
|
||||
expect(req.body).toContain("Transactional");
|
||||
expect(req.body).toContain("AWS.SNS.SMS.SenderID");
|
||||
expect(req.body).toContain("SUAVEPLAN");
|
||||
// SigV4 shape on Authorization header.
|
||||
const auth = String(req.headers.authorization ?? "");
|
||||
expect(auth).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE\//);
|
||||
expect(auth).toContain("SignedHeaders=");
|
||||
expect(auth).toMatch(/Signature=[0-9a-f]{64}$/);
|
||||
expect(req.headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/);
|
||||
expect(req.headers["x-amz-content-sha256"]).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(r.messageId).toBe("sns-msg-1");
|
||||
expect(r.status).toBe("sent");
|
||||
expect(r.to).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("forwards X-Amz-Security-Token when sessionToken is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider({ sessionToken: "tok-123" });
|
||||
await p.send({ to: "+1", body: "hi" });
|
||||
expect(server.requests[0]!.headers["x-amz-security-token"]).toBe(
|
||||
"tok-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Promotional SMSType when configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider({ smsType: "Promotional" });
|
||||
await p.send({ to: "+1", body: "hi" });
|
||||
expect(server.requests[0]!.body).toContain("Promotional");
|
||||
});
|
||||
|
||||
it("falls back to message.from for SenderID when senderId is unset", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({ to: "+1", from: "FROMSENDER", body: "hi" });
|
||||
expect(server.requests[0]!.body).toContain("FROMSENDER");
|
||||
});
|
||||
|
||||
it("omits the SenderID attribute when neither senderId nor from is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({ to: "+1", body: "hi" });
|
||||
expect(server.requests[0]!.body).not.toContain("AWS.SNS.SMS.SenderID");
|
||||
});
|
||||
|
||||
it("throws when the SNS response lacks a MessageId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/xml" });
|
||||
res.end("<PublishResponse><PublishResult/></PublishResponse>");
|
||||
});
|
||||
const p = makeProvider();
|
||||
await expect(p.send({ to: "+1", body: "hi" })).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a JSON-shaped SNS Publish response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ MessageId: "json-msg-1" })
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+1", body: "hi" });
|
||||
expect(r.messageId).toBe("json-msg-1");
|
||||
});
|
||||
|
||||
it("throws on a non-2xx response", { timeout: 5000 }, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ message: "InvalidParameter" }, 400)
|
||||
);
|
||||
const p = makeProvider();
|
||||
await expect(p.send({ to: "+1", body: "hi" })).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
httpStatus: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AwsSnsProvider — getStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("returns a synthetic queued report (SNS has no per-message API)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.getStatus("anything");
|
||||
expect(r.status).toBe("queued");
|
||||
expect(r.messageId).toBe("anything");
|
||||
expect(r.to).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AwsSnsProvider — mapStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pending", "queued"],
|
||||
["success", "delivered"],
|
||||
["failure", "failed"],
|
||||
["optout", "undelivered"],
|
||||
["nope", "failed"],
|
||||
])("%s → %s", { timeout: 5000 }, (raw, mapped) => {
|
||||
expect(makeProvider().mapStatus(raw)).toBe(mapped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AwsSnsProvider — parseInbound", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("identifies an SMS delivery receipt notification", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
const evt = p.parseInbound({
|
||||
Type: "Notification",
|
||||
MessageId: "msg1",
|
||||
Subject: "AWS SMS Delivery Receipt",
|
||||
Message: JSON.stringify({ status: "SUCCESS", errorCode: "0" }),
|
||||
Timestamp: "2026-04-24T10:00:00Z",
|
||||
Signature: "...",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "https://x/cert.pem",
|
||||
});
|
||||
expect(evt).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "msg1",
|
||||
status: "delivered",
|
||||
errorCode: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies STOP/YES/HELP and message_received from inbound MO SMS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
const payload = (body: Record<string, unknown>) => ({
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: JSON.stringify(body),
|
||||
Timestamp: "t",
|
||||
Signature: "s",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "u",
|
||||
});
|
||||
expect(
|
||||
p.parseInbound(
|
||||
payload({ originationNumber: "+1", messageBody: "stop" })
|
||||
)
|
||||
).toMatchObject({ type: "opt_out" });
|
||||
expect(
|
||||
p.parseInbound(
|
||||
payload({ originationNumber: "+1", messageBody: "YES" })
|
||||
)
|
||||
).toMatchObject({ type: "opt_in" });
|
||||
expect(
|
||||
p.parseInbound(
|
||||
payload({ originationNumber: "+1", messageBody: "HELP" })
|
||||
)
|
||||
).toMatchObject({ type: "help" });
|
||||
expect(
|
||||
p.parseInbound(
|
||||
payload({
|
||||
originationNumber: "+1",
|
||||
destinationNumber: "+2",
|
||||
messageBody: "free form",
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "free form",
|
||||
providerId: "id",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to empty fields when the JSON Message is unparseable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
expect(
|
||||
p.parseInbound({
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: "not json",
|
||||
Timestamp: "t",
|
||||
Signature: "s",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "u",
|
||||
})
|
||||
).toMatchObject({
|
||||
type: "message_received",
|
||||
from: "",
|
||||
to: "",
|
||||
body: "",
|
||||
providerId: "id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AwsSnsProvider — buildSnsStringToSign", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("orders fields for Notification per the SNS spec", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = buildSnsStringToSign({
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
TopicArn: "arn",
|
||||
Subject: "sub",
|
||||
Message: "msg",
|
||||
Timestamp: "t",
|
||||
Signature: "sig",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "url",
|
||||
});
|
||||
expect(s).toBe(
|
||||
[
|
||||
"Message",
|
||||
"msg",
|
||||
"MessageId",
|
||||
"id",
|
||||
"Subject",
|
||||
"sub",
|
||||
"Timestamp",
|
||||
"t",
|
||||
"TopicArn",
|
||||
"arn",
|
||||
"Type",
|
||||
"Notification",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
it("orders fields for SubscriptionConfirmation per the SNS spec", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = buildSnsStringToSign({
|
||||
Type: "SubscriptionConfirmation",
|
||||
MessageId: "id",
|
||||
TopicArn: "arn",
|
||||
Message: "msg",
|
||||
Timestamp: "t",
|
||||
Token: "tok",
|
||||
SubscribeURL: "sub",
|
||||
Signature: "sig",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "url",
|
||||
});
|
||||
expect(s).toBe(
|
||||
[
|
||||
"Message",
|
||||
"msg",
|
||||
"MessageId",
|
||||
"id",
|
||||
"SubscribeURL",
|
||||
"sub",
|
||||
"Timestamp",
|
||||
"t",
|
||||
"Token",
|
||||
"tok",
|
||||
"TopicArn",
|
||||
"arn",
|
||||
"Type",
|
||||
"SubscriptionConfirmation",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
it("skips undefined fields", { timeout: 5000 }, () => {
|
||||
const s = buildSnsStringToSign({
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: "msg",
|
||||
Timestamp: "t",
|
||||
Signature: "sig",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "url",
|
||||
});
|
||||
// No Subject/TopicArn lines.
|
||||
expect(s).not.toContain("Subject");
|
||||
expect(s).not.toContain("TopicArn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AwsSnsProvider — verifyNotification", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/x-pem-file" });
|
||||
res.end(
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates to the injected rsaVerify callback", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: Array<{ pem: string; msg: string; sig: string }> = [];
|
||||
const rsaVerify = async (
|
||||
pem: string,
|
||||
msg: string,
|
||||
sig: string
|
||||
): Promise<boolean> => {
|
||||
calls.push({ pem, msg, sig });
|
||||
return true;
|
||||
};
|
||||
const p = makeProvider();
|
||||
const ok = await p.verifyNotification(
|
||||
{
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: "m",
|
||||
Timestamp: "t",
|
||||
Signature: "sig123",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "https://sns/cert.pem",
|
||||
},
|
||||
rsaVerify
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.sig).toBe("sig123");
|
||||
expect(calls[0]!.pem).toContain("BEGIN CERTIFICATE");
|
||||
});
|
||||
|
||||
it("throws HTTP_ERROR when the cert URL fails to fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ message: "x" }, 404)
|
||||
);
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.verifyNotification(
|
||||
{
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: "m",
|
||||
Timestamp: "t",
|
||||
Signature: "sig",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "https://sns/cert.pem",
|
||||
},
|
||||
async () => true
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws PARSE_ERROR when the cert URL returns non-PEM", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("not a cert");
|
||||
});
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.verifyNotification(
|
||||
{
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: "m",
|
||||
Timestamp: "t",
|
||||
Signature: "sig",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "https://sns/cert.pem",
|
||||
},
|
||||
async () => true
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false when the rsaVerify callback rejects the signature", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const ok = await p.verifyNotification(
|
||||
{
|
||||
Type: "Notification",
|
||||
MessageId: "id",
|
||||
Message: "m",
|
||||
Timestamp: "t",
|
||||
Signature: "sig",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL: "https://sns/cert.pem",
|
||||
},
|
||||
async () => false
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Touch the unused node:crypto import via a no-op describe so the
|
||||
// module reference stays exercised on CI; createVerify is used in the
|
||||
// usage example only.
|
||||
void createVerify;
|
||||
@@ -0,0 +1,382 @@
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsSendCallOptions,
|
||||
} from "@suaveplan/sms";
|
||||
import { defaultHmac } from "../crypto/default-hmac.js";
|
||||
import {
|
||||
configMissing,
|
||||
httpError,
|
||||
parseError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { httpRequest } from "../http/http.js";
|
||||
import { parseOptOutKeyword } from "../keywords/keywords.js";
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordWebhookVerifyDuration,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
AwsSnsConfig,
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
} from "../types/provider.js";
|
||||
import { sha256OfUtf8, signRequest } from "./sigv4.js";
|
||||
|
||||
const PROVIDER = "aws-sns" as const;
|
||||
|
||||
const STATUS_MAP: Readonly<Record<string, SmsDeliveryStatus>> = Object.freeze({
|
||||
pending: "queued",
|
||||
success: "delivered",
|
||||
failure: "failed",
|
||||
optout: "undelivered",
|
||||
});
|
||||
|
||||
interface SnsPublishResponse {
|
||||
readonly raw?: string;
|
||||
readonly MessageId?: string;
|
||||
}
|
||||
|
||||
interface SnsNotificationPayload {
|
||||
readonly Type: string;
|
||||
readonly MessageId: string;
|
||||
readonly TopicArn?: string;
|
||||
readonly Subject?: string;
|
||||
readonly Message: string;
|
||||
readonly Timestamp: string;
|
||||
readonly Signature: string;
|
||||
readonly SignatureVersion: string;
|
||||
readonly SigningCertURL: string;
|
||||
readonly UnsubscribeURL?: string;
|
||||
readonly Token?: string;
|
||||
readonly SubscribeURL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real AWS SNS SMS provider. POSTs `Action=Publish` form bodies to the
|
||||
* regional SNS endpoint (`https://sns.<region>.amazonaws.com/`) signed
|
||||
* with inline AWS Signature V4 (see `./sigv4`). SMS-specific message
|
||||
* attributes are forwarded when supplied on the provider config.
|
||||
* Inbound SNS HTTP/S notifications (subscription confirmation, delivery
|
||||
* callbacks, unsubscribe confirmation) are validated through
|
||||
* `verifyNotification` which delegates RSA-SHA1 verification to an
|
||||
* injectable callback.
|
||||
*/
|
||||
export class AwsSnsProvider implements SmsProvider {
|
||||
public readonly name = PROVIDER;
|
||||
private readonly config: AwsSnsConfig;
|
||||
private readonly baseUrl: string;
|
||||
private readonly host: string;
|
||||
private readonly hmac: HmacSign;
|
||||
private readonly fetch: FetchLike | undefined;
|
||||
private readonly now: () => Date;
|
||||
|
||||
public constructor(config: AwsSnsConfig) {
|
||||
if (!config.region) {
|
||||
throw configMissing(PROVIDER, "region");
|
||||
}
|
||||
if (!config.accessKeyId) {
|
||||
throw configMissing(PROVIDER, "accessKeyId");
|
||||
}
|
||||
if (!config.secretAccessKey) {
|
||||
throw configMissing(PROVIDER, "secretAccessKey");
|
||||
}
|
||||
this.config = config;
|
||||
this.host = `sns.${config.region}.amazonaws.com`;
|
||||
this.baseUrl = (config.baseUrl ?? `https://${this.host}`).replace(
|
||||
/\/$/,
|
||||
""
|
||||
);
|
||||
this.hmac = config.hmac ?? defaultHmac;
|
||||
this.fetch = config.fetch;
|
||||
this.now = config.now ?? ((): Date => new Date());
|
||||
}
|
||||
|
||||
public mapStatus(raw: string): SmsDeliveryStatus {
|
||||
return STATUS_MAP[raw.toLowerCase()] ?? "failed";
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendCallOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("Action", "Publish");
|
||||
params.set("Version", "2010-03-31");
|
||||
params.set("PhoneNumber", message.to);
|
||||
params.set("Message", message.body);
|
||||
const smsType = this.config.smsType ?? "Transactional";
|
||||
let i = 1;
|
||||
params.set(
|
||||
`MessageAttributes.entry.${i}.Name`,
|
||||
"AWS.SNS.SMS.SMSType"
|
||||
);
|
||||
params.set(`MessageAttributes.entry.${i}.Value.DataType`, "String");
|
||||
params.set(
|
||||
`MessageAttributes.entry.${i}.Value.StringValue`,
|
||||
smsType
|
||||
);
|
||||
i += 1;
|
||||
const senderId = this.config.senderId ?? message.from;
|
||||
if (senderId !== undefined) {
|
||||
params.set(
|
||||
`MessageAttributes.entry.${i}.Name`,
|
||||
"AWS.SNS.SMS.SenderID"
|
||||
);
|
||||
params.set(
|
||||
`MessageAttributes.entry.${i}.Value.DataType`,
|
||||
"String"
|
||||
);
|
||||
params.set(
|
||||
`MessageAttributes.entry.${i}.Value.StringValue`,
|
||||
senderId
|
||||
);
|
||||
}
|
||||
const body = params.toString();
|
||||
const signed = await signRequest({
|
||||
accessKeyId: this.config.accessKeyId,
|
||||
secretAccessKey: this.config.secretAccessKey,
|
||||
...(this.config.sessionToken !== undefined
|
||||
? { sessionToken: this.config.sessionToken }
|
||||
: {}),
|
||||
region: this.config.region,
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: this.host,
|
||||
path: "/",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
bodySha256Hex: await sha256OfUtf8(body),
|
||||
now: this.now(),
|
||||
hmac: this.hmac,
|
||||
});
|
||||
const res = await httpRequest<SnsPublishResponse | string>({
|
||||
method: "POST",
|
||||
url: `${this.baseUrl}/`,
|
||||
headers: signed.headers,
|
||||
body,
|
||||
provider: PROVIDER,
|
||||
...this.common(options.signal),
|
||||
});
|
||||
const messageId = extractSnsMessageId(res.data);
|
||||
if (messageId === undefined) {
|
||||
throw httpError(
|
||||
PROVIDER,
|
||||
res.status,
|
||||
`${this.baseUrl}/`,
|
||||
typeof res.data === "string"
|
||||
? res.data
|
||||
: JSON.stringify(res.data),
|
||||
"SNS Publish response missing MessageId"
|
||||
);
|
||||
}
|
||||
recordSend(PROVIDER, "ok");
|
||||
return {
|
||||
messageId,
|
||||
to: message.to,
|
||||
status: "sent",
|
||||
timestamp: this.now(),
|
||||
};
|
||||
} catch (err) {
|
||||
recordSend(PROVIDER, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
recordSendDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AWS SNS does not expose a per-message status API for SMS — the
|
||||
* lifecycle is reported via the configured SMS delivery-status
|
||||
* SNS topic. Returns a synthetic `queued` report so the
|
||||
* `SmsProvider` contract is honoured; production consumers should
|
||||
* subscribe to the delivery topic and feed `parseInbound`.
|
||||
*/
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
return {
|
||||
messageId,
|
||||
to: "",
|
||||
status: "queued",
|
||||
timestamp: this.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an inbound SNS notification. The canonical signing string
|
||||
* is built from the documented field order; RSA-SHA1 verification
|
||||
* is delegated to an injectable `rsaVerify` callback so this
|
||||
* universal package never imports `node:crypto`.
|
||||
*/
|
||||
public async verifyNotification(
|
||||
payload: SnsNotificationPayload,
|
||||
rsaVerify: (
|
||||
pem: string,
|
||||
stringToSign: string,
|
||||
signatureBase64: string
|
||||
) => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const stringToSign = buildSnsStringToSign(payload);
|
||||
const pem = await fetchPem(payload.SigningCertURL, this.fetch);
|
||||
return await rsaVerify(pem, stringToSign, payload.Signature);
|
||||
} finally {
|
||||
recordWebhookVerifyDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse an inbound SNS HTTP/S notification payload. */
|
||||
public parseInbound(payload: SnsNotificationPayload): InboundSmsEvent {
|
||||
if (
|
||||
payload.Type === "Notification" &&
|
||||
payload.Subject === "AWS SMS Delivery Receipt"
|
||||
) {
|
||||
const parsed = safeJsonObject(payload.Message);
|
||||
const status = String(parsed["status"] ?? "");
|
||||
return {
|
||||
type: "delivery_receipt",
|
||||
messageId: payload.MessageId,
|
||||
status: this.mapStatus(status),
|
||||
...(typeof parsed["errorCode"] === "string"
|
||||
? { errorCode: parsed["errorCode"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const parsed = safeJsonObject(payload.Message);
|
||||
const from = String(parsed["originationNumber"] ?? "");
|
||||
const to = String(parsed["destinationNumber"] ?? "");
|
||||
const body = String(parsed["messageBody"] ?? "");
|
||||
const keyword = parseOptOutKeyword(body);
|
||||
if (keyword === "opt_out") {
|
||||
return { type: "opt_out", from };
|
||||
}
|
||||
if (keyword === "opt_in") {
|
||||
return { type: "opt_in", from };
|
||||
}
|
||||
if (keyword === "help") {
|
||||
return { type: "help", from };
|
||||
}
|
||||
return {
|
||||
type: "message_received",
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
providerId: payload.MessageId,
|
||||
};
|
||||
}
|
||||
|
||||
private common(signal: AbortSignal | undefined): {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} {
|
||||
const out: {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} = {};
|
||||
if (this.fetch !== undefined) {
|
||||
out.fetch = this.fetch;
|
||||
}
|
||||
const s = signal ?? this.config.signal;
|
||||
if (s !== undefined) {
|
||||
out.signal = s;
|
||||
}
|
||||
if (this.config.timeoutMs !== undefined) {
|
||||
out.timeoutMs = this.config.timeoutMs;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSnsMessageId(
|
||||
data: SnsPublishResponse | string | unknown
|
||||
): string | undefined {
|
||||
if (typeof data === "string") {
|
||||
const m = /<MessageId>([^<]+)<\/MessageId>/.exec(data);
|
||||
return m?.[1];
|
||||
}
|
||||
if (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"MessageId" in (data as Record<string, unknown>)
|
||||
) {
|
||||
const v = (data as Record<string, unknown>)["MessageId"];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function safeJsonObject(input: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(input) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SNS notification string-to-sign per
|
||||
* https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
|
||||
* The canonical key order differs by `Type`.
|
||||
*/
|
||||
export function buildSnsStringToSign(payload: SnsNotificationPayload): string {
|
||||
const fields: Array<[string, string | undefined]> =
|
||||
payload.Type === "Notification"
|
||||
? [
|
||||
["Message", payload.Message],
|
||||
["MessageId", payload.MessageId],
|
||||
["Subject", payload.Subject],
|
||||
["Timestamp", payload.Timestamp],
|
||||
["TopicArn", payload.TopicArn],
|
||||
["Type", payload.Type],
|
||||
]
|
||||
: [
|
||||
["Message", payload.Message],
|
||||
["MessageId", payload.MessageId],
|
||||
["SubscribeURL", payload.SubscribeURL],
|
||||
["Timestamp", payload.Timestamp],
|
||||
["Token", payload.Token],
|
||||
["TopicArn", payload.TopicArn],
|
||||
["Type", payload.Type],
|
||||
];
|
||||
let out = "";
|
||||
for (const [key, value] of fields) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
out += `${key}\n${value}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchPem(
|
||||
url: string,
|
||||
fetchImpl: FetchLike | undefined
|
||||
): Promise<string> {
|
||||
const f: FetchLike = fetchImpl ?? globalThis.fetch.bind(globalThis);
|
||||
const res = await f(url);
|
||||
if (!res.ok) {
|
||||
throw httpError(
|
||||
PROVIDER,
|
||||
res.status,
|
||||
url,
|
||||
await res.text(),
|
||||
"SNS signing cert fetch failed"
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text.includes("BEGIN CERTIFICATE")) {
|
||||
throw parseError(PROVIDER, url, text, new Error("not a PEM cert"));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AwsSnsProvider, buildSnsStringToSign } from "./aws-sns.js";
|
||||
export type { SignRequestInput, SignRequestOutput } from "./sigv4.js";
|
||||
export { sha256OfUtf8, signRequest } from "./sigv4.js";
|
||||
@@ -0,0 +1,80 @@
|
||||
# SigV4 — `@suaveplan/sms-providers`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB) with an under-80-LOC implementation of the canonical-request → string-to-sign → signing-key → final-signature chain. HMAC computations are delegated to the injectable `HmacSign` seam from `@suaveplan/sms-providers`, so the signer stays universal and tests can substitute a deterministic adapter.
|
||||
|
||||
The signer returns a fresh headers object containing the input headers plus `Host`, `X-Amz-Date`, `X-Amz-Content-Sha256`, the optional `X-Amz-Security-Token` (when a session token is supplied), and the `Authorization` header in the documented form `AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=<64 hex>`.
|
||||
|
||||
## Features
|
||||
|
||||
- 64-character lowercase-hex signature per the AWS spec.
|
||||
- Canonical request built from method + path + canonical headers (lower-cased keys, trimmed values) + signed-headers list + body-sha256 hex.
|
||||
- `dateStamp` (`YYYYMMDD`) and `amzDate` (`YYYYMMDDTHHMMSSZ`) derived from a caller-supplied `Date` so tests can pin the clock and produce stable signatures.
|
||||
- Signing key chain: `kDate → kRegion → kService → kSigning` via HMAC-SHA256 over `AWS4<secret>`, `region`, `service`, `aws4_request`.
|
||||
- Body SHA-256 hashing routed through `globalThis.crypto.subtle.digest("SHA-256", …)` (universal: Bun, browsers, Workers, Deno, Node 19+).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultHmac } from "@suaveplan/sms-providers";
|
||||
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
|
||||
|
||||
const body = "Action=Publish&Message=hi";
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
bodySha256Hex: await sha256OfUtf8(body),
|
||||
now: new Date(),
|
||||
hmac: defaultHmac,
|
||||
});
|
||||
// out.headers.Authorization === "AWS4-HMAC-SHA256 Credential=AKIA…/<dateStamp>/us-east-1/sns/aws4_request, SignedHeaders=…, Signature=<64 hex>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Drive the signer with a Node-side HMAC adapter and a pinned date so
|
||||
// the resulting Authorization header is byte-stable between test runs.
|
||||
const hmac = async (algo: "SHA-1" | "SHA-256", key: Uint8Array, msg: Uint8Array) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIDEXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
bodySha256Hex: await sha256OfUtf8("Action=Publish"),
|
||||
now: new Date("2026-04-24T10:00:00Z"),
|
||||
hmac,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `signRequest(input)` | function | Returns `{ headers }` with the SigV4 signature applied. |
|
||||
| `sha256OfUtf8(s)` | function | SHA-256-of-UTF-8 helper returning lowercase hex. |
|
||||
| `SignRequestInput` | interface | Required inputs to the signer. |
|
||||
| `SignRequestOutput` | interface | Returned headers object. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical-request newline before the body-sha256 is intentional per the AWS docs (the canonical request ends with `\n<body-sha256>`, no trailing newline). The signed-headers list is computed by lower-casing every input key, sorting, and joining with `;` — this matches the canonical-request header rendering exactly.
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultHmac } from "../crypto/default-hmac.js";
|
||||
import type { HmacSign } from "../types/provider.js";
|
||||
import { sha256OfUtf8, signRequest } from "./sigv4.js";
|
||||
|
||||
const NODE_HMAC: HmacSign = async (algo, key, msg) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
|
||||
describe("signRequest", () => {
|
||||
it("produces an AWS4-HMAC-SHA256 Authorization header with all required components", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const body = "Action=Publish&Message=hi";
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "SECRET",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
bodySha256Hex: await sha256OfUtf8(body),
|
||||
now: new Date("2026-04-24T10:00:00Z"),
|
||||
hmac: defaultHmac,
|
||||
});
|
||||
expect(out.headers.Authorization).toMatch(
|
||||
/^AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE\/20260424\/us-east-1\/sns\/aws4_request, SignedHeaders=[a-z0-9;-]+, Signature=[0-9a-f]{64}$/
|
||||
);
|
||||
expect(out.headers["X-Amz-Date"]).toBe("20260424T100000Z");
|
||||
expect(out.headers["X-Amz-Content-Sha256"]).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(out.headers.Host).toBe("sns.us-east-1.amazonaws.com");
|
||||
});
|
||||
|
||||
it("forwards X-Amz-Security-Token when a sessionToken is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const out = await signRequest({
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
sessionToken: "tok-xyz",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: {},
|
||||
bodySha256Hex: await sha256OfUtf8(""),
|
||||
now: new Date("2026-01-01T00:00:00Z"),
|
||||
hmac: defaultHmac,
|
||||
});
|
||||
expect(out.headers["X-Amz-Security-Token"]).toBe("tok-xyz");
|
||||
});
|
||||
|
||||
it("produces a stable signature for a pinned input across HMAC backends", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const input = {
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "SECRET",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
bodySha256Hex: await sha256OfUtf8("Action=Publish"),
|
||||
now: new Date("2026-04-24T10:00:00Z"),
|
||||
};
|
||||
const a = await signRequest({ ...input, hmac: defaultHmac });
|
||||
const b = await signRequest({ ...input, hmac: NODE_HMAC });
|
||||
expect(a.headers.Authorization).toBe(b.headers.Authorization);
|
||||
});
|
||||
|
||||
it("defaults an empty path to '/'", { timeout: 5000 }, async () => {
|
||||
const out = await signRequest({
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "",
|
||||
headers: {},
|
||||
bodySha256Hex: await sha256OfUtf8(""),
|
||||
now: new Date("2026-04-24T10:00:00Z"),
|
||||
hmac: defaultHmac,
|
||||
});
|
||||
// The signature differs whenever the canonical path differs;
|
||||
// we assert structure rather than recompute the expected sig.
|
||||
expect(out.headers.Authorization).toContain("Signature=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sha256OfUtf8", () => {
|
||||
it("matches the documented SHA-256 of an empty string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
expect(await sha256OfUtf8("")).toBe(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the documented SHA-256 of 'abc'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
expect(await sha256OfUtf8("abc")).toBe(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Inline AWS Signature V4 signer for the AWS SNS provider. Avoids the
|
||||
* `@aws-sdk/signature-v4` dep (15+ MB tree) by computing the canonical
|
||||
* request → string-to-sign → signing key → final signature chain in
|
||||
* under 80 LOC. HMAC is delegated to the injectable `HmacSign` seam so
|
||||
* the implementation stays universal.
|
||||
*/
|
||||
import { toHex, utf8 } from "../crypto/default-hmac.js";
|
||||
import type { HmacSign } from "../types/provider.js";
|
||||
|
||||
export interface SignRequestInput {
|
||||
readonly accessKeyId: string;
|
||||
readonly secretAccessKey: string;
|
||||
readonly sessionToken?: string;
|
||||
readonly region: string;
|
||||
readonly service: string;
|
||||
readonly method: string;
|
||||
readonly host: string;
|
||||
readonly path: string;
|
||||
readonly headers: Record<string, string>;
|
||||
readonly bodySha256Hex: string;
|
||||
readonly now: Date;
|
||||
readonly hmac: HmacSign;
|
||||
}
|
||||
|
||||
export interface SignRequestOutput {
|
||||
readonly headers: Record<string, string>;
|
||||
}
|
||||
|
||||
function fmt(date: Date): { amzDate: string; dateStamp: string } {
|
||||
const iso = date
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d{3}/, "");
|
||||
// iso === YYYYMMDDTHHMMSSZ
|
||||
return { amzDate: iso, dateStamp: iso.slice(0, 8) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SigV4-signed header set for the supplied request. The
|
||||
* function returns a fresh headers object containing the inputs plus
|
||||
* `Host`, `X-Amz-Date`, `X-Amz-Content-Sha256`, optionally
|
||||
* `X-Amz-Security-Token`, and `Authorization`.
|
||||
*/
|
||||
export async function signRequest(
|
||||
input: SignRequestInput
|
||||
): Promise<SignRequestOutput> {
|
||||
const { amzDate, dateStamp } = fmt(input.now);
|
||||
const headers: Record<string, string> = {
|
||||
...input.headers,
|
||||
Host: input.host,
|
||||
"X-Amz-Date": amzDate,
|
||||
"X-Amz-Content-Sha256": input.bodySha256Hex,
|
||||
};
|
||||
if (input.sessionToken !== undefined) {
|
||||
headers["X-Amz-Security-Token"] = input.sessionToken;
|
||||
}
|
||||
|
||||
// Canonical headers + signed headers.
|
||||
const sortedHeaderKeys = Object.keys(headers)
|
||||
.map((k) => k.toLowerCase())
|
||||
.sort();
|
||||
const lcHeaders: Record<string, string> = {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
lcHeaders[k.toLowerCase()] = String(headers[k]).trim();
|
||||
}
|
||||
const canonicalHeaders = sortedHeaderKeys
|
||||
.map((k) => `${k}:${lcHeaders[k]}\n`)
|
||||
.join("");
|
||||
const signedHeaders = sortedHeaderKeys.join(";");
|
||||
|
||||
const canonicalRequest = [
|
||||
input.method,
|
||||
input.path === "" ? "/" : input.path,
|
||||
"", // query string
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
input.bodySha256Hex,
|
||||
].join("\n");
|
||||
|
||||
const credentialScope = `${dateStamp}/${input.region}/${input.service}/aws4_request`;
|
||||
const stringToSign = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
credentialScope,
|
||||
await sha256Hex(canonicalRequest),
|
||||
].join("\n");
|
||||
|
||||
// Signing key chain.
|
||||
const kDate = await input.hmac(
|
||||
"SHA-256",
|
||||
utf8(`AWS4${input.secretAccessKey}`),
|
||||
utf8(dateStamp)
|
||||
);
|
||||
const kRegion = await input.hmac("SHA-256", kDate, utf8(input.region));
|
||||
const kService = await input.hmac("SHA-256", kRegion, utf8(input.service));
|
||||
const kSigning = await input.hmac(
|
||||
"SHA-256",
|
||||
kService,
|
||||
utf8("aws4_request")
|
||||
);
|
||||
const signature = toHex(
|
||||
await input.hmac("SHA-256", kSigning, utf8(stringToSign))
|
||||
);
|
||||
|
||||
headers["Authorization"] =
|
||||
"AWS4-HMAC-SHA256 " +
|
||||
`Credential=${input.accessKeyId}/${credentialScope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, ` +
|
||||
`Signature=${signature}`;
|
||||
return { headers };
|
||||
}
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const bytes = utf8(input);
|
||||
const ab = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(ab).set(bytes);
|
||||
const buf = await globalThis.crypto.subtle.digest("SHA-256", ab);
|
||||
return toHex(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
/** Convenience: SHA-256 of a UTF-8 string as lowercase hex. */
|
||||
export async function sha256OfUtf8(input: string): Promise<string> {
|
||||
return sha256Hex(input);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# crypto — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of universal codec helpers — `toHex` / `fromHex` / `toBase64` / `fromBase64` / `utf8` / `constantTimeEqual` — that every provider's signature verifier shares. The default HMAC implementation is available in Bun, browsers, Workers, Deno, and Node 19+; callers on older runtimes inject a `node:crypto`-backed adapter on the provider's `hmac` config field.
|
||||
|
||||
Keeping the codec helpers in one place stops every provider from rolling its own hex / base64 routine (which empirically all end up subtly different — different elision sentinels, different uppercasing, different `Buffer` assumptions).
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultHmac` — universal `HmacSign` driven by `globalThis.crypto.subtle`. Supports `SHA-1` (Twilio + AWS SNS verifier) and `SHA-256` (Vonage + MessageBird + Plivo + AWS SigV4).
|
||||
- `toHex` / `fromHex` — lowercase hex round-trip.
|
||||
- `toBase64` / `fromBase64` — standard Base-64 (no URL-safe substitutions; CTIA / SNS / Twilio all expect canonical Base-64).
|
||||
- `utf8(s)` — convenience wrapper for `new TextEncoder().encode(s)`.
|
||||
- `constantTimeEqual(a, b)` — byte-array comparison that is XOR-accumulated to avoid early-exit timing attacks on signature verification.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
toBase64,
|
||||
utf8,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const sig = await defaultHmac("SHA-1", utf8("authToken"), utf8("payload"));
|
||||
const ok = constantTimeEqual(sig, expectedSig);
|
||||
console.log(toBase64(sig));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultHmac,
|
||||
toHex,
|
||||
utf8,
|
||||
type HmacSign,
|
||||
} from "@suaveplan/sms-providers";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Node 18 fallback when subtle.sign is not exposed.
|
||||
const nodeHmac: HmacSign = async (algo, key, msg) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
|
||||
const hmac: HmacSign = typeof globalThis.crypto?.subtle?.sign === "function"
|
||||
? defaultHmac
|
||||
: nodeHmac;
|
||||
|
||||
const sig = toHex(await hmac("SHA-256", utf8("secret"), utf8("payload")));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `defaultHmac` | const | `HmacSign` over `globalThis.crypto.subtle`. |
|
||||
| `toHex(bytes)` / `fromHex(s)` | functions | Lowercase hex codec. |
|
||||
| `toBase64(bytes)` / `fromBase64(s)` | functions | Standard Base-64 codec. |
|
||||
| `utf8(s)` | function | `TextEncoder().encode(s)`. |
|
||||
| `constantTimeEqual(a, b)` | function | XOR-accumulated comparison. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`toHex` builds the string with `String#padStart(2, "0")` per byte rather than the bigint-shift trick to keep the implementation legible. `constantTimeEqual` never short-circuits on length mismatch *during* iteration — the early `length` check is unavoidable but does not leak content. Subtle's `importKey` is called inside `defaultHmac` rather than cached because production callers typically supply a fresh `key` per call.
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
fromBase64,
|
||||
fromHex,
|
||||
toBase64,
|
||||
toHex,
|
||||
utf8,
|
||||
} from "./default-hmac.js";
|
||||
|
||||
describe("hex codec", () => {
|
||||
it("toHex / fromHex round-trips", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array([0, 1, 0xab, 0xff]);
|
||||
const hex = toHex(bytes);
|
||||
expect(hex).toBe("0001abff");
|
||||
expect(Array.from(fromHex(hex))).toEqual(Array.from(bytes));
|
||||
});
|
||||
|
||||
it("fromHex accepts uppercase + odd-length inputs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Array.from(fromHex("F"))).toEqual([0x0f]);
|
||||
expect(Array.from(fromHex("AB"))).toEqual([0xab]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64 codec", () => {
|
||||
it("toBase64 / fromBase64 round-trips", { timeout: 5000 }, () => {
|
||||
const bytes = utf8("hello");
|
||||
const b64 = toBase64(bytes);
|
||||
expect(b64).toBe("aGVsbG8=");
|
||||
expect(Array.from(fromBase64(b64))).toEqual(Array.from(bytes));
|
||||
});
|
||||
});
|
||||
|
||||
describe("constantTimeEqual", () => {
|
||||
it("returns true for equal arrays", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
constantTimeEqual(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
new Uint8Array([1, 2, 3])
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for mismatched length", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
constantTimeEqual(new Uint8Array([1]), new Uint8Array([1, 2]))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for mismatched contents", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
constantTimeEqual(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
new Uint8Array([1, 2, 4])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultHmac", () => {
|
||||
it("computes HMAC-SHA256 matching the known RFC 4231 test vector", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// RFC 4231 Test Case 1: key=0x0b*20, data="Hi There"
|
||||
const key = new Uint8Array(20).fill(0x0b);
|
||||
const data = utf8("Hi There");
|
||||
const expected =
|
||||
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
|
||||
expect(toHex(await defaultHmac("SHA-256", key, data))).toBe(expected);
|
||||
});
|
||||
|
||||
it("computes HMAC-SHA1 matching the known RFC 2202 test vector", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// RFC 2202 Test Case 1: key=0x0b*20, data="Hi There"
|
||||
const key = new Uint8Array(20).fill(0x0b);
|
||||
const data = utf8("Hi There");
|
||||
const expected = "b617318655057264e28bc0b6fb378c8ef146be00";
|
||||
expect(toHex(await defaultHmac("SHA-1", key, data))).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { HmacSign } from "../types/provider.js";
|
||||
|
||||
/**
|
||||
* Default `HmacSign` implementation routed through
|
||||
* `globalThis.crypto.subtle`. Available in Bun, browsers, Workers, Deno,
|
||||
* and Node 19+ (the runtime targets genesis supports). Callers running
|
||||
* on a runtime without subtle exposure (e.g. older Node, Edge stripped
|
||||
* builds) may inject a `node:crypto`-backed adapter on the provider's
|
||||
* `hmac` field.
|
||||
*/
|
||||
export const defaultHmac: HmacSign = async (algorithm, key, message) => {
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
// The Web Crypto types tighten `BufferSource` to require
|
||||
// `Uint8Array<ArrayBuffer>` rather than the wider
|
||||
// `Uint8Array<ArrayBufferLike>` produced by `TextEncoder`; coerce
|
||||
// through a fresh ArrayBuffer-backed view.
|
||||
const cryptoKey = await subtle.importKey(
|
||||
"raw",
|
||||
copyBytes(key),
|
||||
{ name: "HMAC", hash: algorithm },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const sig = await subtle.sign("HMAC", cryptoKey, copyBytes(message));
|
||||
return new Uint8Array(sig);
|
||||
};
|
||||
|
||||
function copyBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const buf = new ArrayBuffer(input.byteLength);
|
||||
new Uint8Array(buf).set(input);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/** Convert a byte array to lowercase hex. */
|
||||
export function toHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
out += (bytes[i] as number).toString(16).padStart(2, "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a byte array to standard Base-64 (no URL-safe substitutions). */
|
||||
export function toBase64(bytes: Uint8Array): string {
|
||||
// `btoa` is universal (Node 18+, Bun, browsers, Workers).
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bin += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return globalThis.btoa(bin);
|
||||
}
|
||||
|
||||
/** Convert a UTF-8 string to bytes. */
|
||||
export function utf8(input: string): Uint8Array {
|
||||
return new TextEncoder().encode(input);
|
||||
}
|
||||
|
||||
/** Convert a hex string to bytes (lowercase or uppercase input accepted). */
|
||||
export function fromHex(hex: string): Uint8Array {
|
||||
const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
|
||||
const out = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert standard Base-64 to bytes. */
|
||||
export function fromBase64(b64: string): Uint8Array {
|
||||
const bin = globalThis.atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) {
|
||||
out[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time byte-array comparison. Returns `true` iff both arrays
|
||||
* have the same length and identical contents.
|
||||
*/
|
||||
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
diff |= (a[i] as number) ^ (b[i] as number);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
fromBase64,
|
||||
fromHex,
|
||||
toBase64,
|
||||
toHex,
|
||||
utf8,
|
||||
} from "./default-hmac.js";
|
||||
@@ -0,0 +1,104 @@
|
||||
# Error codes — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subclass used by every public surface of `@suaveplan/sms-providers`. Every failure path the package can take — a non-2xx HTTP response from a provider, an upstream request timeout, a JSON parse failure on a response body, a missing configuration value, a webhook signature mismatch, an invalid E.164 number, a registry name collision, a missing registry lookup — raises one of these eight codes so downstream loggers, problem+json responders, retry wrappers, and analytics pipelines see a consistent shape.
|
||||
|
||||
Codes are minted through `@suaveplan/error`'s `defineCodes("SMS_PROVIDERS", […])` so they sit in a non-colliding namespace (`SMS_PROVIDERS.*`) distinct from `@suaveplan/sms`'s `SMS.*` codes. The `SmsProvidersError` class extends `SuaveplanError<SmsProvidersErrorCode>` so `instanceof SmsProvidersError` narrows `error.code` to the union; optional `provider`, `httpStatus`, `url`, and `bodyExcerpt` discriminants are carried so consumers can branch without re-parsing the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering every public-surface failure path.
|
||||
- Six factory helpers (`httpError`, `timeoutError`, `parseError`, `configMissing`, `signatureInvalid`, `invalidNumber`, `providerNotRegistered`, `providerAlreadyRegistered`).
|
||||
- Response bodies are truncated to 256 chars on the `bodyExcerpt` discriminant — bounded log volume regardless of payload size.
|
||||
- `cause` is preserved for any wrapped exception (timeout, parse failure) so root-cause traversal still works.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SmsProvidersError,
|
||||
SMS_PROVIDERS_CODES,
|
||||
httpError,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
try {
|
||||
/* call provider */
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof SmsProvidersError &&
|
||||
err.code === SMS_PROVIDERS_CODES.HTTP_ERROR
|
||||
) {
|
||||
console.warn(`HTTP ${err.httpStatus} from ${err.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
const e = httpError("twilio", 422, "https://api.twilio.com/x", "{}", "invalid");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SmsProvidersError,
|
||||
SMS_PROVIDERS_CODES,
|
||||
signatureInvalid,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
// A custom webhook router that maps `SMS_PROVIDERS.SIGNATURE_INVALID` to a 401.
|
||||
export function verifyOrThrow(
|
||||
provider: string,
|
||||
ok: boolean,
|
||||
reason: string
|
||||
): void {
|
||||
if (!ok) {
|
||||
throw signatureInvalid(provider, reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Branch on every code for a problem+json renderer.
|
||||
function statusFor(err: SmsProvidersError): number {
|
||||
switch (err.code) {
|
||||
case SMS_PROVIDERS_CODES.SIGNATURE_INVALID:
|
||||
return 401;
|
||||
case SMS_PROVIDERS_CODES.CONFIG_MISSING:
|
||||
return 500;
|
||||
case SMS_PROVIDERS_CODES.INVALID_NUMBER:
|
||||
return 400;
|
||||
case SMS_PROVIDERS_CODES.PROVIDER_NOT_REGISTERED:
|
||||
return 404;
|
||||
case SMS_PROVIDERS_CODES.PROVIDER_ALREADY_REGISTERED:
|
||||
return 409;
|
||||
case SMS_PROVIDERS_CODES.TIMEOUT:
|
||||
case SMS_PROVIDERS_CODES.HTTP_ERROR:
|
||||
case SMS_PROVIDERS_CODES.PARSE_ERROR:
|
||||
return err.httpStatus ?? 502;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `SMS_PROVIDERS_CODES` | const record | Frozen map produced by `defineCodes("SMS_PROVIDERS", […])` |
|
||||
| `SmsProvidersErrorCode` | type | Union of every code value |
|
||||
| `SmsProvidersError` | class | Extends `SuaveplanError` with optional `provider` / `httpStatus` / `url` / `bodyExcerpt` |
|
||||
| `httpError(provider, status, url, body, message?)` | factory | `HTTP_ERROR` |
|
||||
| `timeoutError(provider, url, timeoutMs, cause?)` | factory | `TIMEOUT` |
|
||||
| `parseError(provider, url, body, cause)` | factory | `PARSE_ERROR` |
|
||||
| `configMissing(provider, key)` | factory | `CONFIG_MISSING` |
|
||||
| `signatureInvalid(provider, reason)` | factory | `SIGNATURE_INVALID` |
|
||||
| `invalidNumber(provider, number)` | factory | `INVALID_NUMBER` |
|
||||
| `providerNotRegistered(name)` | factory | `PROVIDER_NOT_REGISTERED` |
|
||||
| `providerAlreadyRegistered(name)` | factory | `PROVIDER_ALREADY_REGISTERED` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bodyExcerpt` truncation keeps the elision sentinel `…` (U+2026) — distinguishable from a literal three-dot ASCII triplet so log scrapers can tell when truncation fired. The factories never construct errors from raw `Error`; they always go through `new SmsProvidersError(...)` so the prototype chain stays intact under transpiler-emitted `__extends` shims.
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
configMissing,
|
||||
httpError,
|
||||
invalidNumber,
|
||||
parseError,
|
||||
providerAlreadyRegistered,
|
||||
providerNotRegistered,
|
||||
SMS_PROVIDERS_CODES,
|
||||
SmsProvidersError,
|
||||
signatureInvalid,
|
||||
timeoutError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("SMS_PROVIDERS_CODES", () => {
|
||||
it("namespaces every code under SMS_PROVIDERS.*", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(SMS_PROVIDERS_CODES)) {
|
||||
expect(value.startsWith("SMS_PROVIDERS.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the documented eight codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(SMS_PROVIDERS_CODES).sort()).toEqual(
|
||||
[
|
||||
"CONFIG_MISSING",
|
||||
"HTTP_ERROR",
|
||||
"INVALID_NUMBER",
|
||||
"PARSE_ERROR",
|
||||
"PROVIDER_ALREADY_REGISTERED",
|
||||
"PROVIDER_NOT_REGISTERED",
|
||||
"SIGNATURE_INVALID",
|
||||
"TIMEOUT",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpError", () => {
|
||||
it("carries provider, httpStatus, url, and truncated bodyExcerpt", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = httpError("twilio", 422, "https://x/y", '{"a":1}', "msg");
|
||||
expect(e).toBeInstanceOf(SmsProvidersError);
|
||||
expect(e.code).toBe(SMS_PROVIDERS_CODES.HTTP_ERROR);
|
||||
expect(e.message).toBe("msg");
|
||||
expect(e.provider).toBe("twilio");
|
||||
expect(e.httpStatus).toBe(422);
|
||||
expect(e.url).toBe("https://x/y");
|
||||
expect(e.bodyExcerpt).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it("falls back to a default message", { timeout: 5000 }, () => {
|
||||
const e = httpError("twilio", 500, "u", "body");
|
||||
expect(e.message).toBe("HTTP 500 from twilio");
|
||||
});
|
||||
|
||||
it("truncates body to 256 chars with a U+2026 sentinel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const big = "x".repeat(300);
|
||||
const e = httpError("p", 502, "u", big);
|
||||
expect(e.bodyExcerpt?.length).toBe(257);
|
||||
expect(e.bodyExcerpt?.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeoutError", () => {
|
||||
it("encodes provider, url, and timeoutMs in the message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("aborted");
|
||||
const e = timeoutError("vonage", "https://x", 1234, cause);
|
||||
expect(e.code).toBe(SMS_PROVIDERS_CODES.TIMEOUT);
|
||||
expect(e.message).toContain("vonage");
|
||||
expect(e.message).toContain("1234");
|
||||
expect(e.provider).toBe("vonage");
|
||||
expect(e.url).toBe("https://x");
|
||||
expect(e.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("supports omitted cause", { timeout: 5000 }, () => {
|
||||
const e = timeoutError("p", "u", 1);
|
||||
expect(e.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseError", () => {
|
||||
it("preserves cause + truncates body", { timeout: 5000 }, () => {
|
||||
const cause = new Error("not json");
|
||||
const e = parseError("plivo", "https://x", "not-json", cause);
|
||||
expect(e.code).toBe(SMS_PROVIDERS_CODES.PARSE_ERROR);
|
||||
expect(e.cause).toBe(cause);
|
||||
expect(e.bodyExcerpt).toBe("not-json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configMissing", () => {
|
||||
it("attaches the offending key on the context", { timeout: 5000 }, () => {
|
||||
const e = configMissing("twilio", "accountSid");
|
||||
expect(e.code).toBe(SMS_PROVIDERS_CODES.CONFIG_MISSING);
|
||||
expect(e.provider).toBe("twilio");
|
||||
expect(e.context?.["key"]).toBe("accountSid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("signatureInvalid", () => {
|
||||
it("carries the reason on the context", { timeout: 5000 }, () => {
|
||||
const e = signatureInvalid("plivo", "mismatched MAC");
|
||||
expect(e.code).toBe(SMS_PROVIDERS_CODES.SIGNATURE_INVALID);
|
||||
expect(e.provider).toBe("plivo");
|
||||
expect(e.context?.["reason"]).toBe("mismatched MAC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidNumber", () => {
|
||||
it("carries the offending number on the context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = invalidNumber("aws-sns", "abc");
|
||||
expect(e.code).toBe(SMS_PROVIDERS_CODES.INVALID_NUMBER);
|
||||
expect(e.provider).toBe("aws-sns");
|
||||
expect(e.context?.["number"]).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerNotRegistered / providerAlreadyRegistered", () => {
|
||||
it("propagates the offending name on `provider`", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = providerNotRegistered("xyz");
|
||||
const b = providerAlreadyRegistered("xyz");
|
||||
expect(a.code).toBe(SMS_PROVIDERS_CODES.PROVIDER_NOT_REGISTERED);
|
||||
expect(b.code).toBe(SMS_PROVIDERS_CODES.PROVIDER_ALREADY_REGISTERED);
|
||||
expect(a.provider).toBe("xyz");
|
||||
expect(b.provider).toBe("xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SmsProvidersError discriminants", () => {
|
||||
it("omits every discriminant when the constructor receives only required args", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SmsProvidersError(SMS_PROVIDERS_CODES.HTTP_ERROR, "x");
|
||||
expect(e.provider).toBeUndefined();
|
||||
expect(e.httpStatus).toBeUndefined();
|
||||
expect(e.url).toBeUndefined();
|
||||
expect(e.bodyExcerpt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/sms-providers`. Every
|
||||
* production failure path (network error, timeout, JSON parse failure,
|
||||
* configuration omission, signature mismatch, invalid number, registry
|
||||
* collision) raises a `SmsProvidersError` carrying one of these
|
||||
* namespaced codes.
|
||||
*/
|
||||
export const SMS_PROVIDERS_CODES = defineCodes("SMS_PROVIDERS", [
|
||||
"HTTP_ERROR",
|
||||
"TIMEOUT",
|
||||
"PARSE_ERROR",
|
||||
"CONFIG_MISSING",
|
||||
"SIGNATURE_INVALID",
|
||||
"INVALID_NUMBER",
|
||||
"PROVIDER_NOT_REGISTERED",
|
||||
"PROVIDER_ALREADY_REGISTERED",
|
||||
] as const);
|
||||
|
||||
export type SmsProvidersErrorCode =
|
||||
(typeof SMS_PROVIDERS_CODES)[keyof typeof SMS_PROVIDERS_CODES];
|
||||
|
||||
/** Discriminant context carried on a `SmsProvidersError`. */
|
||||
export interface SmsProvidersErrorOptions extends SuaveplanErrorOptions {
|
||||
readonly provider?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly url?: string;
|
||||
readonly bodyExcerpt?: string;
|
||||
}
|
||||
|
||||
const BODY_EXCERPT_MAX = 256;
|
||||
|
||||
/**
|
||||
* Discriminated error class for `@suaveplan/sms-providers`. Extends
|
||||
* `SuaveplanError` so `instanceof` narrows `code` to
|
||||
* `SmsProvidersErrorCode`; carries optional `provider` / `httpStatus` /
|
||||
* `url` / `bodyExcerpt` diagnostics for downstream loggers and
|
||||
* problem+json responders.
|
||||
*/
|
||||
export class SmsProvidersError extends SuaveplanError<SmsProvidersErrorCode> {
|
||||
public readonly provider?: string;
|
||||
public readonly httpStatus?: number;
|
||||
public readonly url?: string;
|
||||
public readonly bodyExcerpt?: string;
|
||||
|
||||
public constructor(
|
||||
code: SmsProvidersErrorCode,
|
||||
message: string,
|
||||
options: SmsProvidersErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
if (options.provider !== undefined) {
|
||||
this.provider = options.provider;
|
||||
}
|
||||
if (options.httpStatus !== undefined) {
|
||||
this.httpStatus = options.httpStatus;
|
||||
}
|
||||
if (options.url !== undefined) {
|
||||
this.url = options.url;
|
||||
}
|
||||
if (options.bodyExcerpt !== undefined) {
|
||||
this.bodyExcerpt = options.bodyExcerpt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(input: string): string {
|
||||
if (input.length <= BODY_EXCERPT_MAX) {
|
||||
return input;
|
||||
}
|
||||
return `${input.slice(0, BODY_EXCERPT_MAX)}…`;
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.HTTP_ERROR` — non-2xx HTTP response. */
|
||||
export function httpError(
|
||||
provider: string,
|
||||
httpStatus: number,
|
||||
url: string,
|
||||
body: string,
|
||||
message?: string
|
||||
): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
message ?? `HTTP ${httpStatus} from ${provider}`,
|
||||
{ provider, httpStatus, url, bodyExcerpt: truncate(body) }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.TIMEOUT` — request aborted by timeout. */
|
||||
export function timeoutError(
|
||||
provider: string,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
cause?: unknown
|
||||
): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.TIMEOUT,
|
||||
`Request to ${provider} timed out after ${timeoutMs}ms`,
|
||||
{ provider, url, cause }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.PARSE_ERROR` — JSON parse failure. */
|
||||
export function parseError(
|
||||
provider: string,
|
||||
url: string,
|
||||
body: string,
|
||||
cause: unknown
|
||||
): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.PARSE_ERROR,
|
||||
`Failed to parse response from ${provider}`,
|
||||
{ provider, url, bodyExcerpt: truncate(body), cause }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.CONFIG_MISSING` — required config absent. */
|
||||
export function configMissing(
|
||||
provider: string,
|
||||
key: string
|
||||
): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.CONFIG_MISSING,
|
||||
`SMS provider "${provider}" configuration value "${key}" is missing`,
|
||||
{ provider, context: { key } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.SIGNATURE_INVALID` — webhook signature mismatch. */
|
||||
export function signatureInvalid(
|
||||
provider: string,
|
||||
reason: string
|
||||
): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
`Webhook signature from ${provider} failed verification: ${reason}`,
|
||||
{ provider, context: { reason } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.INVALID_NUMBER`. */
|
||||
export function invalidNumber(
|
||||
provider: string,
|
||||
number: string
|
||||
): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.INVALID_NUMBER,
|
||||
`Number "${number}" is not in valid E.164 form for ${provider}`,
|
||||
{ provider, context: { number } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.PROVIDER_NOT_REGISTERED`. */
|
||||
export function providerNotRegistered(name: string): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.PROVIDER_NOT_REGISTERED,
|
||||
`SMS provider "${name}" is not registered`,
|
||||
{ provider: name }
|
||||
);
|
||||
}
|
||||
|
||||
/** Factory for `SMS_PROVIDERS.PROVIDER_ALREADY_REGISTERED`. */
|
||||
export function providerAlreadyRegistered(name: string): SmsProvidersError {
|
||||
return new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.PROVIDER_ALREADY_REGISTERED,
|
||||
`SMS provider "${name}" is already registered`,
|
||||
{ provider: name }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
configMissing,
|
||||
httpError,
|
||||
invalidNumber,
|
||||
parseError,
|
||||
providerAlreadyRegistered,
|
||||
providerNotRegistered,
|
||||
SMS_PROVIDERS_CODES,
|
||||
SmsProvidersError,
|
||||
type SmsProvidersErrorCode,
|
||||
type SmsProvidersErrorOptions,
|
||||
signatureInvalid,
|
||||
timeoutError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# http — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, serialises JSON or form bodies, enforces a per-call timeout via `AbortSignal`, composes the timeout signal with a caller-supplied `signal` so external cancellation also tears the request down, and translates every failure mode into the typed `SmsProvidersError` hierarchy.
|
||||
|
||||
The function lets every provider class stay free of platform-specific HTTP code (no `node-fetch`, no `axios`, no SDK fetch runtimes). Tests inject a recording `FetchLike` (or one that proxies through a real `node:http` server on a random port); production runtime resolves `globalThis.fetch.bind(globalThis)`.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `FetchLike` defaulting to `globalThis.fetch.bind(globalThis)` so the same code runs on Bun, Node, browsers, Workers, and Deno.
|
||||
- Per-call timeout via an internal `AbortController`; composed with `caller.signal` through `composeAbortSignal`.
|
||||
- `body: string` is sent verbatim with `Content-Type: application/x-www-form-urlencoded` (Twilio/Vonage/Plivo/SNS form bodies); any other shape is JSON-stringified with `Content-Type: application/json`. Callers may override either header in `headers`.
|
||||
- JSON detection is `Content-Type`-driven, with a `{`/`[` start-byte fallback so providers that omit the header still parse.
|
||||
- 2xx → `{ status, data }`; non-2xx → `SmsProvidersError(HTTP_ERROR)` with the body excerpted; timeout abort → `TIMEOUT`; parse failure → `PARSE_ERROR`; underlying fetch rejection → `HTTP_ERROR` (status 0).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { httpRequest } from "@suaveplan/sms-providers";
|
||||
|
||||
const { status, data } = await httpRequest<{ sid: string }>({
|
||||
method: "POST",
|
||||
url: "https://api.twilio.com/2010-04-01/Accounts/AC/Messages.json",
|
||||
headers: { Authorization: "Basic …" },
|
||||
body: "To=%2B1&From=%2B1&Body=hi",
|
||||
provider: "twilio",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { composeAbortSignal, httpRequest } from "@suaveplan/sms-providers";
|
||||
|
||||
const userAbort = new AbortController();
|
||||
setTimeout(() => userAbort.abort(new Error("cancelled by user")), 100);
|
||||
|
||||
try {
|
||||
await httpRequest({
|
||||
method: "GET",
|
||||
url: "https://api.example.com/slow",
|
||||
timeoutMs: 30_000,
|
||||
signal: userAbort.signal,
|
||||
provider: "example",
|
||||
});
|
||||
} catch (err) {
|
||||
// SmsProvidersError(TIMEOUT) or HTTP_ERROR(status:0) depending on
|
||||
// which signal fired first.
|
||||
}
|
||||
|
||||
// Compose your own signals when assembling a multi-step provider call.
|
||||
const internal = new AbortController().signal;
|
||||
const composed = composeAbortSignal(internal, userAbort.signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `httpRequest<T>(options)` | function | Returns `Promise<ProviderResponse<T>>`; throws `SmsProvidersError` on failure. |
|
||||
| `composeAbortSignal(internal, external?)` | function | Returns a signal that aborts when either input aborts. |
|
||||
| `DEFAULT_TIMEOUT_MS` | private | 30 000 ms. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`composeAbortSignal` re-uses the caller's signal directly when `external` is `undefined`, avoiding a wrapper allocation on the hot path. The timeout abort is differentiated from caller-driven aborts by inspecting `timeoutSignal.aborted` after a thrown `AbortError` — a separate `AbortController` per request guarantees the test is reliable even when the caller's signal also fires.
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SMS_PROVIDERS_CODES,
|
||||
SmsProvidersError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { FetchLike } from "../types/provider.js";
|
||||
import { composeAbortSignal, httpRequest } from "./http.js";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(text: string, status = 200, ct = "text/plain"): Response {
|
||||
return new Response(text, { status, headers: { "content-type": ct } });
|
||||
}
|
||||
|
||||
describe("httpRequest — happy paths", () => {
|
||||
it("returns parsed JSON on 2xx with application/json", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () => jsonResponse({ ok: 1 });
|
||||
const { status, data } = await httpRequest<{ ok: number }>({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
provider: "p",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it("parses JSON via start-byte fallback when content-type is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () =>
|
||||
new Response('{"a":2}', { status: 200 });
|
||||
const { data } = await httpRequest<{ a: number }>({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(data).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
it("returns the raw text when the response is not JSON-shaped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () => textResponse("OK");
|
||||
const { data } = await httpRequest<string>({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(data).toBe("OK");
|
||||
});
|
||||
|
||||
it("returns an empty object on an empty response body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () =>
|
||||
new Response("", { status: 200 });
|
||||
const { status, data } = await httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpRequest — body + headers", () => {
|
||||
it("sends a string body verbatim with form content-type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured:
|
||||
| { url: string; init: RequestInit | undefined }
|
||||
| undefined;
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
captured = { url, init };
|
||||
return new Response("", { status: 200 });
|
||||
};
|
||||
await httpRequest({
|
||||
method: "POST",
|
||||
url: "https://x/",
|
||||
body: "a=1&b=2",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(captured?.init?.body).toBe("a=1&b=2");
|
||||
const headers = (captured?.init?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Content-Type"]).toBe(
|
||||
"application/x-www-form-urlencoded"
|
||||
);
|
||||
});
|
||||
|
||||
it("JSON-stringifies non-string bodies", { timeout: 5000 }, async () => {
|
||||
let bodyCaptured: BodyInit | null | undefined;
|
||||
const fetchImpl: FetchLike = async (_url, init) => {
|
||||
bodyCaptured = init?.body;
|
||||
return jsonResponse({});
|
||||
};
|
||||
await httpRequest({
|
||||
method: "POST",
|
||||
url: "https://x/",
|
||||
body: { hi: "there" },
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(bodyCaptured).toBe('{"hi":"there"}');
|
||||
});
|
||||
|
||||
it("merges caller headers over defaults", { timeout: 5000 }, async () => {
|
||||
let captured: Record<string, string> | undefined;
|
||||
const fetchImpl: FetchLike = async (_url, init) => {
|
||||
captured = (init?.headers ?? {}) as Record<string, string>;
|
||||
return new Response("", { status: 200 });
|
||||
};
|
||||
await httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
headers: {
|
||||
Authorization: "Bearer xx",
|
||||
"Content-Type": "application/x-foo",
|
||||
},
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(captured?.Authorization).toBe("Bearer xx");
|
||||
expect(captured?.["Content-Type"]).toBe("application/x-foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpRequest — error paths", () => {
|
||||
it("raises HTTP_ERROR on non-2xx with the body excerpted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () =>
|
||||
jsonResponse({ message: "bad" }, 422);
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "POST",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
httpStatus: 422,
|
||||
provider: "p",
|
||||
message: "bad",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a generic HTTP_ERROR message when body has no `message`", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () => jsonResponse({}, 500);
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
message: "HTTP 500 from p",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when an application/json body is not valid JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () =>
|
||||
new Response("{not json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.PARSE_ERROR });
|
||||
});
|
||||
|
||||
it("raises HTTP_ERROR(status:0) when the fetch itself rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () => {
|
||||
throw new Error("DNS failed");
|
||||
};
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
httpStatus: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws an `SmsProvidersError` raised by a fetch wrapper unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sentinel = new SmsProvidersError(
|
||||
SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
"x"
|
||||
);
|
||||
const fetchImpl: FetchLike = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
})
|
||||
).rejects.toBe(sentinel);
|
||||
});
|
||||
|
||||
it("wraps non-Error rejections", { timeout: 5000 }, async () => {
|
||||
const fetchImpl: FetchLike = async () => {
|
||||
throw "boom" as unknown as Error;
|
||||
};
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
httpStatus: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpRequest — timeout + abort", () => {
|
||||
it("raises TIMEOUT when the internal timeout fires before the response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = (_url, init) =>
|
||||
new Promise((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
timeoutMs: 5,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.TIMEOUT });
|
||||
});
|
||||
|
||||
it("propagates a caller-side abort as HTTP_ERROR(status:0)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
const fetchImpl: FetchLike = (_url, init) =>
|
||||
new Promise((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
const err = new Error("user aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
setTimeout(() => ctrl.abort(), 5);
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 5_000,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
httpStatus: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeAbortSignal", () => {
|
||||
it("returns `internal` when external is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const internal = new AbortController().signal;
|
||||
expect(composeAbortSignal(internal, undefined)).toBe(internal);
|
||||
});
|
||||
|
||||
it("aborts when external aborts (already-aborted at call time)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ext = new AbortController();
|
||||
ext.abort(new Error("pre"));
|
||||
const composed = composeAbortSignal(
|
||||
new AbortController().signal,
|
||||
ext.signal
|
||||
);
|
||||
expect(composed.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts when external aborts after construction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ext = new AbortController();
|
||||
const composed = composeAbortSignal(
|
||||
new AbortController().signal,
|
||||
ext.signal
|
||||
);
|
||||
expect(composed.aborted).toBe(false);
|
||||
ext.abort(new Error("later"));
|
||||
expect(composed.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts when internal aborts", { timeout: 5000 }, () => {
|
||||
const internalCtrl = new AbortController();
|
||||
const ext = new AbortController();
|
||||
const composed = composeAbortSignal(internalCtrl.signal, ext.signal);
|
||||
internalCtrl.abort(new Error("timeout"));
|
||||
expect(composed.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
158
packages/communication/messaging/sms-providers/src/http/http.ts
Normal file
158
packages/communication/messaging/sms-providers/src/http/http.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
httpError,
|
||||
parseError,
|
||||
SmsProvidersError,
|
||||
timeoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
HttpOptions,
|
||||
ProviderResponse,
|
||||
} from "../types/provider.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_PROVIDER = "unknown";
|
||||
|
||||
function defaultFetch(): FetchLike {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose two `AbortSignal`s into one. When `external` is `undefined`
|
||||
* the function returns `internal` directly (reference-equal). Otherwise
|
||||
* it returns a fresh signal that aborts as soon as either input aborts,
|
||||
* propagating the firing input's `reason`. If `external` is already
|
||||
* aborted at call time the composed signal is aborted before the
|
||||
* function returns.
|
||||
*/
|
||||
export function composeAbortSignal(
|
||||
internal: AbortSignal,
|
||||
external: AbortSignal | undefined
|
||||
): AbortSignal {
|
||||
if (external === undefined) {
|
||||
return internal;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const onAbort = (signal: AbortSignal): void => {
|
||||
controller.abort(signal.reason);
|
||||
};
|
||||
internal.addEventListener("abort", () => onAbort(internal), { once: true });
|
||||
if (external.aborted) {
|
||||
controller.abort(external.reason);
|
||||
} else {
|
||||
external.addEventListener("abort", () => onAbort(external), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function isTimeoutAbort(err: unknown, timeoutSignal: AbortSignal): boolean {
|
||||
if (!timeoutSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
return err instanceof Error && err.name === "AbortError";
|
||||
}
|
||||
|
||||
function looksLikeJson(headers: Headers, text: string): boolean {
|
||||
const ct = headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) {
|
||||
return true;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal HTTP helper wrapping an injectable `FetchLike`. Serialises
|
||||
* JSON or `string` bodies, enforces a per-call timeout via
|
||||
* `AbortSignal`, composes a caller-supplied `signal` with the internal
|
||||
* timeout signal, and raises typed `SmsProvidersError`s on failure.
|
||||
*
|
||||
* `body` is sent verbatim when it is a `string` (Twilio / Vonage /
|
||||
* Plivo / SNS form bodies); any other shape is JSON-stringified and
|
||||
* `Content-Type` defaults to `application/json`.
|
||||
*/
|
||||
export async function httpRequest<T = unknown>(
|
||||
options: HttpOptions
|
||||
): Promise<ProviderResponse<T>> {
|
||||
const {
|
||||
method,
|
||||
url,
|
||||
headers = {},
|
||||
body,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
fetch: fetchImpl = defaultFetch(),
|
||||
signal: external,
|
||||
provider = DEFAULT_PROVIDER,
|
||||
} = options;
|
||||
|
||||
const timeoutController = new AbortController();
|
||||
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
|
||||
const signal = composeAbortSignal(timeoutController.signal, external);
|
||||
|
||||
const isStringBody = typeof body === "string";
|
||||
const defaultContentType = isStringBody
|
||||
? "application/x-www-form-urlencoded"
|
||||
: "application/json";
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": defaultContentType,
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
},
|
||||
signal,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
init.body = isStringBody ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(url, init);
|
||||
} catch (err) {
|
||||
if (isTimeoutAbort(err, timeoutController.signal)) {
|
||||
throw timeoutError(provider, url, timeoutMs, err);
|
||||
}
|
||||
if (err instanceof SmsProvidersError) {
|
||||
throw err;
|
||||
}
|
||||
throw httpError(
|
||||
provider,
|
||||
0,
|
||||
url,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
err instanceof Error ? err.message : undefined
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let data: T;
|
||||
if (text === "") {
|
||||
data = {} as T;
|
||||
} else if (looksLikeJson(response.headers, text)) {
|
||||
try {
|
||||
data = JSON.parse(text) as T;
|
||||
} catch (parseErr) {
|
||||
throw parseError(provider, url, text, parseErr);
|
||||
}
|
||||
} else {
|
||||
data = text as unknown as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data
|
||||
? String((data as Record<string, unknown>)["message"])
|
||||
: `HTTP ${response.status} from ${provider}`;
|
||||
throw httpError(provider, response.status, url, text, message);
|
||||
}
|
||||
|
||||
return { status: response.status, data };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { composeAbortSignal, httpRequest } from "./http.js";
|
||||
79
packages/communication/messaging/sms-providers/src/index.ts
Normal file
79
packages/communication/messaging/sms-providers/src/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// AWS SNS provider + SigV4 helpers.
|
||||
export {
|
||||
AwsSnsProvider,
|
||||
buildSnsStringToSign,
|
||||
} from "./aws-sns/aws-sns.js";
|
||||
export {
|
||||
type SignRequestInput,
|
||||
type SignRequestOutput,
|
||||
sha256OfUtf8,
|
||||
signRequest,
|
||||
} from "./aws-sns/sigv4.js";
|
||||
// Universal codec + default HMAC.
|
||||
export {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
fromBase64,
|
||||
fromHex,
|
||||
toBase64,
|
||||
toHex,
|
||||
utf8,
|
||||
} from "./crypto/default-hmac.js";
|
||||
// Error codes.
|
||||
export {
|
||||
configMissing,
|
||||
httpError,
|
||||
invalidNumber,
|
||||
parseError,
|
||||
providerAlreadyRegistered,
|
||||
providerNotRegistered,
|
||||
SMS_PROVIDERS_CODES,
|
||||
SmsProvidersError,
|
||||
type SmsProvidersErrorCode,
|
||||
type SmsProvidersErrorOptions,
|
||||
signatureInvalid,
|
||||
timeoutError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// HTTP.
|
||||
export { composeAbortSignal, httpRequest } from "./http/http.js";
|
||||
// Compliance keyword classifier.
|
||||
export {
|
||||
type OptOutAction,
|
||||
parseOptOutKeyword,
|
||||
} from "./keywords/keywords.js";
|
||||
// MessageBird.
|
||||
export { MessageBirdProvider } from "./messagebird/messagebird.js";
|
||||
// OTel.
|
||||
export type { SendOutcome, WebhookOutcome } from "./otel/otel.js";
|
||||
export {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhook,
|
||||
recordWebhookVerifyDuration,
|
||||
refreshSmsProvidersMeters,
|
||||
} from "./otel/otel.js";
|
||||
// Plivo.
|
||||
export { PlivoProvider } from "./plivo/plivo.js";
|
||||
// Registry.
|
||||
export { ProviderRegistry } from "./registry/registry.js";
|
||||
// Twilio.
|
||||
export { TwilioProvider } from "./twilio/twilio.js";
|
||||
// Shared types.
|
||||
export type {
|
||||
AwsSnsConfig,
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
HttpOptions,
|
||||
InboundSmsEvent,
|
||||
MessageBirdConfig,
|
||||
OutboundExtras,
|
||||
OutboundMessage,
|
||||
PlivoConfig,
|
||||
ProviderCommonOptions,
|
||||
ProviderResponse,
|
||||
TwilioConfig,
|
||||
VonageConfig,
|
||||
} from "./types/provider.js";
|
||||
// Vonage.
|
||||
export { VonageProvider } from "./vonage/vonage.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type OptOutAction, parseOptOutKeyword } from "./keywords.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# Keywords — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOutRegistry`. CTIA's Short Code Monitoring Handbook (and the parallel TCPA / GDPR opt-out frameworks) mandate that outbound SMS programs recognise STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT as opt-out, START / YES / UNSTOP as opt-in, and HELP / INFO as help-info requests, in every market. Every provider's `parseInbound` runs the raw body through this classifier so the higher-level service layer never has to re-implement the vocabulary.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical six-keyword opt-out set: `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`.
|
||||
- Canonical three-keyword opt-in set: `START`, `YES`, `UNSTOP`.
|
||||
- Canonical two-keyword help set: `HELP`, `INFO`.
|
||||
- Recognition is case-insensitive and ignores surrounding whitespace — `" Stop "`, `"STOP\n"`, `"stop"` all match.
|
||||
- Multi-word bodies do not match (the canonical CTIA contract is single-keyword); they fall through to `"none"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseOptOutKeyword } from "@suaveplan/sms-providers";
|
||||
|
||||
const action = parseOptOutKeyword(" Stop ");
|
||||
// action === "opt_out"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseOptOutKeyword,
|
||||
type OptOutAction,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
function handleInbound(from: string, body: string): void {
|
||||
const action: OptOutAction = parseOptOutKeyword(body);
|
||||
switch (action) {
|
||||
case "opt_out":
|
||||
// optOutRegistry.recordOptOut({ phoneNumber: from, optedOutAt: new Date() });
|
||||
return;
|
||||
case "opt_in":
|
||||
// optOutRegistry.recordOptIn({ phoneNumber: from });
|
||||
return;
|
||||
case "help":
|
||||
// outbound auto-reply with HELP text required by CTIA
|
||||
return;
|
||||
case "none":
|
||||
// forward to the conversational handler
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `OptOutAction` | union | `"opt_out" \| "opt_in" \| "help" \| "none"`. |
|
||||
| `parseOptOutKeyword(body)` | function | Returns the classification. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The keyword sets are frozen `Set<string>` instances at module load so lookup is O(1). Trim + upper-case the input once and compare against all three sets in sequence — short-circuiting on the first match means an opt-out check beats opt-in beats help (no overlap exists in the canonical vocabulary; sequencing is for stability rather than correctness).
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseOptOutKeyword } from "./keywords.js";
|
||||
|
||||
describe("parseOptOutKeyword — opt_out", () => {
|
||||
for (const kw of [
|
||||
"STOP",
|
||||
"STOPALL",
|
||||
"UNSUBSCRIBE",
|
||||
"CANCEL",
|
||||
"END",
|
||||
"QUIT",
|
||||
]) {
|
||||
it(`matches ${kw}`, { timeout: 5000 }, () => {
|
||||
expect(parseOptOutKeyword(kw)).toBe("opt_out");
|
||||
expect(parseOptOutKeyword(kw.toLowerCase())).toBe("opt_out");
|
||||
expect(parseOptOutKeyword(` ${kw} `)).toBe("opt_out");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("parseOptOutKeyword — opt_in", () => {
|
||||
for (const kw of ["START", "YES", "UNSTOP"]) {
|
||||
it(`matches ${kw}`, { timeout: 5000 }, () => {
|
||||
expect(parseOptOutKeyword(kw)).toBe("opt_in");
|
||||
expect(parseOptOutKeyword(kw.toLowerCase())).toBe("opt_in");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("parseOptOutKeyword — help", () => {
|
||||
for (const kw of ["HELP", "INFO"]) {
|
||||
it(`matches ${kw}`, { timeout: 5000 }, () => {
|
||||
expect(parseOptOutKeyword(kw)).toBe("help");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("parseOptOutKeyword — none", () => {
|
||||
it("returns 'none' for free-form messages", { timeout: 5000 }, () => {
|
||||
expect(parseOptOutKeyword("hello there")).toBe("none");
|
||||
expect(parseOptOutKeyword("stop now please")).toBe("none");
|
||||
expect(parseOptOutKeyword("")).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Compliance-keyword classifier. CTIA / TCPA / GDPR mandate that
|
||||
* outbound SMS programs recognise a small canonical vocabulary for
|
||||
* opt-in / opt-out / help. The set is small and case-insensitive; this
|
||||
* module is the single source of truth used by every provider's
|
||||
* `parseInbound` to normalise the matched keyword onto the canonical
|
||||
* `OptOutAction`.
|
||||
*/
|
||||
export type OptOutAction = "opt_out" | "opt_in" | "help" | "none";
|
||||
|
||||
const OPT_OUT = new Set([
|
||||
"STOP",
|
||||
"STOPALL",
|
||||
"UNSUBSCRIBE",
|
||||
"CANCEL",
|
||||
"END",
|
||||
"QUIT",
|
||||
]);
|
||||
const OPT_IN = new Set(["START", "YES", "UNSTOP"]);
|
||||
const HELP = new Set(["HELP", "INFO"]);
|
||||
|
||||
/**
|
||||
* Classify the inbound message body. Recognition is case-insensitive
|
||||
* and ignores leading / trailing whitespace; any body that does not
|
||||
* match exactly one of the canonical keywords returns `"none"`.
|
||||
*/
|
||||
export function parseOptOutKeyword(body: string): OptOutAction {
|
||||
const trimmed = body.trim().toUpperCase();
|
||||
if (OPT_OUT.has(trimmed)) {
|
||||
return "opt_out";
|
||||
}
|
||||
if (OPT_IN.has(trimmed)) {
|
||||
return "opt_in";
|
||||
}
|
||||
if (HELP.has(trimmed)) {
|
||||
return "help";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MessageBirdProvider } from "./messagebird.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# MessageBird — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorization: AccessKey <key>`; status fetched via `GET /messages/{id}`. Inbound webhook signature verification follows the MessageBird Signed Webhooks v2 (`Messagebird-Signature-Jwt`) spec — a JWT HS256 carrying `iat`/`exp`/`nbf`/`payload_hash` claims; the verifier checks the timestamp window (default ±5 minutes), re-MACs the canonical `header.payload`, and constant-time compares the base64url-decoded signature.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- `AccessKey` auth header per MessageBird's documented scheme.
|
||||
- JSON body with `recipients[]`, `originator`, `body`.
|
||||
- Status mapping covers `scheduled` / `sent` / `buffered` / `delivered` / `expired` / `delivery_failed`.
|
||||
- JWT HS256 webhook verifier with `iat` / `exp` / `nbf` window enforcement and optional `payload_hash` cross-check.
|
||||
- Injectable `nowMs` + `toleranceMs` so tests pin the clock and the production deployment can widen the freshness window if needed.
|
||||
- `parseInbound` discriminates between inbound messages and DLRs, recognising the standard CTIA opt-out keywords.
|
||||
- OTel — `send_total` + `send_duration_ms` + `signature_failures_total` + `webhook_verify_duration_ms`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MessageBirdProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const mb = new MessageBirdProvider({ accessKey: "test_…" });
|
||||
await mb.send({ to: "+15551234567", from: "Suaveplan", body: "hi" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MessageBirdProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const mb = new MessageBirdProvider({ accessKey: "live_…" });
|
||||
const ok = await mb.verifyWebhookSignature(
|
||||
"<header>.<payload>.<sig>",
|
||||
"shared-signing-key",
|
||||
{ bodyHashHex: "<sha256-hex>", nowMs: Date.now(), toleranceMs: 60_000 }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `MessageBirdProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | `POST /messages`, returns `SmsDeliveryReport`. |
|
||||
| `getStatus(id)` | method | `GET /messages/{id}`. |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyWebhookSignature(token, signingKey, options?)` | method | JWT HS256 verifier; throws `SIGNATURE_INVALID` on failure. |
|
||||
| `parseInbound(body)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The verifier handles both standard Base-64 and base64url (`-`/`_` → `+`/`/`) inputs because MessageBird's JWT uses the URL-safe variant. `payload_hash` is checked only when both the JWT carries it and the caller supplies a `bodyHashHex`; missing on either side is treated as "skip" so callers can disable the body binding when their proxy strips bodies.
|
||||
@@ -0,0 +1,422 @@
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "../../tests/setup/http-server.js";
|
||||
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
||||
import { MessageBirdProvider } from "./messagebird.js";
|
||||
|
||||
let server: RecordingServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function makeProvider(accessKey = "K") {
|
||||
return new MessageBirdProvider({
|
||||
accessKey,
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
const OK = {
|
||||
id: "mb1",
|
||||
originator: "Sender",
|
||||
body: "hi",
|
||||
type: "sms",
|
||||
recipients: {
|
||||
totalCount: 1,
|
||||
items: [
|
||||
{
|
||||
recipient: 15551234567,
|
||||
status: "sent",
|
||||
statusDatetime: "2026-04-24T10:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("MessageBirdProvider — construction + send", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(OK));
|
||||
});
|
||||
|
||||
it("requires an accessKey", { timeout: 5000 }, () => {
|
||||
expect(() => makeProvider("")).toThrow();
|
||||
});
|
||||
|
||||
it("POSTs /messages with AccessKey auth + JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.send({
|
||||
to: "+15551234567",
|
||||
from: "Sender",
|
||||
body: "hi",
|
||||
});
|
||||
const req = server.requests[0]!;
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/messages");
|
||||
expect(req.headers.authorization).toBe("AccessKey K");
|
||||
expect(req.headers["content-type"]).toContain("application/json");
|
||||
const parsed = JSON.parse(req.body) as Record<string, unknown>;
|
||||
expect(parsed["recipients"]).toEqual(["+15551234567"]);
|
||||
expect(parsed["originator"]).toBe("Sender");
|
||||
expect(parsed["body"]).toBe("hi");
|
||||
expect(r.messageId).toBe("mb1");
|
||||
expect(r.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("omits originator when from is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({ to: "+1", body: "hi" });
|
||||
const parsed = JSON.parse(server.requests[0]!.body) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(parsed["originator"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults the timestamp when statusDatetime is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
...OK,
|
||||
recipients: {
|
||||
totalCount: 1,
|
||||
items: [{ recipient: 1, status: "sent" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+1", body: "hi" });
|
||||
expect(r.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("queues status when the recipients list is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
...OK,
|
||||
recipients: { totalCount: 0, items: [] },
|
||||
})
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+1", body: "hi" });
|
||||
expect(r.status).toBe("queued");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageBirdProvider — getStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(OK));
|
||||
});
|
||||
|
||||
it("GETs /messages/{id}", { timeout: 5000 }, async () => {
|
||||
const p = makeProvider();
|
||||
await p.getStatus("mb1");
|
||||
expect(server.requests[0]!.path).toBe("/messages/mb1");
|
||||
});
|
||||
|
||||
it("returns queued + empty to when no recipients", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
...OK,
|
||||
recipients: { totalCount: 0, items: [] },
|
||||
})
|
||||
);
|
||||
const r = await makeProvider().getStatus("mb1");
|
||||
expect(r.status).toBe("queued");
|
||||
expect(r.to).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageBirdProvider — mapStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(OK));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["scheduled", "queued"],
|
||||
["sent", "sent"],
|
||||
["buffered", "queued"],
|
||||
["delivered", "delivered"],
|
||||
["expired", "undelivered"],
|
||||
["delivery_failed", "failed"],
|
||||
["wat", "failed"],
|
||||
])("%s → %s", { timeout: 5000 }, (raw, mapped) => {
|
||||
expect(makeProvider().mapStatus(raw)).toBe(mapped);
|
||||
});
|
||||
});
|
||||
|
||||
function b64url(input: Buffer | string): string {
|
||||
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/=+$/, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function makeJwt(
|
||||
secret: string,
|
||||
claims: Record<string, unknown>,
|
||||
alg = "HS256"
|
||||
): string {
|
||||
const header = b64url(JSON.stringify({ alg, typ: "JWT" }));
|
||||
const payload = b64url(JSON.stringify(claims));
|
||||
const sig = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest();
|
||||
return `${header}.${payload}.${b64url(sig)}`;
|
||||
}
|
||||
|
||||
describe("MessageBirdProvider — verifyWebhookSignature", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("accepts a valid JWT within the window", { timeout: 5000 }, async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const token = makeJwt("secret", {
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + 60,
|
||||
});
|
||||
const ok = await makeProvider().verifyWebhookSignature(
|
||||
token,
|
||||
"secret",
|
||||
{ nowMs: now }
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", { timeout: 5000 }, async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const token = makeJwt("secret", {
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + 60,
|
||||
});
|
||||
const broken = `${token.slice(0, -1)}A`;
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(broken, "secret", {
|
||||
nowMs: now,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed JWTs", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature("not.a.jwt.extra", "s")
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported algorithms", { timeout: 5000 }, async () => {
|
||||
const token = makeJwt("s", { iat: 0 }, "none");
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(token, "s", { nowMs: 0 })
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects iat in the future", { timeout: 5000 }, async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const token = makeJwt("s", {
|
||||
iat: Math.floor(now / 1000) + 10_000,
|
||||
});
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(token, "s", {
|
||||
nowMs: now,
|
||||
toleranceMs: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects expired JWTs", { timeout: 5000 }, async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const token = makeJwt("s", {
|
||||
iat: 0,
|
||||
exp: 1,
|
||||
});
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(token, "s", {
|
||||
nowMs: now,
|
||||
toleranceMs: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects nbf in the future", { timeout: 5000 }, async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const token = makeJwt("s", {
|
||||
iat: Math.floor(now / 1000),
|
||||
nbf: Math.floor(now / 1000) + 100,
|
||||
});
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(token, "s", {
|
||||
nowMs: now,
|
||||
toleranceMs: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces payload_hash binding when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const bodyHash = createHash("sha256").update("payload").digest("hex");
|
||||
const token = makeJwt("s", {
|
||||
iat: Math.floor(now / 1000),
|
||||
payload_hash: bodyHash,
|
||||
});
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(token, "s", {
|
||||
nowMs: now,
|
||||
bodyHashHex: bodyHash,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(token, "s", {
|
||||
nowMs: now,
|
||||
bodyHashHex: "deadbeef",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unparseable header / payload JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const header = b64url("{not json");
|
||||
const payload = b64url("{}");
|
||||
const sig = b64url(
|
||||
createHmac("sha256", "s").update(`${header}.${payload}`).digest()
|
||||
);
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(
|
||||
`${header}.${payload}.${sig}`,
|
||||
"s",
|
||||
{ nowMs: 0 }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageBirdProvider — parseInbound", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("identifies a delivery receipt", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
expect(
|
||||
p.parseInbound({
|
||||
id: "mb1",
|
||||
recipient: "+1",
|
||||
status: "delivered",
|
||||
statusErrorCode: "0",
|
||||
})
|
||||
).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "mb1",
|
||||
status: "delivered",
|
||||
errorCode: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies delivery receipt without errorCode when absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
id: "mb1",
|
||||
recipient: "+1",
|
||||
status: "delivered",
|
||||
})
|
||||
).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "mb1",
|
||||
status: "delivered",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies STOP / YES / HELP", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
expect(
|
||||
p.parseInbound({ originator: "+1", payload: "stop" })
|
||||
).toMatchObject({ type: "opt_out" });
|
||||
expect(
|
||||
p.parseInbound({ originator: "+1", payload: "yes" })
|
||||
).toMatchObject({ type: "opt_in" });
|
||||
expect(
|
||||
p.parseInbound({ originator: "+1", payload: "HELP" })
|
||||
).toMatchObject({ type: "help" });
|
||||
});
|
||||
|
||||
it("returns message_received with providerId when id is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
id: "mb1",
|
||||
originator: "+1",
|
||||
recipient: "+2",
|
||||
payload: "free-form",
|
||||
})
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "free-form",
|
||||
providerId: "mb1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to `body` when payload is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
originator: "+1",
|
||||
recipient: "+2",
|
||||
body: "from-body",
|
||||
})
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "from-body",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsSendCallOptions,
|
||||
} from "@suaveplan/sms";
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
fromBase64,
|
||||
toBase64,
|
||||
utf8,
|
||||
} from "../crypto/default-hmac.js";
|
||||
import { configMissing, signatureInvalid } from "../error-codes/error-codes.js";
|
||||
import { httpRequest } from "../http/http.js";
|
||||
import { parseOptOutKeyword } from "../keywords/keywords.js";
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhookVerifyDuration,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
MessageBirdConfig,
|
||||
} from "../types/provider.js";
|
||||
|
||||
const PROVIDER = "messagebird" as const;
|
||||
const DEFAULT_BASE_URL = "https://rest.messagebird.com";
|
||||
|
||||
const STATUS_MAP: Readonly<Record<string, SmsDeliveryStatus>> = Object.freeze({
|
||||
scheduled: "queued",
|
||||
sent: "sent",
|
||||
buffered: "queued",
|
||||
delivered: "delivered",
|
||||
expired: "undelivered",
|
||||
delivery_failed: "failed",
|
||||
});
|
||||
|
||||
interface MbMessageResource {
|
||||
readonly id: string;
|
||||
readonly originator: string;
|
||||
readonly body: string;
|
||||
readonly type: string;
|
||||
readonly recipients: {
|
||||
readonly totalCount: number;
|
||||
readonly items: ReadonlyArray<{
|
||||
readonly recipient: number;
|
||||
readonly status: string;
|
||||
readonly statusDatetime?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real MessageBird SMS provider. POSTs to `/messages` with
|
||||
* `Authorization: AccessKey <key>`; status fetched via
|
||||
* `GET /messages/{id}`. Inbound webhook signature verification follows
|
||||
* the MessageBird Signed Webhooks v2 (`Messagebird-Signature-Jwt`) spec
|
||||
* — a JWT HS256 wrapping `iat`/`exp`/`nbf`/`payload_hash` claims; we
|
||||
* validate the timestamp window (default ±5 minutes) and recompute the
|
||||
* MAC over the canonical signing string.
|
||||
*/
|
||||
export class MessageBirdProvider implements SmsProvider {
|
||||
public readonly name = PROVIDER;
|
||||
private readonly config: MessageBirdConfig;
|
||||
private readonly baseUrl: string;
|
||||
private readonly hmac: HmacSign;
|
||||
private readonly fetch: FetchLike | undefined;
|
||||
|
||||
public constructor(config: MessageBirdConfig) {
|
||||
if (!config.accessKey) {
|
||||
throw configMissing(PROVIDER, "accessKey");
|
||||
}
|
||||
this.config = config;
|
||||
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
this.hmac = config.hmac ?? defaultHmac;
|
||||
this.fetch = config.fetch;
|
||||
}
|
||||
|
||||
public mapStatus(raw: string): SmsDeliveryStatus {
|
||||
return STATUS_MAP[raw.toLowerCase()] ?? "failed";
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendCallOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
recipients: [message.to],
|
||||
body: message.body,
|
||||
};
|
||||
if (message.from !== undefined) {
|
||||
body["originator"] = message.from;
|
||||
}
|
||||
const res = await httpRequest<MbMessageResource>({
|
||||
method: "POST",
|
||||
url: `${this.baseUrl}/messages`,
|
||||
headers: this.authHeader(),
|
||||
body,
|
||||
provider: PROVIDER,
|
||||
...this.common(options.signal),
|
||||
});
|
||||
const first = res.data.recipients.items[0];
|
||||
const status = first ? this.mapStatus(first.status) : "queued";
|
||||
recordSend(PROVIDER, "ok");
|
||||
return {
|
||||
messageId: res.data.id,
|
||||
to: String(first?.recipient ?? message.to),
|
||||
status,
|
||||
timestamp: first?.statusDatetime
|
||||
? new Date(first.statusDatetime)
|
||||
: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
recordSend(PROVIDER, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
recordSendDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
const res = await httpRequest<MbMessageResource>({
|
||||
method: "GET",
|
||||
url: `${this.baseUrl}/messages/${encodeURIComponent(messageId)}`,
|
||||
headers: this.authHeader(),
|
||||
provider: PROVIDER,
|
||||
...this.common(undefined),
|
||||
});
|
||||
const first = res.data.recipients.items[0];
|
||||
const status = first ? this.mapStatus(first.status) : "queued";
|
||||
return {
|
||||
messageId: res.data.id,
|
||||
to: String(first?.recipient ?? ""),
|
||||
status,
|
||||
timestamp: first?.statusDatetime
|
||||
? new Date(first.statusDatetime)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a MessageBird signed-webhook JWT (HS256). The JWT carries
|
||||
* `iat`/`exp`/`nbf` (epoch seconds) and a `payload_hash` claim
|
||||
* (SHA-256 hex of the raw request body, when present); we re-MAC
|
||||
* the canonical `header.payload` and constant-time compare.
|
||||
*
|
||||
* `nowMs` is injectable so tests can pin the clock.
|
||||
*/
|
||||
public async verifyWebhookSignature(
|
||||
token: string,
|
||||
signingKey: string,
|
||||
options: {
|
||||
readonly bodyHashHex?: string;
|
||||
readonly nowMs?: number;
|
||||
readonly toleranceMs?: number;
|
||||
} = {}
|
||||
): Promise<boolean> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "malformed JWT");
|
||||
}
|
||||
const [headerB64, payloadB64, sigB64] = parts as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const headerStr = decodeJwtSegment(headerB64);
|
||||
const payloadStr = decodeJwtSegment(payloadB64);
|
||||
let header: Record<string, unknown>;
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
header = JSON.parse(headerStr) as Record<string, unknown>;
|
||||
payload = JSON.parse(payloadStr) as Record<string, unknown>;
|
||||
} catch {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "JWT JSON parse failure");
|
||||
}
|
||||
if (header["alg"] !== "HS256") {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "unexpected JWT alg");
|
||||
}
|
||||
const nowSec = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
||||
const tolSec = Math.floor((options.toleranceMs ?? 300_000) / 1000);
|
||||
const iat = numberClaim(payload, "iat");
|
||||
const exp = numberClaim(payload, "exp");
|
||||
const nbf = numberClaim(payload, "nbf");
|
||||
if (iat !== undefined && iat > nowSec + tolSec) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "JWT iat in the future");
|
||||
}
|
||||
if (exp !== undefined && exp + tolSec < nowSec) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "JWT expired");
|
||||
}
|
||||
if (nbf !== undefined && nbf > nowSec + tolSec) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "JWT not yet valid");
|
||||
}
|
||||
if (
|
||||
options.bodyHashHex !== undefined &&
|
||||
payload["payload_hash"] !== undefined &&
|
||||
payload["payload_hash"] !== options.bodyHashHex
|
||||
) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "payload_hash mismatch");
|
||||
}
|
||||
const expectedSig = await this.hmac(
|
||||
"SHA-256",
|
||||
utf8(signingKey),
|
||||
utf8(`${headerB64}.${payloadB64}`)
|
||||
);
|
||||
const decoded = fromBase64(b64UrlToB64(sigB64));
|
||||
const ok = constantTimeEqual(expectedSig, decoded);
|
||||
if (!ok) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(
|
||||
PROVIDER,
|
||||
"JWT HS256 signature mismatch"
|
||||
);
|
||||
}
|
||||
// Touch the encode helper so the codec stays exercised.
|
||||
toBase64(expectedSig);
|
||||
return true;
|
||||
} finally {
|
||||
recordWebhookVerifyDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a MessageBird-shaped inbound JSON body. */
|
||||
public parseInbound(
|
||||
body: Readonly<Record<string, unknown>>
|
||||
): InboundSmsEvent {
|
||||
if (
|
||||
typeof body["id"] === "string" &&
|
||||
typeof body["recipient"] === "string" &&
|
||||
typeof body["status"] === "string"
|
||||
) {
|
||||
const errorCode =
|
||||
typeof body["statusErrorCode"] === "string"
|
||||
? body["statusErrorCode"]
|
||||
: undefined;
|
||||
return {
|
||||
type: "delivery_receipt",
|
||||
messageId: body["id"],
|
||||
status: this.mapStatus(body["status"]),
|
||||
...(errorCode !== undefined ? { errorCode } : {}),
|
||||
};
|
||||
}
|
||||
const from = String(body["originator"] ?? "");
|
||||
const to = String(body["recipient"] ?? "");
|
||||
const text = String(body["payload"] ?? body["body"] ?? "");
|
||||
const keyword = parseOptOutKeyword(text);
|
||||
if (keyword === "opt_out") {
|
||||
return { type: "opt_out", from };
|
||||
}
|
||||
if (keyword === "opt_in") {
|
||||
return { type: "opt_in", from };
|
||||
}
|
||||
if (keyword === "help") {
|
||||
return { type: "help", from };
|
||||
}
|
||||
return {
|
||||
type: "message_received",
|
||||
from,
|
||||
to,
|
||||
body: text,
|
||||
...(typeof body["id"] === "string"
|
||||
? { providerId: body["id"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private authHeader(): Record<string, string> {
|
||||
return { Authorization: `AccessKey ${this.config.accessKey}` };
|
||||
}
|
||||
|
||||
private common(signal: AbortSignal | undefined): {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} {
|
||||
const out: {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} = {};
|
||||
if (this.fetch !== undefined) {
|
||||
out.fetch = this.fetch;
|
||||
}
|
||||
const s = signal ?? this.config.signal;
|
||||
if (s !== undefined) {
|
||||
out.signal = s;
|
||||
}
|
||||
if (this.config.timeoutMs !== undefined) {
|
||||
out.timeoutMs = this.config.timeoutMs;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function b64UrlToB64(input: string): string {
|
||||
let out = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (out.length % 4 !== 0) {
|
||||
out += "=";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeJwtSegment(segment: string): string {
|
||||
const b64 = b64UrlToB64(segment);
|
||||
const bytes = fromBase64(b64);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function numberClaim(
|
||||
payload: Record<string, unknown>,
|
||||
key: string
|
||||
): number | undefined {
|
||||
const v = payload[key];
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { SendOutcome, WebhookOutcome } from "./otel.js";
|
||||
export {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhook,
|
||||
recordWebhookVerifyDuration,
|
||||
refreshSmsProvidersMeters,
|
||||
} from "./otel.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# OTel — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider class performs: outbound send (count + duration), inbound webhook events (count + verification duration), and signature-failure counter. The module exposes typed `recordSend`/`recordWebhook`/`recordSignatureFailure`/`recordSendDuration`/`recordWebhookVerifyDuration` helpers so providers stay free of inline OTel boilerplate, and a `refreshSmsProvidersMeters()` helper that re-resolves every instrument against the current global `MeterProvider` — tests install a fresh in-memory reader, call `refresh`, and then drive the helpers to assert on the captured metric points.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly five instruments — three counters (`send_total`, `webhook_total`, `signature_failures_total`) and two histograms (`send_duration_ms`, `webhook_verify_duration_ms`).
|
||||
- Every counter carries a `provider` label so multi-provider deployments split cleanly in Grafana / Honeycomb.
|
||||
- `outcome` (`"ok"` / `"error"`) on `send_total` and `webhook_total` lets dashboards compute success rate as `1 - error / total`.
|
||||
- `webhook_total` adds an `event` label (`message_received` | `delivery_receipt` | `opt_in` | `opt_out` | `help`).
|
||||
- Histograms record in `ms`.
|
||||
- `refreshSmsProvidersMeters()` is the test seam — module-load resolves the global meter once, this resets it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
/* call provider */
|
||||
recordSend("twilio", "ok");
|
||||
} catch {
|
||||
recordSend("twilio", "error");
|
||||
throw;
|
||||
} finally {
|
||||
recordSendDuration("twilio", Date.now() - t0);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSignatureFailure,
|
||||
recordWebhook,
|
||||
recordWebhookVerifyDuration,
|
||||
refreshSmsProvidersMeters,
|
||||
} from "@suaveplan/sms-providers";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
// Test-only: swap the global meter for a recording one.
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSmsProvidersMeters();
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
// verifyWebhookSignature(...)
|
||||
} catch (err) {
|
||||
recordSignatureFailure("plivo");
|
||||
throw err;
|
||||
} finally {
|
||||
recordWebhookVerifyDuration("plivo", performance.now() - t0);
|
||||
}
|
||||
|
||||
recordWebhook("vonage", "delivery_receipt", "ok");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `recordSend(provider, outcome)` | function | Counter `sms_providers.send_total`. |
|
||||
| `recordWebhook(provider, event, outcome)` | function | Counter `sms_providers.webhook_total`. |
|
||||
| `recordSignatureFailure(provider)` | function | Counter `sms_providers.signature_failures_total`. |
|
||||
| `recordSendDuration(provider, ms)` | function | Histogram `sms_providers.send_duration_ms`. |
|
||||
| `recordWebhookVerifyDuration(provider, ms)` | function | Histogram `sms_providers.webhook_verify_duration_ms`. |
|
||||
| `refreshSmsProvidersMeters()` | function | Re-resolves every instrument against the current global meter. |
|
||||
| `SendOutcome` / `WebhookOutcome` | type | `"ok" \| "error"`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Instruments are held as `let`-bound module-locals so `refreshSmsProvidersMeters()` can reassign them after `metrics.setGlobalMeterProvider(...)` — re-resolving is necessary because OTel's `Meter` proxy snapshots the active provider at meter-creation time. The instrument names follow the OTel naming convention (`<domain>.<metric>` with `_total` suffix on monotonic counters).
|
||||
@@ -0,0 +1,80 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhook,
|
||||
recordWebhookVerifyDuration,
|
||||
refreshSmsProvidersMeters,
|
||||
} from "./otel.js";
|
||||
|
||||
describe("otel — helpers do not throw against the default no-op meter", () => {
|
||||
it("invokes every helper", { timeout: 5000 }, () => {
|
||||
expect(() => recordSend("twilio", "ok")).not.toThrow();
|
||||
expect(() => recordSend("twilio", "error")).not.toThrow();
|
||||
expect(() =>
|
||||
recordWebhook("vonage", "delivery_receipt", "ok")
|
||||
).not.toThrow();
|
||||
expect(() => recordSignatureFailure("plivo")).not.toThrow();
|
||||
expect(() => recordSendDuration("twilio", 12)).not.toThrow();
|
||||
expect(() => recordWebhookVerifyDuration("aws-sns", 4)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("otel — refreshSmsProvidersMeters re-binds against the global provider", () => {
|
||||
it("rebind is idempotent under repeated calls", { timeout: 5000 }, () => {
|
||||
refreshSmsProvidersMeters();
|
||||
refreshSmsProvidersMeters();
|
||||
expect(() => recordSend("twilio", "ok")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("otel — instruments record against an installed MeterProvider", () => {
|
||||
it("emits all five instruments after refresh", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSmsProvidersMeters();
|
||||
|
||||
recordSend("twilio", "ok");
|
||||
recordWebhook("vonage", "delivery_receipt", "ok");
|
||||
recordSignatureFailure("plivo");
|
||||
recordSendDuration("twilio", 12);
|
||||
recordWebhookVerifyDuration("aws-sns", 4);
|
||||
|
||||
await provider.forceFlush();
|
||||
const exported = exporter.getMetrics();
|
||||
const names = new Set<string>();
|
||||
for (const resource of exported) {
|
||||
for (const scope of resource.scopeMetrics) {
|
||||
for (const metric of scope.metrics) {
|
||||
names.add(metric.descriptor.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(names.has("sms_providers.send_total")).toBe(true);
|
||||
expect(names.has("sms_providers.webhook_total")).toBe(true);
|
||||
expect(names.has("sms_providers.signature_failures_total")).toBe(true);
|
||||
expect(names.has("sms_providers.send_duration_ms")).toBe(true);
|
||||
expect(names.has("sms_providers.webhook_verify_duration_ms")).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
await provider.shutdown();
|
||||
});
|
||||
});
|
||||
100
packages/communication/messaging/sms-providers/src/otel/otel.ts
Normal file
100
packages/communication/messaging/sms-providers/src/otel/otel.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/sms-providers";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let sendTotal: Counter = meter.createCounter("sms_providers.send_total", {
|
||||
description: "Count of provider send attempts by outcome",
|
||||
});
|
||||
let webhookTotal: Counter = meter.createCounter("sms_providers.webhook_total", {
|
||||
description: "Count of inbound provider webhook events by event + outcome",
|
||||
});
|
||||
let signatureFailuresTotal: Counter = meter.createCounter(
|
||||
"sms_providers.signature_failures_total",
|
||||
{ description: "Count of inbound webhook signature verification failures" }
|
||||
);
|
||||
let sendDuration: Histogram = meter.createHistogram(
|
||||
"sms_providers.send_duration_ms",
|
||||
{ description: "Provider send latency in milliseconds", unit: "ms" }
|
||||
);
|
||||
let webhookVerifyDuration: Histogram = meter.createHistogram(
|
||||
"sms_providers.webhook_verify_duration_ms",
|
||||
{
|
||||
description: "Webhook signature verification latency in ms",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global `MeterProvider`.
|
||||
* Tests call this after installing a fresh in-memory reader so
|
||||
* subsequent record calls land on the test provider rather than the
|
||||
* module-load meter.
|
||||
*/
|
||||
export function refreshSmsProvidersMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
sendTotal = meter.createCounter("sms_providers.send_total", {
|
||||
description: "Count of provider send attempts by outcome",
|
||||
});
|
||||
webhookTotal = meter.createCounter("sms_providers.webhook_total", {
|
||||
description:
|
||||
"Count of inbound provider webhook events by event + outcome",
|
||||
});
|
||||
signatureFailuresTotal = meter.createCounter(
|
||||
"sms_providers.signature_failures_total",
|
||||
{
|
||||
description:
|
||||
"Count of inbound webhook signature verification failures",
|
||||
}
|
||||
);
|
||||
sendDuration = meter.createHistogram("sms_providers.send_duration_ms", {
|
||||
description: "Provider send latency in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
webhookVerifyDuration = meter.createHistogram(
|
||||
"sms_providers.webhook_verify_duration_ms",
|
||||
{
|
||||
description: "Webhook signature verification latency in ms",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Outcome label values. */
|
||||
export type SendOutcome = "ok" | "error";
|
||||
export type WebhookOutcome = "ok" | "error";
|
||||
|
||||
/** Increment `sms_providers.send_total{provider, outcome}`. */
|
||||
export function recordSend(provider: string, outcome: SendOutcome): void {
|
||||
sendTotal.add(1, { provider, outcome });
|
||||
}
|
||||
|
||||
/** Increment `sms_providers.webhook_total{provider, event, outcome}`. */
|
||||
export function recordWebhook(
|
||||
provider: string,
|
||||
event: string,
|
||||
outcome: WebhookOutcome
|
||||
): void {
|
||||
webhookTotal.add(1, { provider, event, outcome });
|
||||
}
|
||||
|
||||
/** Increment `sms_providers.signature_failures_total{provider}`. */
|
||||
export function recordSignatureFailure(provider: string): void {
|
||||
signatureFailuresTotal.add(1, { provider });
|
||||
}
|
||||
|
||||
/** Record `sms_providers.send_duration_ms{provider}`. */
|
||||
export function recordSendDuration(provider: string, durationMs: number): void {
|
||||
sendDuration.record(durationMs, { provider });
|
||||
}
|
||||
|
||||
/** Record `sms_providers.webhook_verify_duration_ms{provider}`. */
|
||||
export function recordWebhookVerifyDuration(
|
||||
provider: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
webhookVerifyDuration.record(durationMs, { provider });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PlivoProvider } from "./plivo.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# Plivo — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` authenticated via HTTP Basic (`AuthId:AuthToken`); JSON body. Status fetched via `GET .../Message/{uuid}/`. Inbound webhook signature verification follows Plivo's documented `X-Plivo-Signature-V3` scheme — HMAC-SHA256 over the canonical signing string `<url><nonce><sorted form-fields joined as key=value, comma-separated>` with the auth token as the key, Base-64 encoded.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- HTTP Basic auth with the canonical `AuthId:AuthToken` pair.
|
||||
- JSON request body — `src`, `dst`, `text` (and `media_urls`/`type=mms` when MMS).
|
||||
- `getStatus(uuid)` round-trips the documented `.../Message/{uuid}/` resource.
|
||||
- Status mapping covers `queued` / `sent` / `delivered` / `undelivered` / `failed` / `rejected`.
|
||||
- `verifyWebhookSignature(url, nonce, params, sig)` implements the X-Plivo-Signature-V3 canonical string scheme.
|
||||
- `parseInbound` discriminates between inbound messages, delivery receipts, and opt-out/opt-in/help keywords.
|
||||
- OTel — counter + histogram on every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PlivoProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const plivo = new PlivoProvider({
|
||||
authId: "MA…",
|
||||
authToken: "…",
|
||||
});
|
||||
const r = await plivo.send({
|
||||
to: "+15551234567",
|
||||
from: "+15559999999",
|
||||
body: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PlivoProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const plivo = new PlivoProvider({ authId: "MA…", authToken: "…" });
|
||||
const ok = await plivo.verifyWebhookSignature(
|
||||
"https://your.app/webhooks/plivo",
|
||||
"nonce-from-X-Plivo-Signature-Ma-Nonce-Header",
|
||||
{ From: "+1", To: "+2", Text: "hi", MessageUUID: "uuid" },
|
||||
"<base64 signature>"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `PlivoProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | POST `.../Message/`; returns `SmsDeliveryReport`. |
|
||||
| `getStatus(uuid)` | method | `GET .../Message/{uuid}/`. |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyWebhookSignature(url, nonce, params, sig)` | method | HMAC-SHA256-Base-64 verifier; throws `SIGNATURE_INVALID` on mismatch. |
|
||||
| `parseInbound(params)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`send` defaults the initial status to `"queued"` because Plivo's create response only returns the message UUID; the actual carrier-side state is later reported on the inbound DLR webhook. MMS support flips `type: "mms"` and forwards `mediaUrls` as `media_urls[]` per the documented Plivo convention.
|
||||
@@ -0,0 +1,300 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "../../tests/setup/http-server.js";
|
||||
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
||||
import type { OutboundMessage } from "../types/provider.js";
|
||||
import { PlivoProvider } from "./plivo.js";
|
||||
|
||||
let server: RecordingServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function makeProvider(config: { authId?: string; authToken?: string } = {}) {
|
||||
return new PlivoProvider({
|
||||
authId: config.authId ?? "A1",
|
||||
authToken: config.authToken ?? "T1",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
const SEND_RESPONSE = {
|
||||
api_id: "api-uuid",
|
||||
message: "message(s) queued",
|
||||
message_uuid: ["msg-uuid-1"],
|
||||
};
|
||||
|
||||
const GET_RESPONSE = {
|
||||
api_id: "api-uuid",
|
||||
message_uuid: "msg-uuid-1",
|
||||
to_number: "+15551234567",
|
||||
message_state: "delivered",
|
||||
message_time: "2026-04-24T10:00:00Z",
|
||||
error_code: "000",
|
||||
};
|
||||
|
||||
describe("PlivoProvider — construction + send", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(SEND_RESPONSE, 202));
|
||||
});
|
||||
|
||||
it("requires authId + authToken", { timeout: 5000 }, () => {
|
||||
expect(() => makeProvider({ authId: "" })).toThrow();
|
||||
expect(() => makeProvider({ authToken: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("POSTs Account/{id}/Message/ with Basic auth + JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.send({
|
||||
to: "+15551234567",
|
||||
from: "+15559999999",
|
||||
body: "hi",
|
||||
});
|
||||
const req = server.requests[0]!;
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/v1/Account/A1/Message/");
|
||||
expect(req.headers.authorization).toBe(
|
||||
`Basic ${Buffer.from("A1:T1").toString("base64")}`
|
||||
);
|
||||
expect(req.headers["content-type"]).toContain("application/json");
|
||||
const parsed = JSON.parse(req.body) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({
|
||||
src: "+15559999999",
|
||||
dst: "+15551234567",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.messageId).toBe("msg-uuid-1");
|
||||
expect(r.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("forwards MMS media + flips type to mms", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({
|
||||
to: "+1",
|
||||
from: "+1",
|
||||
body: "hi",
|
||||
mediaUrls: ["https://cdn/a.png"],
|
||||
} as OutboundMessage);
|
||||
const parsed = JSON.parse(server.requests[0]!.body) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(parsed["media_urls"]).toEqual(["https://cdn/a.png"]);
|
||||
expect(parsed["type"]).toBe("mms");
|
||||
});
|
||||
|
||||
it("defaults src to empty when from is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({ to: "+1", body: "hi" });
|
||||
const parsed = JSON.parse(server.requests[0]!.body) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(parsed["src"]).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty messageId when message_uuid[] is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ ...SEND_RESPONSE, message_uuid: [] }, 202)
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+1", from: "+1", body: "hi" });
|
||||
expect(r.messageId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlivoProvider — getStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(GET_RESPONSE));
|
||||
});
|
||||
|
||||
it("GETs the Message/{uuid}/ resource", { timeout: 5000 }, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.getStatus("msg-uuid-1");
|
||||
expect(server.requests[0]!.path).toBe(
|
||||
"/v1/Account/A1/Message/msg-uuid-1/"
|
||||
);
|
||||
expect(r.status).toBe("delivered");
|
||||
expect(r.errorCode).toBe("000");
|
||||
});
|
||||
|
||||
it("defaults the timestamp when message_time is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ ...GET_RESPONSE, message_time: undefined })
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.getStatus("msg");
|
||||
expect(r.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlivoProvider — mapStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "queued"],
|
||||
["sent", "sent"],
|
||||
["delivered", "delivered"],
|
||||
["undelivered", "undelivered"],
|
||||
["failed", "failed"],
|
||||
["rejected", "failed"],
|
||||
["wat", "failed"],
|
||||
])("%s → %s", { timeout: 5000 }, (raw, mapped) => {
|
||||
expect(makeProvider().mapStatus(raw)).toBe(mapped);
|
||||
});
|
||||
});
|
||||
|
||||
function plivoSign(
|
||||
token: string,
|
||||
url: string,
|
||||
nonce: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
const pairs = sortedKeys.map((k) => `${k}=${params[k]}`).join(",");
|
||||
return createHmac("sha256", token)
|
||||
.update(`${url}${nonce}${pairs}`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
describe("PlivoProvider — verifyWebhookSignature", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("accepts a valid signature", { timeout: 5000 }, async () => {
|
||||
const url = "https://app/webhooks/plivo";
|
||||
const nonce = "n123";
|
||||
const params = { From: "+1", To: "+2", Text: "hi", MessageUUID: "u" };
|
||||
const sig = plivoSign("T1", url, nonce, params);
|
||||
const ok = await makeProvider().verifyWebhookSignature(
|
||||
url,
|
||||
nonce,
|
||||
params,
|
||||
sig
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", { timeout: 5000 }, async () => {
|
||||
const url = "https://app/webhooks/plivo";
|
||||
const sig = plivoSign("T1", url, "n", { a: "1" });
|
||||
await expect(
|
||||
makeProvider().verifyWebhookSignature(
|
||||
url,
|
||||
"n",
|
||||
{ a: "1" },
|
||||
`${sig.slice(0, -1)}!`
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlivoProvider — parseInbound", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("identifies delivery receipts", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
MessageUUID: "u",
|
||||
Status: "delivered",
|
||||
ErrorCode: "000",
|
||||
})
|
||||
).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "u",
|
||||
status: "delivered",
|
||||
errorCode: "000",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies DLR without errorCode when ErrorCode absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
MessageUUID: "u",
|
||||
Status: "delivered",
|
||||
})
|
||||
).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "u",
|
||||
status: "delivered",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies STOP / YES / HELP", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
expect(p.parseInbound({ From: "+1", Text: "stop" })).toMatchObject({
|
||||
type: "opt_out",
|
||||
});
|
||||
expect(p.parseInbound({ From: "+1", Text: "YES" })).toMatchObject({
|
||||
type: "opt_in",
|
||||
});
|
||||
expect(p.parseInbound({ From: "+1", Text: "HELP" })).toMatchObject({
|
||||
type: "help",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns message_received with providerId when MessageUUID present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
From: "+1",
|
||||
To: "+2",
|
||||
Text: "free-form",
|
||||
MessageUUID: "u",
|
||||
})
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "free-form",
|
||||
providerId: "u",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to lowercase 'text' when 'Text' is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({ From: "+1", text: "lower" })
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "+1",
|
||||
to: "",
|
||||
body: "lower",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsSendCallOptions,
|
||||
} from "@suaveplan/sms";
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
toBase64,
|
||||
utf8,
|
||||
} from "../crypto/default-hmac.js";
|
||||
import { configMissing, signatureInvalid } from "../error-codes/error-codes.js";
|
||||
import { httpRequest } from "../http/http.js";
|
||||
import { parseOptOutKeyword } from "../keywords/keywords.js";
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhookVerifyDuration,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
OutboundMessage,
|
||||
PlivoConfig,
|
||||
} from "../types/provider.js";
|
||||
|
||||
const PROVIDER = "plivo" as const;
|
||||
const DEFAULT_BASE_URL = "https://api.plivo.com";
|
||||
|
||||
const STATUS_MAP: Readonly<Record<string, SmsDeliveryStatus>> = Object.freeze({
|
||||
queued: "queued",
|
||||
sent: "sent",
|
||||
delivered: "delivered",
|
||||
undelivered: "undelivered",
|
||||
failed: "failed",
|
||||
rejected: "failed",
|
||||
});
|
||||
|
||||
interface PlivoCreateResponse {
|
||||
readonly api_id: string;
|
||||
readonly message: string;
|
||||
readonly message_uuid: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface PlivoGetResponse {
|
||||
readonly api_id: string;
|
||||
readonly message_uuid: string;
|
||||
readonly to_number: string;
|
||||
readonly message_state: string;
|
||||
readonly error_code?: string;
|
||||
readonly message_time?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Plivo SMS provider. POSTs to `/v1/Account/{AuthId}/Message/`
|
||||
* authenticated via HTTP Basic (`AuthId:AuthToken`); JSON body. Status
|
||||
* fetched via `GET .../Message/{uuid}/`. Inbound webhook signature
|
||||
* verification follows Plivo's documented `X-Plivo-Signature-V3`
|
||||
* scheme: HMAC-SHA256 over `<url><nonce><sortedForm-joined>` Base-64
|
||||
* encoded.
|
||||
*/
|
||||
export class PlivoProvider implements SmsProvider {
|
||||
public readonly name = PROVIDER;
|
||||
private readonly config: PlivoConfig;
|
||||
private readonly baseUrl: string;
|
||||
private readonly hmac: HmacSign;
|
||||
private readonly fetch: FetchLike | undefined;
|
||||
|
||||
public constructor(config: PlivoConfig) {
|
||||
if (!config.authId) {
|
||||
throw configMissing(PROVIDER, "authId");
|
||||
}
|
||||
if (!config.authToken) {
|
||||
throw configMissing(PROVIDER, "authToken");
|
||||
}
|
||||
this.config = config;
|
||||
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
this.hmac = config.hmac ?? defaultHmac;
|
||||
this.fetch = config.fetch;
|
||||
}
|
||||
|
||||
public mapStatus(raw: string): SmsDeliveryStatus {
|
||||
return STATUS_MAP[raw.toLowerCase()] ?? "failed";
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendCallOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const url = `${this.baseUrl}/v1/Account/${encodeURIComponent(this.config.authId)}/Message/`;
|
||||
const body: Record<string, unknown> = {
|
||||
src: message.from ?? "",
|
||||
dst: message.to,
|
||||
text: message.body,
|
||||
};
|
||||
const extras = message as OutboundMessage;
|
||||
if (extras.mediaUrls !== undefined) {
|
||||
body["media_urls"] = extras.mediaUrls;
|
||||
body["type"] = "mms";
|
||||
}
|
||||
const res = await httpRequest<PlivoCreateResponse>({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: { Authorization: this.basicAuth() },
|
||||
body,
|
||||
provider: PROVIDER,
|
||||
...this.common(options.signal),
|
||||
});
|
||||
const id = res.data.message_uuid[0] ?? "";
|
||||
recordSend(PROVIDER, "ok");
|
||||
return {
|
||||
messageId: id,
|
||||
to: message.to,
|
||||
status: "queued",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
recordSend(PROVIDER, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
recordSendDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
const res = await httpRequest<PlivoGetResponse>({
|
||||
method: "GET",
|
||||
url: `${this.baseUrl}/v1/Account/${encodeURIComponent(this.config.authId)}/Message/${encodeURIComponent(messageId)}/`,
|
||||
headers: { Authorization: this.basicAuth() },
|
||||
provider: PROVIDER,
|
||||
...this.common(undefined),
|
||||
});
|
||||
return {
|
||||
messageId: res.data.message_uuid,
|
||||
to: res.data.to_number,
|
||||
status: this.mapStatus(res.data.message_state),
|
||||
timestamp: res.data.message_time
|
||||
? new Date(res.data.message_time)
|
||||
: new Date(),
|
||||
...(res.data.error_code !== undefined
|
||||
? { errorCode: res.data.error_code }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Plivo signed webhook per X-Plivo-Signature-V3: HMAC-
|
||||
* SHA256 over `<url><nonce>[sorted form fields joined as
|
||||
* key=value, comma-separated]` with the auth token as the key,
|
||||
* Base-64-encoded; constant-time compared against the supplied
|
||||
* signature.
|
||||
*/
|
||||
public async verifyWebhookSignature(
|
||||
url: string,
|
||||
nonce: string,
|
||||
params: Readonly<Record<string, string>>,
|
||||
signature: string
|
||||
): Promise<boolean> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
const formPairs = sortedKeys.map((k) => `${k}=${params[k]}`);
|
||||
const payload = `${url}${nonce}${formPairs.join(",")}`;
|
||||
const sig = await this.hmac(
|
||||
"SHA-256",
|
||||
utf8(this.config.authToken),
|
||||
utf8(payload)
|
||||
);
|
||||
const expected = toBase64(sig);
|
||||
const ok = constantTimeEqual(utf8(expected), utf8(signature));
|
||||
if (!ok) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(
|
||||
PROVIDER,
|
||||
"X-Plivo-Signature-V3 mismatch"
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
recordWebhookVerifyDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
public parseInbound(
|
||||
params: Readonly<Record<string, string>>
|
||||
): InboundSmsEvent {
|
||||
if (
|
||||
params["MessageUUID"] !== undefined &&
|
||||
params["Status"] !== undefined
|
||||
) {
|
||||
return {
|
||||
type: "delivery_receipt",
|
||||
messageId: params["MessageUUID"],
|
||||
status: this.mapStatus(params["Status"]),
|
||||
...(params["ErrorCode"] !== undefined
|
||||
? { errorCode: params["ErrorCode"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const from = params["From"] ?? "";
|
||||
const to = params["To"] ?? "";
|
||||
const text = params["Text"] ?? params["text"] ?? "";
|
||||
const keyword = parseOptOutKeyword(text);
|
||||
if (keyword === "opt_out") {
|
||||
return { type: "opt_out", from };
|
||||
}
|
||||
if (keyword === "opt_in") {
|
||||
return { type: "opt_in", from };
|
||||
}
|
||||
if (keyword === "help") {
|
||||
return { type: "help", from };
|
||||
}
|
||||
return {
|
||||
type: "message_received",
|
||||
from,
|
||||
to,
|
||||
body: text,
|
||||
...(params["MessageUUID"] !== undefined
|
||||
? { providerId: params["MessageUUID"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private basicAuth(): string {
|
||||
return `Basic ${toBase64(
|
||||
utf8(`${this.config.authId}:${this.config.authToken}`)
|
||||
)}`;
|
||||
}
|
||||
|
||||
private common(signal: AbortSignal | undefined): {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} {
|
||||
const out: {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} = {};
|
||||
if (this.fetch !== undefined) {
|
||||
out.fetch = this.fetch;
|
||||
}
|
||||
const s = signal ?? this.config.signal;
|
||||
if (s !== undefined) {
|
||||
out.signal = s;
|
||||
}
|
||||
if (this.config.timeoutMs !== undefined) {
|
||||
out.timeoutMs = this.config.timeoutMs;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProviderRegistry } from "./registry.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# Registry — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling provider classes (`TwilioProvider`, `VonageProvider`, `MessageBirdProvider`, `PlivoProvider`, `AwsSnsProvider`) contribute instances; consumers — typically the `@suaveplan/sms` `SmsService` or a higher-level routing layer such as the campaigns API plugin — register the ones they intend to route to and look them up by string name.
|
||||
|
||||
The registry intentionally has no persistence, no auto-discovery, and no async lifecycle. Those concerns belong to higher-level orchestrators. The registry's contract is small enough to fit in a single file and complete enough that the strict register/lookup semantics (throwing on duplicate names + throwing on missing names) prevent the two most common configuration mistakes silently corrupting routing.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict `register` — throws `SmsProvidersError(PROVIDER_ALREADY_REGISTERED)` on duplicate names.
|
||||
- Strict `get` — throws `SmsProvidersError(PROVIDER_NOT_REGISTERED)` on absent names.
|
||||
- Idempotent `unregister(name)` — returns `true` when a provider was removed, `false` otherwise.
|
||||
- `has(name)` for soft lookup before a `get`.
|
||||
- `list()` returns a frozen, insertion-ordered snapshot — callers cannot mutate the registry through the returned array.
|
||||
- `size` reports the current cardinality.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry, TwilioProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new TwilioProvider({
|
||||
accountSid: "ACxxxxx",
|
||||
authToken: "xxxxx",
|
||||
}));
|
||||
|
||||
const twilio = registry.get("twilio");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AwsSnsProvider,
|
||||
MessageBirdProvider,
|
||||
PlivoProvider,
|
||||
ProviderRegistry,
|
||||
TwilioProvider,
|
||||
VonageProvider,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
for (const p of [
|
||||
new TwilioProvider({ accountSid: "AC", authToken: "t" }),
|
||||
new VonageProvider({ apiKey: "k", apiSecret: "s" }),
|
||||
new MessageBirdProvider({ accessKey: "K" }),
|
||||
new PlivoProvider({ authId: "A", authToken: "T" }),
|
||||
new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
}),
|
||||
]) {
|
||||
registry.register(p);
|
||||
}
|
||||
|
||||
function chooseProvider(prefix: string): string {
|
||||
if (prefix.startsWith("+1")) {
|
||||
return "twilio";
|
||||
}
|
||||
if (prefix.startsWith("+44")) {
|
||||
return "vonage";
|
||||
}
|
||||
return "aws-sns";
|
||||
}
|
||||
|
||||
const provider = registry.get(chooseProvider("+15551234567"));
|
||||
await provider.send({ to: "+15551234567", body: "hi", from: "+15550000000" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ProviderRegistry` | class | Synchronous Map-backed registry. |
|
||||
| `register(p)` | method | Throws on duplicate `p.name`. |
|
||||
| `unregister(name)` | method | Returns `true` if removed. |
|
||||
| `get(name)` | method | Throws on missing `name`. |
|
||||
| `has(name)` | method | Boolean. |
|
||||
| `list()` | method | Frozen, insertion-ordered `readonly SmsProvider[]`. |
|
||||
| `size` | getter | `number`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry holds a `Map<string, SmsProvider>` (insertion-ordered by spec). `list()` freezes the returned array so a caller mutating it cannot retroactively remove a provider; the underlying `Map` retains insertion order even after `unregister(name)`.
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SmsProvider } from "@suaveplan/sms";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
||||
import { ProviderRegistry } from "./registry.js";
|
||||
|
||||
function stubProvider(name: string): SmsProvider {
|
||||
return {
|
||||
name,
|
||||
async send() {
|
||||
throw new Error("stub");
|
||||
},
|
||||
async getStatus() {
|
||||
throw new Error("stub");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProviderRegistry", () => {
|
||||
it("registers and retrieves a provider by name", { timeout: 5000 }, () => {
|
||||
const r = new ProviderRegistry();
|
||||
const p = stubProvider("twilio");
|
||||
r.register(p);
|
||||
expect(r.get("twilio")).toBe(p);
|
||||
expect(r.has("twilio")).toBe(true);
|
||||
expect(r.size).toBe(1);
|
||||
});
|
||||
|
||||
it("throws PROVIDER_ALREADY_REGISTERED on duplicate names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ProviderRegistry();
|
||||
r.register(stubProvider("p"));
|
||||
try {
|
||||
r.register(stubProvider("p"));
|
||||
throw new Error("expected duplicate to throw");
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.PROVIDER_ALREADY_REGISTERED,
|
||||
provider: "p",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("throws PROVIDER_NOT_REGISTERED on missing names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ProviderRegistry();
|
||||
try {
|
||||
r.get("missing");
|
||||
throw new Error("expected miss to throw");
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.PROVIDER_NOT_REGISTERED,
|
||||
provider: "missing",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("unregister returns true/false and is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ProviderRegistry();
|
||||
r.register(stubProvider("p"));
|
||||
expect(r.unregister("p")).toBe(true);
|
||||
expect(r.unregister("p")).toBe(false);
|
||||
expect(r.has("p")).toBe(false);
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("list() returns a frozen, insertion-ordered snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ProviderRegistry();
|
||||
r.register(stubProvider("a"));
|
||||
r.register(stubProvider("b"));
|
||||
r.register(stubProvider("c"));
|
||||
const snapshot = r.list();
|
||||
expect(snapshot.map((p) => p.name)).toEqual(["a", "b", "c"]);
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { SmsProvider } from "@suaveplan/sms";
|
||||
import {
|
||||
providerAlreadyRegistered,
|
||||
providerNotRegistered,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Process-local, synchronous registry indexing `SmsProvider` instances
|
||||
* by `provider.name`. Sibling provider classes (`TwilioProvider`,
|
||||
* `VonageProvider`, `MessageBirdProvider`, `PlivoProvider`,
|
||||
* `AwsSnsProvider`) contribute instances; consumers register the ones
|
||||
* they intend to route to and look them up by string name.
|
||||
*
|
||||
* The registry intentionally has no persistence, no auto-discovery,
|
||||
* and no async lifecycle — those concerns belong to higher-level
|
||||
* orchestrators (e.g. `@suaveplan/sms`'s `SmsService`).
|
||||
*/
|
||||
export class ProviderRegistry {
|
||||
private readonly providers = new Map<string, SmsProvider>();
|
||||
|
||||
/**
|
||||
* Index a provider by `provider.name`. Throws
|
||||
* `SmsProvidersError(PROVIDER_ALREADY_REGISTERED)` when the name is
|
||||
* already present — explicit duplicate handling is preferable to
|
||||
* silent overwrite because two different provider instances
|
||||
* sharing a name almost always indicate a configuration mistake.
|
||||
*/
|
||||
public register(provider: SmsProvider): void {
|
||||
if (this.providers.has(provider.name)) {
|
||||
throw providerAlreadyRegistered(provider.name);
|
||||
}
|
||||
this.providers.set(provider.name, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider by name. Returns `true` when a provider was
|
||||
* removed and `false` otherwise — the second case is intentionally
|
||||
* non-throwing so callers can use the method as an idempotent
|
||||
* cleanup hook.
|
||||
*/
|
||||
public unregister(name: string): boolean {
|
||||
return this.providers.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider by name. Throws
|
||||
* `SmsProvidersError(PROVIDER_NOT_REGISTERED)` when absent.
|
||||
*/
|
||||
public get(name: string): SmsProvider {
|
||||
const provider = this.providers.get(name);
|
||||
if (provider === undefined) {
|
||||
throw providerNotRegistered(name);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** Report whether a provider with the given name is registered. */
|
||||
public has(name: string): boolean {
|
||||
return this.providers.has(name);
|
||||
}
|
||||
|
||||
/** Frozen snapshot of all registered providers in insertion order. */
|
||||
public list(): readonly SmsProvider[] {
|
||||
return Object.freeze(Array.from(this.providers.values()));
|
||||
}
|
||||
|
||||
/** Number of currently registered providers. */
|
||||
public get size(): number {
|
||||
return this.providers.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TwilioProvider } from "./twilio.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# Twilio — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-encoded HTTP requests against Twilio's documented Messages API (`POST /2010-04-01/Accounts/{AccountSid}/Messages.json`), normalises Twilio's status vocabulary into the canonical `SmsDeliveryStatus`, fetches delivery status via the corresponding `GET`, and verifies inbound webhooks per Twilio's `X-Twilio-Signature` HMAC-SHA1 scheme. MMS `MediaUrl[]` is forwarded as repeated `MediaUrl` form parameters per Twilio's wire convention.
|
||||
|
||||
The class holds no in-flight state: every `send` issues exactly one HTTP request through the injectable `FetchLike` seam. Tests bind a `node:http` server on a random port and inject a `fetch` adapter pointing at it; production resolves `globalThis.fetch.bind(globalThis)`.
|
||||
|
||||
## Features
|
||||
|
||||
- HTTP Basic auth (`Authorization: Basic base64(AccountSid:AuthToken)`).
|
||||
- Form-encoded body via `URLSearchParams` — Twilio rejects JSON bodies.
|
||||
- MMS `MediaUrl[]` forwarded as repeated `MediaUrl` params.
|
||||
- Canonical status mapping (`queued`, `sending`, `sent`, `delivered`, `undelivered`, `failed`, `canceled`, `receiving`, `received`, `accepted`, `scheduled`).
|
||||
- `getStatus(sid)` round-trips the same auth + URL convention.
|
||||
- `verifyWebhookSignature(url, params, signature)` implements the documented HMAC-SHA1-over-sorted-form scheme; mismatched signatures throw `SmsProvidersError(SIGNATURE_INVALID)` and increment `sms_providers.signature_failures_total{provider="twilio"}`.
|
||||
- `parseInbound(params)` discriminates between message bodies, delivery receipts, and opt-in/opt-out/help keywords using the canonical `parseOptOutKeyword` classifier.
|
||||
- OTel — every `send` is counted (`send_total`) and timed (`send_duration_ms`); every signature verify is timed (`webhook_verify_duration_ms`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TwilioProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const twilio = new TwilioProvider({
|
||||
accountSid: "AC…",
|
||||
authToken: "…",
|
||||
});
|
||||
const report = await twilio.send({
|
||||
to: "+15551234567",
|
||||
from: "+15559999999",
|
||||
body: "Hello",
|
||||
});
|
||||
console.log(report.messageId, report.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TwilioProvider, type FetchLike } from "@suaveplan/sms-providers";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// Stand up a real HTTP server, point Twilio's FetchLike at it.
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(201, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ sid: "SMxxx", status: "queued", to: "+1" }));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const { port } = server.address() as { port: number };
|
||||
const fetchImpl: FetchLike = async (url, init) =>
|
||||
globalThis.fetch(url.replace("https://api.twilio.com", `http://127.0.0.1:${port}`), init);
|
||||
|
||||
const twilio = new TwilioProvider({
|
||||
accountSid: "AC1",
|
||||
authToken: "tok",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const r = await twilio.send({
|
||||
to: "+15551234567",
|
||||
from: "+15550000000",
|
||||
body: "Hi",
|
||||
mediaUrls: ["https://cdn/example.png"],
|
||||
} as Parameters<typeof twilio.send>[0]);
|
||||
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `TwilioProvider` | class | Implements `SmsProvider` from `@suaveplan/sms`. |
|
||||
| `send(message, options?)` | method | Issues the POST and returns an `SmsDeliveryReport`. |
|
||||
| `getStatus(sid)` | method | Round-trips a `GET` for the message resource. |
|
||||
| `mapStatus(raw)` | method | Maps a Twilio status string to `SmsDeliveryStatus`. |
|
||||
| `verifyWebhookSignature(url, params, sig)` | method | HMAC-SHA1 over `url + sortedKey1 + value1 + …`; throws `SIGNATURE_INVALID` on mismatch. |
|
||||
| `parseInbound(params)` | method | Discriminates into the `InboundSmsEvent` union. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `baseUrl` strips any trailing `/` so callers may pass either form. `accountSid` and `authToken` are validated at construction time so credential omissions surface immediately rather than on first call. MMS `mediaUrls` are read off the canonical `OutboundMessage` shape — `SmsMessage & OutboundExtras` — so the call site can pass `mediaUrls` without breaking the `SmsProvider#send` signature.
|
||||
@@ -0,0 +1,354 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "../../tests/setup/http-server.js";
|
||||
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
||||
import type { OutboundMessage } from "../types/provider.js";
|
||||
import { TwilioProvider } from "./twilio.js";
|
||||
|
||||
let server: RecordingServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function makeProvider(
|
||||
config: { accountSid?: string; authToken?: string } = {}
|
||||
) {
|
||||
return new TwilioProvider({
|
||||
accountSid: config.accountSid ?? "AC1",
|
||||
authToken: config.authToken ?? "tok",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
const SEND_RESPONSE = {
|
||||
sid: "SM_abc",
|
||||
status: "queued",
|
||||
to: "+15551234567",
|
||||
date_created: "2026-04-24T10:00:00Z",
|
||||
};
|
||||
|
||||
describe("TwilioProvider — construction", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(SEND_RESPONSE, 201));
|
||||
});
|
||||
|
||||
it("exposes literal name 'twilio'", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
expect(p.name).toBe("twilio");
|
||||
});
|
||||
|
||||
it("throws CONFIG_MISSING when accountSid is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => makeProvider({ accountSid: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("throws CONFIG_MISSING when authToken is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => makeProvider({ authToken: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioProvider — send", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(SEND_RESPONSE, 201));
|
||||
});
|
||||
|
||||
it("POSTs to Accounts/{Sid}/Messages.json with Basic auth + form body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.send({
|
||||
to: "+15551234567",
|
||||
from: "+15559999999",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(server.requests).toHaveLength(1);
|
||||
const req = server.requests[0]!;
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/2010-04-01/Accounts/AC1/Messages.json");
|
||||
expect(req.headers.authorization).toBe(
|
||||
`Basic ${Buffer.from("AC1:tok").toString("base64")}`
|
||||
);
|
||||
expect(req.headers["content-type"]).toContain(
|
||||
"application/x-www-form-urlencoded"
|
||||
);
|
||||
expect(req.body).toContain("To=%2B15551234567");
|
||||
expect(req.body).toContain("Body=Hello");
|
||||
expect(req.body).toContain("From=%2B15559999999");
|
||||
expect(r.messageId).toBe("SM_abc");
|
||||
expect(r.status).toBe("queued");
|
||||
expect(r.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("forwards MMS mediaUrls as repeated MediaUrl params", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({
|
||||
to: "+1",
|
||||
from: "+1",
|
||||
body: "hi",
|
||||
mediaUrls: ["https://cdn/a.png", "https://cdn/b.png"],
|
||||
} as OutboundMessage);
|
||||
const req = server.requests[0]!;
|
||||
const occurrences = req.body.match(/MediaUrl=/g) ?? [];
|
||||
expect(occurrences.length).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to a current timestamp when date_created is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ sid: "S", status: "sent", to: "+1" }, 200)
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+1", from: "+1", body: "x" });
|
||||
expect(r.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("surfaces error_code / error_message on the report", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder(
|
||||
{
|
||||
sid: "S",
|
||||
status: "failed",
|
||||
to: "+1",
|
||||
error_code: 30007,
|
||||
error_message: "Carrier rejection",
|
||||
},
|
||||
200
|
||||
)
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+1", from: "+1", body: "x" });
|
||||
expect(r.status).toBe("failed");
|
||||
expect(r.errorCode).toBe("30007");
|
||||
expect(r.errorMessage).toBe("Carrier rejection");
|
||||
});
|
||||
|
||||
it("threads the AbortSignal", { timeout: 5000 }, async () => {
|
||||
const p = makeProvider();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort(new Error("user"));
|
||||
await expect(
|
||||
p.send(
|
||||
{ to: "+1", from: "+1", body: "hi" },
|
||||
{ signal: ctrl.signal }
|
||||
)
|
||||
).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("translates 4xx into HTTP_ERROR", { timeout: 5000 }, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ message: "bad number" }, 400)
|
||||
);
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.send({ to: "x", from: "x", body: "x" })
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
httpStatus: 400,
|
||||
provider: "twilio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioProvider — getStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(SEND_RESPONSE));
|
||||
});
|
||||
|
||||
it("GETs the Messages/{sid}.json resource", { timeout: 5000 }, async () => {
|
||||
const p = makeProvider();
|
||||
await p.getStatus("SM_abc");
|
||||
expect(server.requests).toHaveLength(1);
|
||||
const req = server.requests[0]!;
|
||||
expect(req.method).toBe("GET");
|
||||
expect(req.path).toBe("/2010-04-01/Accounts/AC1/Messages/SM_abc.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioProvider — mapStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "queued"],
|
||||
["accepted", "queued"],
|
||||
["scheduled", "queued"],
|
||||
["sending", "queued"],
|
||||
["sent", "sent"],
|
||||
["delivered", "delivered"],
|
||||
["received", "delivered"],
|
||||
["receiving", "queued"],
|
||||
["undelivered", "undelivered"],
|
||||
["failed", "failed"],
|
||||
["canceled", "failed"],
|
||||
["DELIVERED", "delivered"],
|
||||
["unknown", "failed"],
|
||||
])("%s → %s", { timeout: 5000 }, (raw, mapped) => {
|
||||
const p = makeProvider();
|
||||
expect(p.mapStatus(raw)).toBe(mapped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioProvider — verifyWebhookSignature", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
function twilioSign(
|
||||
token: string,
|
||||
url: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
let payload = url;
|
||||
for (const k of sortedKeys) {
|
||||
payload += k;
|
||||
payload += params[k];
|
||||
}
|
||||
return createHmac("sha1", token).update(payload).digest("base64");
|
||||
}
|
||||
|
||||
it("accepts a valid signature", { timeout: 5000 }, async () => {
|
||||
const url = "https://callback.example/twilio";
|
||||
const params = {
|
||||
From: "+15551234567",
|
||||
To: "+15559999999",
|
||||
Body: "Hi",
|
||||
MessageSid: "SM1",
|
||||
};
|
||||
const sig = twilioSign("tok", url, params);
|
||||
const p = makeProvider();
|
||||
await expect(p.verifyWebhookSignature(url, params, sig)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", { timeout: 5000 }, async () => {
|
||||
const url = "https://callback.example/twilio";
|
||||
const params = { From: "+1", Body: "Hi" };
|
||||
const sig = twilioSign("tok", url, params);
|
||||
const tampered = `${sig.slice(0, -1)}!`;
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.verifyWebhookSignature(url, params, tampered)
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
provider: "twilio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioProvider — parseInbound", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("returns delivery_receipt when MessageStatus is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
const evt = p.parseInbound({
|
||||
MessageSid: "SM1",
|
||||
MessageStatus: "delivered",
|
||||
ErrorCode: "0",
|
||||
});
|
||||
expect(evt).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "SM1",
|
||||
status: "delivered",
|
||||
errorCode: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns delivery_receipt without errorCode when ErrorCode is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
const evt = p.parseInbound({
|
||||
MessageSid: "SM1",
|
||||
MessageStatus: "delivered",
|
||||
});
|
||||
expect(evt).toMatchObject({ type: "delivery_receipt" });
|
||||
});
|
||||
|
||||
it("returns opt_out on STOP body", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
const evt = p.parseInbound({
|
||||
From: "+1",
|
||||
To: "+2",
|
||||
Body: "STOP",
|
||||
});
|
||||
expect(evt).toEqual({ type: "opt_out", from: "+1" });
|
||||
});
|
||||
|
||||
it("returns opt_in on YES", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
expect(p.parseInbound({ From: "+1", Body: "YES" })).toEqual({
|
||||
type: "opt_in",
|
||||
from: "+1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns help on HELP", { timeout: 5000 }, () => {
|
||||
const p = makeProvider();
|
||||
expect(p.parseInbound({ From: "+1", Body: "HELP" })).toEqual({
|
||||
type: "help",
|
||||
from: "+1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns message_received with provider id when MessageSid is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
expect(
|
||||
p.parseInbound({
|
||||
From: "+1",
|
||||
To: "+2",
|
||||
Body: "free-form",
|
||||
MessageSid: "SM2",
|
||||
})
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "free-form",
|
||||
providerId: "SM2",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns message_received with empty defaults on missing fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
expect(p.parseInbound({})).toEqual({
|
||||
type: "message_received",
|
||||
from: "",
|
||||
to: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsSendCallOptions,
|
||||
} from "@suaveplan/sms";
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
fromBase64,
|
||||
toBase64,
|
||||
utf8,
|
||||
} from "../crypto/default-hmac.js";
|
||||
import { configMissing, signatureInvalid } from "../error-codes/error-codes.js";
|
||||
import { httpRequest } from "../http/http.js";
|
||||
import { parseOptOutKeyword } from "../keywords/keywords.js";
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhookVerifyDuration,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
OutboundMessage,
|
||||
TwilioConfig,
|
||||
} from "../types/provider.js";
|
||||
|
||||
const PROVIDER = "twilio" as const;
|
||||
const DEFAULT_BASE_URL = "https://api.twilio.com";
|
||||
|
||||
const STATUS_MAP: Readonly<Record<string, SmsDeliveryStatus>> = Object.freeze({
|
||||
queued: "queued",
|
||||
accepted: "queued",
|
||||
scheduled: "queued",
|
||||
sending: "queued",
|
||||
sent: "sent",
|
||||
receiving: "queued",
|
||||
received: "delivered",
|
||||
delivered: "delivered",
|
||||
undelivered: "undelivered",
|
||||
failed: "failed",
|
||||
canceled: "failed",
|
||||
});
|
||||
|
||||
/** Wire-shape of Twilio's `POST /Messages.json` response. */
|
||||
interface TwilioMessageResource {
|
||||
readonly sid: string;
|
||||
readonly status: string;
|
||||
readonly to: string;
|
||||
readonly date_created?: string;
|
||||
readonly error_code?: number | null;
|
||||
readonly error_message?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Twilio SMS provider implementing the `SmsProvider` contract from
|
||||
* `@suaveplan/sms`. Issues a `POST /2010-04-01/Accounts/{AccountSid}/
|
||||
* Messages.json` form-encoded request authenticated with HTTP Basic
|
||||
* (`AccountSid:AuthToken`); MMS `MediaUrl[]` is forwarded as repeated
|
||||
* `MediaUrl` form parameters. Status fetched via `GET .../Messages/
|
||||
* {Sid}.json`. Webhook signature verification follows Twilio's
|
||||
* documented `X-Twilio-Signature` HMAC-SHA1 scheme.
|
||||
*/
|
||||
export class TwilioProvider implements SmsProvider {
|
||||
public readonly name = PROVIDER;
|
||||
private readonly config: TwilioConfig;
|
||||
private readonly baseUrl: string;
|
||||
private readonly hmac: HmacSign;
|
||||
private readonly fetch: FetchLike | undefined;
|
||||
|
||||
public constructor(config: TwilioConfig) {
|
||||
if (!config.accountSid) {
|
||||
throw configMissing(PROVIDER, "accountSid");
|
||||
}
|
||||
if (!config.authToken) {
|
||||
throw configMissing(PROVIDER, "authToken");
|
||||
}
|
||||
this.config = config;
|
||||
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
this.hmac = config.hmac ?? defaultHmac;
|
||||
this.fetch = config.fetch;
|
||||
}
|
||||
|
||||
/** Map a raw Twilio status code to the canonical `SmsDeliveryStatus`. */
|
||||
public mapStatus(raw: string): SmsDeliveryStatus {
|
||||
return STATUS_MAP[raw.toLowerCase()] ?? "failed";
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendCallOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const url = `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.config.accountSid)}/Messages.json`;
|
||||
const params = new URLSearchParams();
|
||||
params.set("To", message.to);
|
||||
params.set("Body", message.body);
|
||||
if (message.from !== undefined) {
|
||||
params.set("From", message.from);
|
||||
}
|
||||
const extras = message as OutboundMessage;
|
||||
if (extras.mediaUrls !== undefined) {
|
||||
for (const u of extras.mediaUrls) {
|
||||
params.append("MediaUrl", u);
|
||||
}
|
||||
}
|
||||
const res = await httpRequest<TwilioMessageResource>({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: { Authorization: this.basicAuth() },
|
||||
body: params.toString(),
|
||||
provider: PROVIDER,
|
||||
...this.common(options.signal),
|
||||
});
|
||||
recordSend(PROVIDER, "ok");
|
||||
return {
|
||||
messageId: res.data.sid,
|
||||
to: res.data.to,
|
||||
status: this.mapStatus(res.data.status),
|
||||
timestamp: res.data.date_created
|
||||
? new Date(res.data.date_created)
|
||||
: new Date(),
|
||||
...(res.data.error_code
|
||||
? { errorCode: String(res.data.error_code) }
|
||||
: {}),
|
||||
...(res.data.error_message
|
||||
? { errorMessage: res.data.error_message }
|
||||
: {}),
|
||||
};
|
||||
} catch (err) {
|
||||
recordSend(PROVIDER, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
recordSendDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
const url = `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.config.accountSid)}/Messages/${encodeURIComponent(messageId)}.json`;
|
||||
const res = await httpRequest<TwilioMessageResource>({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: { Authorization: this.basicAuth() },
|
||||
provider: PROVIDER,
|
||||
...this.common(undefined),
|
||||
});
|
||||
return {
|
||||
messageId: res.data.sid,
|
||||
to: res.data.to,
|
||||
status: this.mapStatus(res.data.status),
|
||||
timestamp: res.data.date_created
|
||||
? new Date(res.data.date_created)
|
||||
: new Date(),
|
||||
...(res.data.error_code
|
||||
? { errorCode: String(res.data.error_code) }
|
||||
: {}),
|
||||
...(res.data.error_message
|
||||
? { errorMessage: res.data.error_message }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an inbound Twilio webhook per the documented X-Twilio-
|
||||
* Signature scheme: HMAC-SHA1 of `url + sortedKey1 + value1 + …`
|
||||
* over the auth token, Base-64 encoded; constant-time compared
|
||||
* against the supplied signature.
|
||||
*/
|
||||
public async verifyWebhookSignature(
|
||||
url: string,
|
||||
params: Readonly<Record<string, string>>,
|
||||
signature: string
|
||||
): Promise<boolean> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
let payload = url;
|
||||
for (const k of sortedKeys) {
|
||||
payload += k;
|
||||
payload += params[k];
|
||||
}
|
||||
const sig = await this.hmac(
|
||||
"SHA-1",
|
||||
utf8(this.config.authToken),
|
||||
utf8(payload)
|
||||
);
|
||||
const expected = toBase64(sig);
|
||||
const ok = constantTimeEqual(utf8(expected), utf8(signature));
|
||||
if (!ok) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(PROVIDER, "X-Twilio-Signature mismatch");
|
||||
}
|
||||
// Touch the decode path so callers may supply Base-64 sigs.
|
||||
fromBase64(expected);
|
||||
return true;
|
||||
} finally {
|
||||
recordWebhookVerifyDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Twilio-shaped inbound webhook body (already-decoded form
|
||||
* params) into the canonical `InboundSmsEvent` discriminated union.
|
||||
* Supports inbound message bodies (`From`/`Body`) and delivery
|
||||
* receipts (`MessageSid` + `MessageStatus`).
|
||||
*/
|
||||
public parseInbound(
|
||||
params: Readonly<Record<string, string>>
|
||||
): InboundSmsEvent {
|
||||
if (
|
||||
params["MessageStatus"] !== undefined &&
|
||||
params["MessageSid"] !== undefined
|
||||
) {
|
||||
return {
|
||||
type: "delivery_receipt",
|
||||
messageId: params["MessageSid"],
|
||||
status: this.mapStatus(params["MessageStatus"]),
|
||||
...(params["ErrorCode"] !== undefined
|
||||
? { errorCode: params["ErrorCode"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const from = params["From"] ?? "";
|
||||
const to = params["To"] ?? "";
|
||||
const body = params["Body"] ?? "";
|
||||
const keyword = parseOptOutKeyword(body);
|
||||
if (keyword === "opt_out") {
|
||||
return { type: "opt_out", from };
|
||||
}
|
||||
if (keyword === "opt_in") {
|
||||
return { type: "opt_in", from };
|
||||
}
|
||||
if (keyword === "help") {
|
||||
return { type: "help", from };
|
||||
}
|
||||
return {
|
||||
type: "message_received",
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
...(params["MessageSid"] !== undefined
|
||||
? { providerId: params["MessageSid"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private basicAuth(): string {
|
||||
return `Basic ${toBase64(
|
||||
utf8(`${this.config.accountSid}:${this.config.authToken}`)
|
||||
)}`;
|
||||
}
|
||||
|
||||
private common(signal: AbortSignal | undefined): {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} {
|
||||
const out: {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} = {};
|
||||
if (this.fetch !== undefined) {
|
||||
out.fetch = this.fetch;
|
||||
}
|
||||
const combinedSignal = signal ?? this.config.signal;
|
||||
if (combinedSignal !== undefined) {
|
||||
out.signal = combinedSignal;
|
||||
}
|
||||
if (this.config.timeoutMs !== undefined) {
|
||||
out.timeoutMs = this.config.timeoutMs;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
AwsSnsConfig,
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
HttpOptions,
|
||||
InboundSmsEvent,
|
||||
MessageBirdConfig,
|
||||
OutboundExtras,
|
||||
OutboundMessage,
|
||||
PlivoConfig,
|
||||
ProviderCommonOptions,
|
||||
ProviderResponse,
|
||||
TwilioConfig,
|
||||
VonageConfig,
|
||||
} from "./provider.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# Types — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `HmacSign` (the injectable HMAC seam), `HttpOptions` + `ProviderResponse<T>` (the wire shape of every request and response that flows through the package's `httpRequest` helper), the per-provider config interfaces, the `InboundSmsEvent` discriminated union covering the four canonical inbound webhook events, and the `OutboundMessage` composition extending the canonical `SmsMessage` from `@suaveplan/sms` with provider extras such as MMS `mediaUrls`.
|
||||
|
||||
Every type here is intentionally types-only at runtime — the file ships zero JavaScript output. Provider classes import these types via the package barrel so downstream consumers can also build their own provider adapters against the same shape without depending on any of the concrete provider files.
|
||||
|
||||
## Features
|
||||
|
||||
- `FetchLike` mirrors the global `fetch` signature so any provider works in Bun, Node, browsers, Workers, and Deno without a platform polyfill.
|
||||
- `HmacSign` exposes the HMAC primitive used by every signature verifier without locking the package to `node:crypto` or `WebCrypto`; tests inject a `node:crypto`-backed adapter, production code on Bun/Workers resolves the default `globalThis.crypto.subtle`-backed adapter.
|
||||
- Per-provider configs (`TwilioConfig`, `VonageConfig`, `MessageBirdConfig`, `PlivoConfig`, `AwsSnsConfig`) extend `ProviderCommonOptions` so testing seams (`fetch`, `hmac`, `signal`, `timeoutMs`, `baseUrl`, `clock`) are uniform.
|
||||
- `InboundSmsEvent` is a discriminated union — `message_received | delivery_receipt | opt_out | opt_in | help` — so consumers can switch on `event.type` without re-validating fields.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { TwilioConfig, FetchLike } from "@suaveplan/sms-providers";
|
||||
|
||||
const fetchImpl: FetchLike = (url, init) => globalThis.fetch(url, init);
|
||||
const config: TwilioConfig = {
|
||||
accountSid: "ACxxxxx",
|
||||
authToken: "xxxxx",
|
||||
fetch: fetchImpl,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AwsSnsConfig,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
} from "@suaveplan/sms-providers";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
const hmac: HmacSign = async (algo, key, msg) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
|
||||
const cfg: AwsSnsConfig = {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
hmac,
|
||||
smsType: "Transactional",
|
||||
};
|
||||
|
||||
function handle(evt: InboundSmsEvent): void {
|
||||
switch (evt.type) {
|
||||
case "opt_out":
|
||||
return;
|
||||
case "delivery_receipt":
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `FetchLike` | type | `(url, init?) => Promise<Response>` |
|
||||
| `HmacSign` | type | `(alg, key, msg) => Promise<Uint8Array>` |
|
||||
| `HttpOptions` | interface | Request shape consumed by `httpRequest` |
|
||||
| `ProviderResponse<T>` | interface | `{ status, data }` |
|
||||
| `ProviderCommonOptions` | interface | Shared seams + overrides |
|
||||
| `TwilioConfig` / `VonageConfig` / `MessageBirdConfig` / `PlivoConfig` / `AwsSnsConfig` | interface | Per-provider configs |
|
||||
| `InboundSmsEvent` | union | Discriminated webhook event |
|
||||
| `OutboundMessage` | alias | `SmsMessage & OutboundExtras` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file exports types only — there is no runtime emission. Build tooling excludes the whole `types/` directory's index from coverage thresholds (per `vitest.config.ts`'s `src/**/index.ts` exclusion). The package barrel re-exports every type by name (rule 10: no `export *`).
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { SmsDeliveryStatus, SmsMessage } from "@suaveplan/sms";
|
||||
|
||||
/**
|
||||
* Subset of the global `fetch` signature the package uses. Every provider
|
||||
* accepts a `FetchLike` config field so tests inject a recording adapter
|
||||
* (or a `node:http`-server-bound dispatcher) instead of monkey-patching
|
||||
* `globalThis.fetch`. Defaults to `globalThis.fetch.bind(globalThis)` when
|
||||
* omitted.
|
||||
*/
|
||||
export type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Synchronous HMAC seam. Returns the raw signature bytes. Tests inject a
|
||||
* `node:crypto`-backed adapter; production code on Bun/Workers/browsers
|
||||
* resolves the default `globalThis.crypto.subtle`-backed adapter.
|
||||
*/
|
||||
export type HmacSign = (
|
||||
algorithm: "SHA-1" | "SHA-256",
|
||||
key: Uint8Array,
|
||||
message: Uint8Array
|
||||
) => Promise<Uint8Array>;
|
||||
|
||||
/** HTTP request options for the universal `httpRequest` helper. */
|
||||
export interface HttpOptions {
|
||||
readonly method: string;
|
||||
readonly url: string;
|
||||
readonly headers?: Record<string, string>;
|
||||
/** Body — `string` is sent verbatim; anything else is JSON-stringified. */
|
||||
readonly body?: unknown;
|
||||
readonly timeoutMs?: number;
|
||||
readonly fetch?: FetchLike;
|
||||
readonly signal?: AbortSignal;
|
||||
/** Provider tag forwarded onto raised errors. */
|
||||
readonly provider?: string;
|
||||
}
|
||||
|
||||
/** Provider HTTP response wrapper. */
|
||||
export interface ProviderResponse<T = unknown> {
|
||||
readonly status: number;
|
||||
readonly data: T;
|
||||
}
|
||||
|
||||
/** Common per-provider options carried by every concrete provider config. */
|
||||
export interface ProviderCommonOptions {
|
||||
readonly fetch?: FetchLike;
|
||||
readonly hmac?: HmacSign;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
readonly baseUrl?: string;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
/** Twilio-specific config. */
|
||||
export interface TwilioConfig extends ProviderCommonOptions {
|
||||
readonly accountSid: string;
|
||||
readonly authToken: string;
|
||||
}
|
||||
|
||||
/** Vonage-specific config. */
|
||||
export interface VonageConfig extends ProviderCommonOptions {
|
||||
readonly apiKey: string;
|
||||
readonly apiSecret: string;
|
||||
}
|
||||
|
||||
/** MessageBird-specific config. */
|
||||
export interface MessageBirdConfig extends ProviderCommonOptions {
|
||||
readonly accessKey: string;
|
||||
}
|
||||
|
||||
/** Plivo-specific config. */
|
||||
export interface PlivoConfig extends ProviderCommonOptions {
|
||||
readonly authId: string;
|
||||
readonly authToken: string;
|
||||
}
|
||||
|
||||
/** AWS SNS-specific config. */
|
||||
export interface AwsSnsConfig extends ProviderCommonOptions {
|
||||
readonly region: string;
|
||||
readonly accessKeyId: string;
|
||||
readonly secretAccessKey: string;
|
||||
/** Optional STS session token. */
|
||||
readonly sessionToken?: string;
|
||||
/** Optional Origination number / sender id surfaced as a message attribute. */
|
||||
readonly senderId?: string;
|
||||
/** `Transactional` | `Promotional`; defaults to `Transactional`. */
|
||||
readonly smsType?: "Transactional" | "Promotional";
|
||||
/** Override the system clock for SigV4 (testing seam). */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/** Inbound SMS event discriminated by `type`. */
|
||||
export type InboundSmsEvent =
|
||||
| {
|
||||
readonly type: "message_received";
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly body: string;
|
||||
readonly providerId?: string;
|
||||
}
|
||||
| {
|
||||
readonly type: "delivery_receipt";
|
||||
readonly messageId: string;
|
||||
readonly status: SmsDeliveryStatus;
|
||||
readonly errorCode?: string;
|
||||
}
|
||||
| { readonly type: "opt_out"; readonly from: string }
|
||||
| { readonly type: "opt_in"; readonly from: string }
|
||||
| { readonly type: "help"; readonly from: string };
|
||||
|
||||
/** Outbound message extension: MMS media URLs (Twilio + Plivo). */
|
||||
export interface OutboundExtras {
|
||||
readonly mediaUrls?: readonly string[];
|
||||
}
|
||||
|
||||
/** Composite send-input shape: `SmsMessage` + provider-specific extras. */
|
||||
export type OutboundMessage = SmsMessage & OutboundExtras;
|
||||
@@ -0,0 +1 @@
|
||||
export { VonageProvider } from "./vonage.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# Vonage — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `api_key` + `api_secret` carried in the form body (the documented default authentication mode), auto-selecting `type=unicode` when the body contains a non-ASCII codepoint and `type=text` otherwise. Sender (`from`) supports alphanumeric IDs (up to 11 chars), short codes, and long codes — Vonage accepts the raw string and we pass it through verbatim. Status is fetched via `GET /search/message`. Webhook signature verification follows Vonage's documented HMAC-SHA256 scheme over the sorted form fields excluding the `sig` parameter.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- Form-body auth (`api_key`, `api_secret`) — matches Vonage's documented default.
|
||||
- Auto `type=unicode` vs `type=text` based on whether the body contains a non-ASCII codepoint.
|
||||
- Sender field forwarded verbatim (alpha / short code / long code all supported).
|
||||
- `getStatus(id)` round-trips `GET /search/message?id=…` with the same auth pattern.
|
||||
- `mapStatus` covers `submitted` / `buffered` / `accepted` / `delivered` / `expired` / `failed` / `rejected`.
|
||||
- `verifyWebhookSignature(params, signature, signatureSecret)` — HMAC-SHA256 over `&key=value&…` for sorted keys (excluding `sig`); lowercase-hex comparison in constant time.
|
||||
- `parseInbound(params)` discriminates between inbound messages and DLR (delivery-receipt) payloads.
|
||||
- OTel — counter + histogram for every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VonageProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const vonage = new VonageProvider({
|
||||
apiKey: "vonage_api_key",
|
||||
apiSecret: "vonage_api_secret",
|
||||
});
|
||||
const r = await vonage.send({
|
||||
to: "447700900000",
|
||||
from: "Suaveplan",
|
||||
body: "Hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { VonageProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const vonage = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const ok = await vonage.verifyWebhookSignature(
|
||||
{ messageId: "0A0000001", text: "hi", sig: "<hex>" },
|
||||
"<hex>",
|
||||
"signatureSecretSharedWithVonage"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `VonageProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | POST `/sms/json`, returns `SmsDeliveryReport`. |
|
||||
| `getStatus(id)` | method | `GET /search/message?id=…`. |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyWebhookSignature(params, sig, signatureSecret)` | method | HMAC-SHA256 hex verifier; throws `SIGNATURE_INVALID` on mismatch. |
|
||||
| `parseInbound(params)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Vonage strips the leading `+` from E.164 `to` numbers on submission, so the implementation `replace(/^\+/, "")` once before form-encoding. The signature payload is the documented `&{k}={v}&…` shape (note the leading `&`), built from a stable lexicographic key sort with `sig` excluded.
|
||||
@@ -0,0 +1,317 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "../../tests/setup/http-server.js";
|
||||
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VonageProvider } from "./vonage.js";
|
||||
|
||||
let server: RecordingServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function makeProvider(config: { apiKey?: string; apiSecret?: string } = {}) {
|
||||
return new VonageProvider({
|
||||
apiKey: config.apiKey ?? "k",
|
||||
apiSecret: config.apiSecret ?? "s",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
const OK_RESPONSE = {
|
||||
"message-count": "1",
|
||||
messages: [
|
||||
{
|
||||
to: "447700900000",
|
||||
"message-id": "0A0000",
|
||||
status: "0",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("VonageProvider — construction", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(OK_RESPONSE));
|
||||
});
|
||||
|
||||
it("exposes name 'vonage'", { timeout: 5000 }, () => {
|
||||
expect(makeProvider().name).toBe("vonage");
|
||||
});
|
||||
|
||||
it("requires apiKey + apiSecret", { timeout: 5000 }, () => {
|
||||
expect(() => makeProvider({ apiKey: "" })).toThrow();
|
||||
expect(() => makeProvider({ apiSecret: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VonageProvider — send", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder(OK_RESPONSE));
|
||||
});
|
||||
|
||||
it("POSTs to /sms/json with form auth + text type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.send({
|
||||
to: "+447700900000",
|
||||
from: "Suaveplan",
|
||||
body: "Hello",
|
||||
});
|
||||
const req = server.requests[0]!;
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/sms/json");
|
||||
expect(req.body).toContain("api_key=k");
|
||||
expect(req.body).toContain("api_secret=s");
|
||||
expect(req.body).toContain("to=447700900000");
|
||||
expect(req.body).toContain("from=Suaveplan");
|
||||
expect(req.body).toContain("text=Hello");
|
||||
expect(req.body).toContain("type=text");
|
||||
expect(r.status).toBe("sent");
|
||||
expect(r.messageId).toBe("0A0000");
|
||||
});
|
||||
|
||||
it("auto-selects type=unicode for non-ASCII bodies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
await p.send({ to: "+1", from: "X", body: "héllo" });
|
||||
const req = server.requests[0]!;
|
||||
expect(req.body).toContain("type=unicode");
|
||||
});
|
||||
|
||||
it("marks non-zero message status as failed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-count": "1",
|
||||
messages: [
|
||||
{
|
||||
to: "447",
|
||||
"message-id": "x",
|
||||
status: "1",
|
||||
"error-text": "throttled",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.send({ to: "+447", from: "X", body: "hi" });
|
||||
expect(r.status).toBe("failed");
|
||||
expect(r.errorCode).toBe("1");
|
||||
expect(r.errorMessage).toBe("throttled");
|
||||
});
|
||||
|
||||
it("throws when the response carries no messages", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ "message-count": "0", messages: [] })
|
||||
);
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.send({ to: "+1", from: "X", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
provider: "vonage",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VonageProvider — getStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-id": "id1",
|
||||
to: "447",
|
||||
status: "delivered",
|
||||
"date-received": "2026-04-24T10:00:00Z",
|
||||
"error-code": "0",
|
||||
"error-code-label": "ok",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("GETs /search/message and maps the status", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeProvider();
|
||||
const r = await p.getStatus("id1");
|
||||
expect(server.requests[0]!.path).toContain("/search/message?");
|
||||
expect(r.status).toBe("delivered");
|
||||
expect(r.errorCode).toBe("0");
|
||||
expect(r.errorMessage).toBe("ok");
|
||||
});
|
||||
|
||||
it("falls back to a current timestamp when date-received is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await server.close();
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-id": "x",
|
||||
to: "447",
|
||||
status: "submitted",
|
||||
})
|
||||
);
|
||||
const p = makeProvider();
|
||||
const r = await p.getStatus("x");
|
||||
expect(r.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VonageProvider — mapStatus", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["submitted", "queued"],
|
||||
["buffered", "queued"],
|
||||
["accepted", "sent"],
|
||||
["delivered", "delivered"],
|
||||
["expired", "undelivered"],
|
||||
["failed", "failed"],
|
||||
["rejected", "failed"],
|
||||
["DELIVERED", "delivered"],
|
||||
["wat", "failed"],
|
||||
])("%s → %s", { timeout: 5000 }, (raw, mapped) => {
|
||||
expect(makeProvider().mapStatus(raw)).toBe(mapped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VonageProvider — verifyWebhookSignature", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
function vonageSign(
|
||||
secret: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
const sortedKeys = Object.keys(params)
|
||||
.filter((k) => k !== "sig")
|
||||
.sort();
|
||||
let payload = "";
|
||||
for (const k of sortedKeys) {
|
||||
payload += `&${k}=${params[k]}`;
|
||||
}
|
||||
return createHmac("sha256", secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
it("accepts a valid signature", { timeout: 5000 }, async () => {
|
||||
const params = {
|
||||
messageId: "id1",
|
||||
"message-timestamp": "2026-04-24 10:00:00",
|
||||
text: "Hi",
|
||||
};
|
||||
const sig = vonageSign("secret", params);
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.verifyWebhookSignature(params, sig, "secret")
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", { timeout: 5000 }, async () => {
|
||||
const params = { messageId: "id1", text: "Hi" };
|
||||
const sig = vonageSign("secret", params);
|
||||
const p = makeProvider();
|
||||
await expect(
|
||||
p.verifyWebhookSignature(params, `${sig.slice(0, -1)}0`, "secret")
|
||||
).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VonageProvider — parseInbound", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
});
|
||||
|
||||
it("returns delivery_receipt with errorCode when err-code present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
status: "delivered",
|
||||
messageId: "id1",
|
||||
"err-code": "0",
|
||||
})
|
||||
).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "id1",
|
||||
status: "delivered",
|
||||
errorCode: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns delivery_receipt without errorCode when err-code absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
makeProvider().parseInbound({
|
||||
status: "delivered",
|
||||
messageId: "id1",
|
||||
})
|
||||
).toEqual({
|
||||
type: "delivery_receipt",
|
||||
messageId: "id1",
|
||||
status: "delivered",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns opt_out, opt_in, help and message_received", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = makeProvider();
|
||||
expect(p.parseInbound({ msisdn: "447", text: "stop" })).toMatchObject({
|
||||
type: "opt_out",
|
||||
});
|
||||
expect(p.parseInbound({ msisdn: "447", text: "YES" })).toMatchObject({
|
||||
type: "opt_in",
|
||||
});
|
||||
expect(p.parseInbound({ msisdn: "447", text: "HELP" })).toMatchObject({
|
||||
type: "help",
|
||||
});
|
||||
expect(
|
||||
p.parseInbound({
|
||||
msisdn: "447",
|
||||
to: "X",
|
||||
text: "hi",
|
||||
messageId: "m1",
|
||||
})
|
||||
).toEqual({
|
||||
type: "message_received",
|
||||
from: "447",
|
||||
to: "X",
|
||||
body: "hi",
|
||||
providerId: "m1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns message_received without providerId when messageId absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(makeProvider().parseInbound({ text: "hello" })).toEqual({
|
||||
type: "message_received",
|
||||
from: "",
|
||||
to: "",
|
||||
body: "hello",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsSendCallOptions,
|
||||
} from "@suaveplan/sms";
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
toHex,
|
||||
utf8,
|
||||
} from "../crypto/default-hmac.js";
|
||||
import {
|
||||
configMissing,
|
||||
httpError,
|
||||
signatureInvalid,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { httpRequest } from "../http/http.js";
|
||||
import { parseOptOutKeyword } from "../keywords/keywords.js";
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordSignatureFailure,
|
||||
recordWebhookVerifyDuration,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
VonageConfig,
|
||||
} from "../types/provider.js";
|
||||
|
||||
const PROVIDER = "vonage" as const;
|
||||
const DEFAULT_BASE_URL = "https://rest.nexmo.com";
|
||||
|
||||
const STATUS_MAP: Readonly<Record<string, SmsDeliveryStatus>> = Object.freeze({
|
||||
submitted: "queued",
|
||||
buffered: "queued",
|
||||
accepted: "sent",
|
||||
delivered: "delivered",
|
||||
expired: "undelivered",
|
||||
failed: "failed",
|
||||
rejected: "failed",
|
||||
});
|
||||
|
||||
interface VonageSendResponse {
|
||||
readonly "message-count": string;
|
||||
readonly messages: ReadonlyArray<{
|
||||
readonly to: string;
|
||||
readonly "message-id": string;
|
||||
readonly status: string;
|
||||
readonly "error-text"?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface VonageSearchResponse {
|
||||
readonly "message-id": string;
|
||||
readonly to: string;
|
||||
readonly status: string;
|
||||
readonly "error-code"?: string;
|
||||
readonly "error-code-label"?: string;
|
||||
readonly "date-received"?: string;
|
||||
}
|
||||
|
||||
/** Detect a non-ASCII codepoint — Vonage expects `type=unicode` in that case. */
|
||||
function isUnicode(text: string): boolean {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text.charCodeAt(i) > 0x7f) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Vonage (formerly Nexmo) SMS provider. POSTs to `/sms/json` with
|
||||
* `api_key` + `api_secret` carried in the form body (the documented
|
||||
* default authentication mode). Auto-selects `type=unicode` when the
|
||||
* body contains a non-ASCII codepoint and `type=text` otherwise. Status
|
||||
* is fetched via `GET /search/message`. Webhook signature verification
|
||||
* follows Vonage's documented HMAC-SHA256 scheme over the sorted-form
|
||||
* fields excluding the `sig` parameter.
|
||||
*/
|
||||
export class VonageProvider implements SmsProvider {
|
||||
public readonly name = PROVIDER;
|
||||
private readonly config: VonageConfig;
|
||||
private readonly baseUrl: string;
|
||||
private readonly hmac: HmacSign;
|
||||
private readonly fetch: FetchLike | undefined;
|
||||
|
||||
public constructor(config: VonageConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw configMissing(PROVIDER, "apiKey");
|
||||
}
|
||||
if (!config.apiSecret) {
|
||||
throw configMissing(PROVIDER, "apiSecret");
|
||||
}
|
||||
this.config = config;
|
||||
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
this.hmac = config.hmac ?? defaultHmac;
|
||||
this.fetch = config.fetch;
|
||||
}
|
||||
|
||||
public mapStatus(raw: string): SmsDeliveryStatus {
|
||||
return STATUS_MAP[raw.toLowerCase()] ?? "failed";
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendCallOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("api_key", this.config.apiKey);
|
||||
params.set("api_secret", this.config.apiSecret);
|
||||
params.set("to", message.to.replace(/^\+/, ""));
|
||||
if (message.from !== undefined) {
|
||||
params.set("from", message.from);
|
||||
}
|
||||
params.set("text", message.body);
|
||||
params.set("type", isUnicode(message.body) ? "unicode" : "text");
|
||||
const res = await httpRequest<VonageSendResponse>({
|
||||
method: "POST",
|
||||
url: `${this.baseUrl}/sms/json`,
|
||||
body: params.toString(),
|
||||
provider: PROVIDER,
|
||||
...this.common(options.signal),
|
||||
});
|
||||
const first = res.data.messages[0];
|
||||
if (first === undefined) {
|
||||
throw httpError(
|
||||
PROVIDER,
|
||||
res.status,
|
||||
`${this.baseUrl}/sms/json`,
|
||||
JSON.stringify(res.data),
|
||||
"Vonage response contained no message"
|
||||
);
|
||||
}
|
||||
const status = first.status === "0" ? "sent" : "failed";
|
||||
recordSend(PROVIDER, status === "sent" ? "ok" : "error");
|
||||
return {
|
||||
messageId: first["message-id"],
|
||||
to: first.to,
|
||||
status,
|
||||
timestamp: new Date(),
|
||||
...(first["error-text"] !== undefined
|
||||
? { errorMessage: first["error-text"] }
|
||||
: {}),
|
||||
...(first.status !== "0" ? { errorCode: first.status } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
recordSend(PROVIDER, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
recordSendDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
const params = new URLSearchParams({
|
||||
api_key: this.config.apiKey,
|
||||
api_secret: this.config.apiSecret,
|
||||
id: messageId,
|
||||
});
|
||||
const res = await httpRequest<VonageSearchResponse>({
|
||||
method: "GET",
|
||||
url: `${this.baseUrl}/search/message?${params.toString()}`,
|
||||
provider: PROVIDER,
|
||||
...this.common(undefined),
|
||||
});
|
||||
return {
|
||||
messageId: res.data["message-id"],
|
||||
to: res.data.to,
|
||||
status: this.mapStatus(res.data.status),
|
||||
timestamp: res.data["date-received"]
|
||||
? new Date(res.data["date-received"])
|
||||
: new Date(),
|
||||
...(res.data["error-code"] !== undefined
|
||||
? { errorCode: res.data["error-code"] }
|
||||
: {}),
|
||||
...(res.data["error-code-label"] !== undefined
|
||||
? { errorMessage: res.data["error-code-label"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Vonage inbound webhook per the documented HMAC-SHA256
|
||||
* scheme: build a payload of `&{key}={value}&{key}={value}…` over
|
||||
* keys sorted alphabetically (excluding `sig`), HMAC-SHA256 with
|
||||
* the configured signature secret, lowercase hex compared to the
|
||||
* supplied `sig` in constant time.
|
||||
*/
|
||||
public async verifyWebhookSignature(
|
||||
params: Readonly<Record<string, string>>,
|
||||
signature: string,
|
||||
signatureSecret: string
|
||||
): Promise<boolean> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const sortedKeys = Object.keys(params)
|
||||
.filter((k) => k !== "sig")
|
||||
.sort();
|
||||
let payload = "";
|
||||
for (const k of sortedKeys) {
|
||||
payload += `&${k}=${params[k]}`;
|
||||
}
|
||||
const sig = await this.hmac(
|
||||
"SHA-256",
|
||||
utf8(signatureSecret),
|
||||
utf8(payload)
|
||||
);
|
||||
const expected = toHex(sig);
|
||||
const ok = constantTimeEqual(
|
||||
utf8(expected.toLowerCase()),
|
||||
utf8(signature.toLowerCase())
|
||||
);
|
||||
if (!ok) {
|
||||
recordSignatureFailure(PROVIDER);
|
||||
throw signatureInvalid(
|
||||
PROVIDER,
|
||||
"HMAC-SHA256 signature mismatch"
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
recordWebhookVerifyDuration(PROVIDER, Date.now() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a Vonage-shaped inbound webhook into a discriminated event. */
|
||||
public parseInbound(
|
||||
params: Readonly<Record<string, string>>
|
||||
): InboundSmsEvent {
|
||||
if (
|
||||
params["status"] !== undefined &&
|
||||
params["messageId"] !== undefined
|
||||
) {
|
||||
return {
|
||||
type: "delivery_receipt",
|
||||
messageId: params["messageId"],
|
||||
status: this.mapStatus(params["status"]),
|
||||
...(params["err-code"] !== undefined
|
||||
? { errorCode: params["err-code"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const from = params["msisdn"] ?? "";
|
||||
const to = params["to"] ?? "";
|
||||
const body = params["text"] ?? "";
|
||||
const keyword = parseOptOutKeyword(body);
|
||||
if (keyword === "opt_out") {
|
||||
return { type: "opt_out", from };
|
||||
}
|
||||
if (keyword === "opt_in") {
|
||||
return { type: "opt_in", from };
|
||||
}
|
||||
if (keyword === "help") {
|
||||
return { type: "help", from };
|
||||
}
|
||||
return {
|
||||
type: "message_received",
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
...(params["messageId"] !== undefined
|
||||
? { providerId: params["messageId"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private common(signal: AbortSignal | undefined): {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} {
|
||||
const out: {
|
||||
fetch?: FetchLike;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} = {};
|
||||
if (this.fetch !== undefined) {
|
||||
out.fetch = this.fetch;
|
||||
}
|
||||
const s = signal ?? this.config.signal;
|
||||
if (s !== undefined) {
|
||||
out.signal = s;
|
||||
}
|
||||
if (this.config.timeoutMs !== undefined) {
|
||||
out.timeoutMs = this.config.timeoutMs;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Second targeted branch-coverage suite — closes the remaining
|
||||
* istanbul branch gaps (default-arms of `??` chains, "fetch undefined"
|
||||
* arms of per-provider `common()`, parseInbound default-fall-through
|
||||
* shapes, http content-type-absent path, default baseUrl path for
|
||||
* AWS SNS, conditional-spread absent-arms for delivery receipts, and
|
||||
* the non-object `safeJsonObject` fallback).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AwsSnsProvider } from "../src/aws-sns/aws-sns.js";
|
||||
import { httpRequest } from "../src/http/http.js";
|
||||
import { MessageBirdProvider } from "../src/messagebird/messagebird.js";
|
||||
import { PlivoProvider } from "../src/plivo/plivo.js";
|
||||
import { TwilioProvider } from "../src/twilio/twilio.js";
|
||||
import type { FetchLike } from "../src/types/provider.js";
|
||||
import { VonageProvider } from "../src/vonage/vonage.js";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "./setup/http-server.js";
|
||||
|
||||
let server: RecordingServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Temporarily replace `globalThis.fetch` with a path-rewriting fetch that
|
||||
* routes any URL to the recording server's origin via the captured
|
||||
* platform fetch. Restores the original on exit. Used so providers
|
||||
* constructed without an explicit `fetch` config option exercise the
|
||||
* "this.fetch !== undefined === false" arm of their `common()` helper.
|
||||
*/
|
||||
async function withGlobalFetch<T>(
|
||||
origin: string,
|
||||
body: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const original = globalThis.fetch;
|
||||
const rewriter = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const inputUrl =
|
||||
typeof input === "string" || input instanceof URL
|
||||
? new URL(input.toString())
|
||||
: new URL(input.url);
|
||||
const rerouted = `${origin}${inputUrl.pathname}${inputUrl.search}`;
|
||||
return original.call(globalThis, rerouted, init);
|
||||
};
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: rewriter as unknown as typeof fetch,
|
||||
});
|
||||
try {
|
||||
return await body();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("http.ts — headers.get('content-type') returns null", () => {
|
||||
it("treats a missing content-type header as empty and falls back to start-byte JSON sniffing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
text: async () => '{"k":1}',
|
||||
}) as unknown as Response;
|
||||
const { data } = await httpRequest<{ k: number }>({
|
||||
method: "GET",
|
||||
url: "https://x/",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(data).toEqual({ k: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Twilio — getStatus without date_created falls back to a fresh Date", () => {
|
||||
it("returns a Date timestamp when date_created is omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ sid: "S", status: "sent", to: "+1" })
|
||||
);
|
||||
const t = new TwilioProvider({
|
||||
accountSid: "AC",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await t.getStatus("S");
|
||||
expect(r.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("common() — this.fetch === undefined arm (default global fetch)", () => {
|
||||
it("Twilio.send works when no explicit fetch is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ sid: "S", status: "queued", to: "+1" })
|
||||
);
|
||||
const localServer = server;
|
||||
const t = new TwilioProvider({
|
||||
accountSid: "AC",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
});
|
||||
const r = await withGlobalFetch(localServer.origin, () =>
|
||||
t.send({ to: "+1", body: "x" })
|
||||
);
|
||||
expect(r.messageId).toBe("S");
|
||||
});
|
||||
|
||||
it("Vonage.send works when no explicit fetch is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-count": "1",
|
||||
messages: [{ to: "1", "message-id": "x", status: "0" }],
|
||||
})
|
||||
);
|
||||
const localServer = server;
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
baseUrl: server.origin,
|
||||
});
|
||||
const r = await withGlobalFetch(localServer.origin, () =>
|
||||
v.send({ to: "+1", body: "hi" })
|
||||
);
|
||||
expect(r.messageId).toBe("x");
|
||||
});
|
||||
|
||||
it("MessageBird.send works when no explicit fetch is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
id: "mb",
|
||||
originator: "S",
|
||||
body: "x",
|
||||
type: "sms",
|
||||
recipients: {
|
||||
totalCount: 1,
|
||||
items: [{ recipient: 1, status: "sent" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const localServer = server;
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
baseUrl: server.origin,
|
||||
});
|
||||
const r = await withGlobalFetch(localServer.origin, () =>
|
||||
m.send({ to: "+1", body: "x" })
|
||||
);
|
||||
expect(r.messageId).toBe("mb");
|
||||
});
|
||||
|
||||
it("Plivo.send works when no explicit fetch is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder(
|
||||
{
|
||||
api_id: "x",
|
||||
message: "ok",
|
||||
message_uuid: ["u1"],
|
||||
},
|
||||
202
|
||||
)
|
||||
);
|
||||
const localServer = server;
|
||||
const p = new PlivoProvider({
|
||||
authId: "A",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
});
|
||||
const r = await withGlobalFetch(localServer.origin, () =>
|
||||
p.send({ to: "+1", body: "x" })
|
||||
);
|
||||
expect(r.messageId).toBe("u1");
|
||||
});
|
||||
|
||||
it("AwsSns.send works when no explicit fetch is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const PUBLISH_XML =
|
||||
'<?xml version="1.0"?><PublishResponse><PublishResult><MessageId>x</MessageId></PublishResult></PublishResponse>';
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/xml" });
|
||||
res.end(PUBLISH_XML);
|
||||
});
|
||||
const localServer = server;
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
const r = await withGlobalFetch(localServer.origin, () =>
|
||||
a.send({ to: "+1", body: "hi" })
|
||||
);
|
||||
expect(r.messageId).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS SNS — default baseUrl path (config.baseUrl omitted)", () => {
|
||||
it("constructs the default https://sns.<region>.amazonaws.com base URL when baseUrl is omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// We exercise the `?? \`https://${host}\`` default arm of the
|
||||
// constructor; the resulting fetch URL hits the AWS host, but
|
||||
// the swapped global fetch routes it to our recording server.
|
||||
const PUBLISH_XML =
|
||||
'<?xml version="1.0"?><PublishResponse><PublishResult><MessageId>m1</MessageId></PublishResult></PublishResponse>';
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/xml" });
|
||||
res.end(PUBLISH_XML);
|
||||
});
|
||||
const localServer = server;
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
const r = await withGlobalFetch(localServer.origin, () =>
|
||||
a.send({ to: "+1", body: "hi" })
|
||||
);
|
||||
expect(r.messageId).toBe("m1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS SNS — parseInbound delivery receipt without errorCode", () => {
|
||||
it("omits errorCode when the SNS Message payload has no string errorCode field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
const evt = a.parseInbound({
|
||||
Type: "Notification",
|
||||
MessageId: "m1",
|
||||
Subject: "AWS SMS Delivery Receipt",
|
||||
Message: JSON.stringify({ status: "DELIVERED" }),
|
||||
Timestamp: "2026-04-24T10:00:00Z",
|
||||
TopicArn: "arn:aws:sns:us-east-1:000000000000:topic",
|
||||
Signature: "",
|
||||
SigningCertURL: "",
|
||||
SignatureVersion: "1",
|
||||
});
|
||||
expect(evt.type).toBe("delivery_receipt");
|
||||
if (evt.type === "delivery_receipt") {
|
||||
expect(evt.errorCode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to '' status when the SNS Message payload lacks a status field (delivery receipt path)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
const evt = a.parseInbound({
|
||||
Type: "Notification",
|
||||
MessageId: "m2",
|
||||
Subject: "AWS SMS Delivery Receipt",
|
||||
Message: JSON.stringify({}),
|
||||
Timestamp: "2026-04-24T10:00:00Z",
|
||||
TopicArn: "arn:aws:sns:us-east-1:000000000000:topic",
|
||||
Signature: "",
|
||||
SigningCertURL: "",
|
||||
SignatureVersion: "1",
|
||||
});
|
||||
expect(evt.type).toBe("delivery_receipt");
|
||||
if (evt.type === "delivery_receipt") {
|
||||
// `mapStatus("")` defaults to "failed" per the SNS table.
|
||||
expect(evt.status).toBe("failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS SNS — safeJsonObject non-object fallback", () => {
|
||||
it("parseInbound treats a non-object JSON payload as an empty object (covers the `typeof === 'object' && !== null` else arm)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
// `42` parses to a number — `typeof parsed === "object"` is false,
|
||||
// so safeJsonObject returns `{}`. The non-delivery-receipt branch
|
||||
// then produces a message_received event with empty fields.
|
||||
const evt = a.parseInbound({
|
||||
Type: "Notification",
|
||||
MessageId: "mX",
|
||||
Message: "42",
|
||||
Timestamp: "2026-04-24T10:00:00Z",
|
||||
TopicArn: "arn:aws:sns:us-east-1:000000000000:topic",
|
||||
Signature: "",
|
||||
SigningCertURL: "",
|
||||
SignatureVersion: "1",
|
||||
});
|
||||
expect(evt.type).toBe("message_received");
|
||||
if (evt.type === "message_received") {
|
||||
expect(evt.from).toBe("");
|
||||
expect(evt.to).toBe("");
|
||||
expect(evt.body).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS SNS — fetchPem default-fetch arm (no provider fetch configured)", () => {
|
||||
it("verifyNotification fetches the SNS signing cert via globalThis.fetch when no fetch is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const PEM =
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/x-pem-file",
|
||||
});
|
||||
res.end(PEM);
|
||||
});
|
||||
const localServer = server;
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
const ok = await withGlobalFetch(localServer.origin, () =>
|
||||
a.verifyNotification(
|
||||
{
|
||||
Type: "Notification",
|
||||
MessageId: "m",
|
||||
Message: "{}",
|
||||
Timestamp: "2026-04-24T10:00:00Z",
|
||||
TopicArn: "arn:aws:sns:us-east-1:0:t",
|
||||
Signature: "x",
|
||||
SigningCertURL: `${localServer.origin}/cert.pem`,
|
||||
SignatureVersion: "1",
|
||||
},
|
||||
async () => true
|
||||
)
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageBird — verifyWebhookSignature without nowMs (default arm)", () => {
|
||||
it("accepts a JWT issued at the real wall-clock when nowMs is omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const { createHmac } = await import("node:crypto");
|
||||
const b64url = (input: Buffer | string): string => {
|
||||
const buf =
|
||||
typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/=+$/, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
};
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const payload = b64url(
|
||||
JSON.stringify({ iat: nowSec - 1, exp: nowSec + 60 })
|
||||
);
|
||||
const sig = createHmac("sha256", "secret")
|
||||
.update(`${header}.${payload}`)
|
||||
.digest();
|
||||
const token = `${header}.${payload}.${b64url(sig)}`;
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const ok = await m.verifyWebhookSignature(token, "secret", {});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageBird — parseInbound fallthrough defaults", () => {
|
||||
it("returns message_received with from='' when originator is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const evt = m.parseInbound({ recipient: "+2", payload: "hello" });
|
||||
expect(evt.type).toBe("message_received");
|
||||
if (evt.type === "message_received") {
|
||||
expect(evt.from).toBe("");
|
||||
expect(evt.body).toBe("hello");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns message_received with body='' when neither payload nor body is present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const evt = m.parseInbound({
|
||||
originator: "+1",
|
||||
recipient: "+2",
|
||||
});
|
||||
expect(evt.type).toBe("message_received");
|
||||
if (evt.type === "message_received") {
|
||||
expect(evt.body).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plivo — parseInbound fallthrough defaults", () => {
|
||||
it("returns message_received with from='' when From is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const p = new PlivoProvider({
|
||||
authId: "A",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const evt = p.parseInbound({ To: "+2", Text: "hello" });
|
||||
expect(evt.type).toBe("message_received");
|
||||
if (evt.type === "message_received") {
|
||||
expect(evt.from).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns message_received with body='' when neither Text nor text is present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const p = new PlivoProvider({
|
||||
authId: "A",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const evt = p.parseInbound({ From: "+1", To: "+2" });
|
||||
expect(evt.type).toBe("message_received");
|
||||
if (evt.type === "message_received") {
|
||||
expect(evt.body).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vonage — parseInbound fallthrough default", () => {
|
||||
it("returns message_received with body='' when text is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({}));
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const evt = v.parseInbound({ msisdn: "+1", to: "+2" });
|
||||
expect(evt.type).toBe("message_received");
|
||||
if (evt.type === "message_received") {
|
||||
expect(evt.body).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Targeted branch-coverage suite for the conditional spreads
|
||||
* (`...(x ? {y:x} : {})`) and default-config paths that the per-provider
|
||||
* feature tests skip for readability.
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { AwsSnsProvider } from "../src/aws-sns/aws-sns.js";
|
||||
import { MessageBirdProvider } from "../src/messagebird/messagebird.js";
|
||||
import { PlivoProvider } from "../src/plivo/plivo.js";
|
||||
import { TwilioProvider } from "../src/twilio/twilio.js";
|
||||
import { VonageProvider } from "../src/vonage/vonage.js";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "./setup/http-server.js";
|
||||
|
||||
let server!: RecordingServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("branch coverage — Twilio", () => {
|
||||
it("getStatus returns a report with errorCode + errorMessage when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
sid: "S",
|
||||
status: "failed",
|
||||
to: "+1",
|
||||
date_created: "2026-04-24T10:00:00Z",
|
||||
error_code: 30007,
|
||||
error_message: "carrier",
|
||||
})
|
||||
);
|
||||
const t = new TwilioProvider({
|
||||
accountSid: "AC",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await t.getStatus("S");
|
||||
expect(r.errorCode).toBe("30007");
|
||||
expect(r.errorMessage).toBe("carrier");
|
||||
});
|
||||
|
||||
it("send omits From when message.from is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ sid: "S", status: "queued", to: "+1" })
|
||||
);
|
||||
const t = new TwilioProvider({
|
||||
accountSid: "AC",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
await t.send({ to: "+1", body: "x" });
|
||||
expect(server.requests[0]!.body).not.toContain("From=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch coverage — Vonage", () => {
|
||||
it("send omits from when message.from is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-count": "1",
|
||||
messages: [{ to: "1", "message-id": "x", status: "0" }],
|
||||
})
|
||||
);
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
await v.send({ to: "+1", body: "hi" });
|
||||
expect(server.requests[0]!.body).not.toContain("from=");
|
||||
});
|
||||
|
||||
it("send returns sent without errorCode/errorMessage when status=0 and no error-text", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-count": "1",
|
||||
messages: [{ to: "1", "message-id": "x", status: "0" }],
|
||||
})
|
||||
);
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await v.send({ to: "+1", body: "hi" });
|
||||
expect(r.errorCode).toBeUndefined();
|
||||
expect(r.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getStatus omits errorCode / errorMessage when absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-id": "x",
|
||||
to: "1",
|
||||
status: "delivered",
|
||||
})
|
||||
);
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await v.getStatus("x");
|
||||
expect(r.errorCode).toBeUndefined();
|
||||
expect(r.errorMessage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch coverage — Plivo", () => {
|
||||
it("getStatus omits errorCode when absent", { timeout: 5000 }, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
api_id: "x",
|
||||
message_uuid: "u",
|
||||
to_number: "+1",
|
||||
message_state: "sent",
|
||||
})
|
||||
);
|
||||
const p = new PlivoProvider({
|
||||
authId: "A",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await p.getStatus("u");
|
||||
expect(r.errorCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch coverage — AWS SNS", () => {
|
||||
it("send falls through to a default `now` factory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const PUBLISH_XML =
|
||||
'<?xml version="1.0"?><PublishResponse><PublishResult><MessageId>x</MessageId></PublishResult></PublishResponse>';
|
||||
server = await startRecordingServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/xml" });
|
||||
res.end(PUBLISH_XML);
|
||||
});
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await a.send({ to: "+1", body: "hi" });
|
||||
expect(r.messageId).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch coverage — MessageBird", () => {
|
||||
it("send works without options (signal omitted)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
id: "mb1",
|
||||
originator: "S",
|
||||
body: "x",
|
||||
type: "sms",
|
||||
recipients: {
|
||||
totalCount: 1,
|
||||
items: [{ recipient: 1, status: "sent" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await m.send({ to: "+1", body: "hi" });
|
||||
expect(r.messageId).toBe("mb1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch coverage — default baseUrl path", () => {
|
||||
it("Twilio falls through to the documented default base URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({ sid: "S", status: "queued", to: "+1" })
|
||||
);
|
||||
const t = new TwilioProvider({
|
||||
accountSid: "AC",
|
||||
authToken: "T",
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await t.send({ to: "+1", body: "x" });
|
||||
expect(r.messageId).toBe("S");
|
||||
});
|
||||
|
||||
it("Vonage falls through to the documented default base URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
"message-count": "1",
|
||||
messages: [{ to: "1", "message-id": "x", status: "0" }],
|
||||
})
|
||||
);
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await v.send({ to: "+1", body: "x" });
|
||||
expect(r.messageId).toBe("x");
|
||||
});
|
||||
|
||||
it("MessageBird falls through to the documented default base URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder({
|
||||
id: "mb",
|
||||
originator: "S",
|
||||
body: "x",
|
||||
type: "sms",
|
||||
recipients: {
|
||||
totalCount: 1,
|
||||
items: [{ recipient: 1, status: "sent" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await m.send({ to: "+1", body: "x" });
|
||||
expect(r.messageId).toBe("mb");
|
||||
});
|
||||
|
||||
it("Plivo falls through to the documented default base URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(
|
||||
jsonResponder(
|
||||
{
|
||||
api_id: "x",
|
||||
message: "ok",
|
||||
message_uuid: ["u1"],
|
||||
},
|
||||
202
|
||||
)
|
||||
);
|
||||
const p = new PlivoProvider({
|
||||
authId: "A",
|
||||
authToken: "T",
|
||||
fetch: server.fetch,
|
||||
});
|
||||
const r = await p.send({ to: "+1", body: "x" });
|
||||
expect(r.messageId).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch coverage — extractSnsMessageId fallback for non-object/non-string", () => {
|
||||
it("returns undefined for a plain object without MessageId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({ random: "x" }));
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
await expect(a.send({ to: "+1", body: "x" })).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-string MessageId on the JSON shape", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
server = await startRecordingServer(jsonResponder({ MessageId: 42 }));
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
await expect(a.send({ to: "+1", body: "x" })).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// no-op: each it() block sets up its own server before any work.
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Coverage-completion suite — exercises the branches that the per-
|
||||
* provider feature tests skip for readability (timeoutMs / signal pass-
|
||||
* through, error-path send increments, defaultFetch construction).
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { AwsSnsProvider } from "../src/aws-sns/aws-sns.js";
|
||||
import { SMS_PROVIDERS_CODES } from "../src/error-codes/error-codes.js";
|
||||
import { httpRequest } from "../src/http/http.js";
|
||||
import { MessageBirdProvider } from "../src/messagebird/messagebird.js";
|
||||
import { PlivoProvider } from "../src/plivo/plivo.js";
|
||||
import { TwilioProvider } from "../src/twilio/twilio.js";
|
||||
import { VonageProvider } from "../src/vonage/vonage.js";
|
||||
import {
|
||||
jsonResponder,
|
||||
type RecordingServer,
|
||||
startRecordingServer,
|
||||
} from "./setup/http-server.js";
|
||||
|
||||
let server!: RecordingServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startRecordingServer(jsonResponder({}, 500));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("coverage — provider config seams (signal + timeoutMs)", () => {
|
||||
it("Twilio threads config.signal + config.timeoutMs into the request init", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
const t = new TwilioProvider({
|
||||
accountSid: "AC1",
|
||||
authToken: "tok",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await expect(
|
||||
t.send({ to: "+1", from: "+1", body: "hi" })
|
||||
).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.HTTP_ERROR });
|
||||
});
|
||||
|
||||
it("Vonage threads config.signal + config.timeoutMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
const v = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await expect(
|
||||
v.send({ to: "+1", from: "+1", body: "hi" })
|
||||
).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.HTTP_ERROR });
|
||||
});
|
||||
|
||||
it("MessageBird threads config.signal + config.timeoutMs and counts errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
const m = new MessageBirdProvider({
|
||||
accessKey: "K",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await expect(
|
||||
m.send({ to: "+1", from: "+1", body: "hi" })
|
||||
).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.HTTP_ERROR });
|
||||
});
|
||||
|
||||
it("Plivo threads config.signal + config.timeoutMs and counts errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
const p = new PlivoProvider({
|
||||
authId: "A",
|
||||
authToken: "T",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await expect(
|
||||
p.send({ to: "+1", from: "+1", body: "hi" })
|
||||
).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.HTTP_ERROR });
|
||||
});
|
||||
|
||||
it("AwsSnsProvider threads config.signal + config.timeoutMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 10_000,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
await expect(a.send({ to: "+1", body: "hi" })).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage — extractSnsMessageId fallback for non-string/non-object data", () => {
|
||||
it("returns undefined for numeric data", { timeout: 5000 }, async () => {
|
||||
await server.close();
|
||||
// Respond with a JSON number — extractSnsMessageId hits the
|
||||
// final `return undefined` branch.
|
||||
server = await startRecordingServer(jsonResponder(42));
|
||||
const a = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "K",
|
||||
secretAccessKey: "S",
|
||||
baseUrl: server.origin,
|
||||
fetch: server.fetch,
|
||||
now: () => new Date("2026-04-24T10:00:00Z"),
|
||||
});
|
||||
await expect(a.send({ to: "+1", body: "hi" })).rejects.toMatchObject({
|
||||
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage — httpRequest defaultFetch resolution", () => {
|
||||
it("falls through to globalThis.fetch when `fetch` is omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// We do not actually let the call escape — we abort immediately
|
||||
// so the call rejects after touching the defaultFetch branch.
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort(new Error("local"));
|
||||
await expect(
|
||||
httpRequest({
|
||||
method: "GET",
|
||||
url: `${server.origin}/`,
|
||||
signal: ctrl.signal,
|
||||
provider: "p",
|
||||
})
|
||||
).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..", "src");
|
||||
|
||||
function walkTypescript(dir: string): string[] {
|
||||
const entries = readdirSync(dir);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
files.push(...walkTypescript(full));
|
||||
} else if (
|
||||
entry.endsWith(".ts") &&
|
||||
!entry.endsWith(".test.ts") &&
|
||||
!entry.endsWith(".spec.ts")
|
||||
) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("universal dep graph", () => {
|
||||
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const banned =
|
||||
/from\s+['"](node:|bun:|fs|path|stream|net|http|crypto)['"]/;
|
||||
const offenders: string[] = [];
|
||||
for (const file of walkTypescript(SRC_ROOT)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
if (banned.test(text)) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Test-only helpers that stand up a real `node:http` server on a random
|
||||
* port and route a `FetchLike` through it. Used by every provider's
|
||||
* `*.test.ts` to exercise the full HTTP path without mocks.
|
||||
*
|
||||
* Lives under `tests/setup/` (not `src/`) so the universal dep-graph
|
||||
* scan never sees it.
|
||||
*/
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import type { FetchLike } from "../../src/types/provider.js";
|
||||
|
||||
export interface RecordedRequest {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly headers: Record<string, string | string[] | undefined>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface RecordingServer {
|
||||
readonly origin: string;
|
||||
readonly requests: RecordedRequest[];
|
||||
readonly fetch: FetchLike;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type Responder = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
body: string
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Stand up an HTTP server on a random port, capture every request, and
|
||||
* return a `FetchLike` that proxies any URL (regardless of original
|
||||
* scheme/host) to the local server preserving the path and method.
|
||||
*/
|
||||
export async function startRecordingServer(
|
||||
respond: Responder
|
||||
): Promise<RecordingServer> {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const server: Server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
requests.push({
|
||||
method: req.method ?? "GET",
|
||||
path: req.url ?? "/",
|
||||
headers: { ...req.headers },
|
||||
body,
|
||||
});
|
||||
respond(req, res, body);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
const addr = server.address();
|
||||
if (addr === null || typeof addr === "string") {
|
||||
throw new Error("server.address() returned an unexpected value");
|
||||
}
|
||||
const origin = `http://127.0.0.1:${addr.port}`;
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
const u = new URL(url);
|
||||
const rerouted = `${origin}${u.pathname}${u.search}`;
|
||||
return globalThis.fetch(rerouted, init);
|
||||
};
|
||||
const close = async (): Promise<void> => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
};
|
||||
return { origin, requests, fetch: fetchImpl, close };
|
||||
}
|
||||
|
||||
/** Convenience: respond with a JSON body + status. */
|
||||
export function jsonResponder(body: unknown, status = 200): Responder {
|
||||
return (_req, res) => {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Polyfill setup for @suaveplan/sms-providers tests.
|
||||
//
|
||||
// Every provider test injects a `FetchLike` that either returns a canned
|
||||
// `Response` or proxies through a real `node:http` server on a random port,
|
||||
// so production code never reaches into a platform fetch. This file mirrors
|
||||
// the sibling-package layout and gives vitest a stable setup-file path.
|
||||
export {};
|
||||
12
packages/communication/messaging/sms-providers/tsconfig.json
Normal file
12
packages/communication/messaging/sms-providers/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/sms-providers",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/sms",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user