11 KiB
sms-providers — spec delta
ADDED Requirements
Requirement: Package layout & versioning
The package SHALL live at packages/communication/messaging/sms-providers/, ship at 0.1.0, be universal-tier in src/ (no hard browser/node imports outside the test tree), and expose a public ES-module barrel + per-feature subpath exports.
Scenario: Package metadata is correct
- WHEN
package.jsonis loaded - THEN
name === "@suaveplan/sms-providers",version === "0.1.0",type === "module",sideEffects === false, andexports["."]resolves to./dist/index.js
Verified by: structural inspection via tests/dep-graph.test.ts + the package.json JSON itself; CI gate bunx turbo lint typecheck test build --filter=@suaveplan/sms-providers.
Scenario: Source tree is universal-tier
- WHEN every
.tsundersrc/(excluding*.test.ts) is scanned - THEN none import from
node:*,bun:*,fs,path,stream,net,http, orcrypto
Verified by: tests/dep-graph.test.ts (regex scan failing the suite on any banned import).
Requirement: Namespaced error code registry
The package SHALL define an SMS_PROVIDERS_CODES registry via defineCodes("SMS_PROVIDERS", […]) containing exactly eight codes — HTTP_ERROR, TIMEOUT, PARSE_ERROR, CONFIG_MISSING, SIGNATURE_INVALID, INVALID_NUMBER, PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED — and SHALL export a SmsProvidersError discriminated subclass of SuaveplanError carrying optional provider, httpStatus, url, and bodyExcerpt discriminants alongside six factory helpers (httpError, timeoutError, parseError, configMissing, signatureInvalid, providerNotRegistered/providerAlreadyRegistered).
Scenario: Codes are namespaced
- WHEN
SMS_PROVIDERS_CODES.HTTP_ERRORis read - THEN its value equals
"SMS_PROVIDERS.HTTP_ERROR"
Scenario: Factories propagate discriminants
- WHEN
httpError("twilio", 422, "https://api.twilio.com/x", "body", "msg")is called - THEN the returned error has
code === "SMS_PROVIDERS.HTTP_ERROR",provider === "twilio",httpStatus === 422,url === "https://api.twilio.com/x", andbodyExcerpt.startsWith("body")
Verified by: src/error-codes/error-codes.test.ts (every code + every factory exercised; body-excerpt truncation at 256 chars asserted).
Requirement: httpRequest over injectable FetchLike
The package SHALL ship a universal httpRequest<T> helper that wraps an injectable FetchLike (defaulting to globalThis.fetch.bind(globalThis)), serialises JSON or string bodies, composes a caller-supplied AbortSignal with an internal timeout-driven signal via composeAbortSignal, raises SmsProvidersError(SMS_PROVIDERS.TIMEOUT) on the internal abort, SmsProvidersError(SMS_PROVIDERS.HTTP_ERROR) on non-2xx responses (with the request body excerpted to 256 chars), and SmsProvidersError(SMS_PROVIDERS.PARSE_ERROR) on JSON parse failure.
Scenario: Timeout fires the typed error
- WHEN
httpRequestis invoked withtimeoutMs: 5against a fetch that never resolves - THEN it rejects with
SmsProvidersErrorcarryingcode === "SMS_PROVIDERS.TIMEOUT"
Verified by: src/http/http.test.ts (timeout, abort propagation, JSON+form body shapes, non-2xx → typed error, parse failure → typed error, header forwarding).
Requirement: Provider registry
The package SHALL ship a ProviderRegistry indexing SmsProvider instances by name, with register throwing PROVIDER_ALREADY_REGISTERED on duplicate names, get throwing PROVIDER_NOT_REGISTERED on absent names, has, unregister (idempotent), list() returning a frozen insertion-ordered array, and size.
Scenario: Registry rejects duplicate names
- WHEN
register(provider1)andregister(provider2)are called withprovider1.name === provider2.name - THEN the second call throws
SmsProvidersError(SMS_PROVIDERS.PROVIDER_ALREADY_REGISTERED)withproviderdiscriminant set to the colliding name
Verified by: src/registry/registry.test.ts.
Requirement: Five real provider clients
The package SHALL ship TwilioProvider, VonageProvider, MessageBirdProvider, PlivoProvider, and AwsSnsProvider, each implementing the SmsProvider interface from @suaveplan/sms (literal name, send(message, options?), getStatus(messageId)). Every implementation SHALL issue real HTTP requests through httpRequest, honour the caller-supplied AbortSignal, fail fast with SmsProvidersError(SMS_PROVIDERS.CONFIG_MISSING) on missing required credentials at construction time, and expose a verifyWebhookSignature(...) method using the provider's documented signing scheme.
Scenario: Twilio issues the documented Messages.json POST
- WHEN
new TwilioProvider({accountSid:"AC1", authToken:"t", fetch}).send({to:"+1…", from:"+1…", body:"hi"})is called - THEN the captured request method is
"POST", URL ends with/2010-04-01/Accounts/AC1/Messages.json,Authorizationheader equalsBasic ${base64(AC1:t)},Content-Typeisapplication/x-www-form-urlencoded, and the body containsTo=%2B1%E2%80%A6,From=…,Body=hi
Scenario: Vonage issues the documented /sms/json POST
- WHEN
new VonageProvider({apiKey:"k", apiSecret:"s", fetch}).send({to:"447…", from:"Sender", body:"hi"})is called - THEN the captured request URL ends with
/sms/json, the form body containsapi_key=k,api_secret=s,from=Sender,to=447…,text=hi, andtypeisunicodewhenever the body contains a non-ASCII codepoint
Scenario: MessageBird issues the documented /messages POST
- WHEN
new MessageBirdProvider({accessKey:"K", fetch}).send({to:"+1…", from:"Sender", body:"hi"})is called - THEN the captured request
Authorizationheader equals"AccessKey K",Content-Typeisapplication/json, and the JSON body hasrecipients: ["+1…"],originator: "Sender",body: "hi"
Scenario: Plivo issues the documented Message POST
- WHEN
new PlivoProvider({authId:"A", authToken:"T", fetch}).send({to:"+1…", from:"+1…", body:"hi"})is called - THEN the captured URL ends with
/v1/Account/A/Message/,AuthorizationequalsBasic ${base64("A:T")},Content-Typeisapplication/json, and the JSON body hassrc,dst,text
Scenario: AWS SNS issues a SigV4-signed Publish POST
- WHEN
new AwsSnsProvider({region:"us-east-1", accessKeyId:"K", secretAccessKey:"S", fetch}).send({to:"+1…", body:"hi"})is called - THEN the captured URL hostname matches
sns.us-east-1.amazonaws.com,Authorizationstarts withAWS4-HMAC-SHA256 Credential=K/, containsSignedHeaders=, containsSignature=(64 hex chars), and the form body containsAction=Publish&Message=hi&PhoneNumber=%2B1%E2%80%A6
Verified by: one <provider>.test.ts per provider that binds a real node:http server on a random port, configures the provider with a FetchLike pointing at that server, dispatches the operation, captures the raw request, and asserts URL/method/headers/body/SigV4-shape; ditto for getStatus; all under bunx turbo test --filter=@suaveplan/sms-providers (verified by tests).
Requirement: Webhook signature verification
Each provider class SHALL expose verifyWebhookSignature(...) returning true on a valid signature and throwing SmsProvidersError(SMS_PROVIDERS.SIGNATURE_INVALID) (also recorded on the signature_failures_total counter) on an invalid one. The signing algorithms are: Twilio HMAC-SHA1 over url + sorted-form-fields, Vonage HMAC-SHA256 over sorted-params-without-sig, MessageBird JWT HS256 with iat/exp/nbf window enforcement, Plivo HMAC-SHA256 over url + nonce + sorted-form-fields, AWS SNS PEM-cert RSA-SHA1 over the canonical string-to-sign assembled from the message fields.
Scenario: A tampered Twilio webhook is rejected
- WHEN the body is mutated after signing and
verifyWebhookSignature(url, params, signature)is called - THEN the call throws
SmsProvidersError(SMS_PROVIDERS.SIGNATURE_INVALID)withprovider === "twilio"and incrementssms_providers.signature_failures_total{provider="twilio"}
Verified by: one signature suite per provider in <provider>.test.ts using real node:crypto HMAC + RSA to compute expected signatures, exercising accept + reject paths (verified by tests).
Requirement: Status mapping
Each provider class SHALL expose a mapStatus(raw: string): SmsDeliveryStatus method covering at minimum the provider's documented terminal and in-flight states (Twilio: queued/sending/sent/delivered/undelivered/failed; Vonage: submitted/buffered/delivered/expired/failed/rejected/accepted; MessageBird: scheduled/sent/buffered/delivered/expired/delivery_failed; Plivo: queued/sent/delivered/undelivered/failed/rejected; AWS SNS: pending/success/failure/optout), and SHALL default unknown values to "failed".
Scenario: Case-insensitive status mapping
- WHEN
TwilioProvider#mapStatus("DELIVERED")is invoked - THEN it returns
"delivered"
Verified by: dedicated mapStatus describe block per provider (verified by tests).
Requirement: OTel instrumentation
The package SHALL emit exactly five OTel instruments — sms_providers.send_total{provider,outcome} (counter), sms_providers.webhook_total{provider,event,outcome} (counter), sms_providers.signature_failures_total{provider} (counter), sms_providers.send_duration_ms{provider} (histogram), sms_providers.webhook_verify_duration_ms{provider} (histogram) — via helpers recordSend, recordWebhook, recordSignatureFailure, recordSendDuration, recordWebhookVerifyDuration, all routed through @suaveplan/opentelemetry.
Scenario: A successful send increments send_total{outcome="ok"}
- WHEN
recordSend("twilio", "ok")is called - THEN the recorder receives an increment of 1 on the
send_totalcounter with attributes{provider:"twilio", outcome:"ok"}
Verified by: src/otel/otel.test.ts using an in-memory test reader (verified by tests).
Requirement: Inbound opt-in/opt-out keyword detection
The package SHALL ship a universal parseOptOutKeyword(body: string): OptOutAction recognising the canonical compliance keywords STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT (→ "opt_out"), START / YES / UNSTOP (→ "opt_in"), HELP / INFO (→ "help"), and any other body → "none". Recognition SHALL be case-insensitive and ignore surrounding whitespace.
Scenario: STOP is recognised regardless of case and whitespace
- WHEN
parseOptOutKeyword(" Stop ")is called - THEN it returns
"opt_out"
Verified by: src/keywords/keywords.test.ts (every keyword + non-match path) (verified by tests).