first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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)`.

View File

@@ -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.

View File

@@ -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 *`).

View File

@@ -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.