Files
suaveplan/openspec/archive/2026-05-25-enterprise-sms-providers/specs/sms-providers/spec.md
2026-05-27 23:29:53 +01:00

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.json is loaded
  • THEN name === "@suaveplan/sms-providers", version === "0.1.0", type === "module", sideEffects === false, and exports["."] 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 .ts under src/ (excluding *.test.ts) is scanned
  • THEN none import from node:*, bun:*, fs, path, stream, net, http, or crypto

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_ERROR is 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", and bodyExcerpt.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 httpRequest is invoked with timeoutMs: 5 against a fetch that never resolves
  • THEN it rejects with SmsProvidersError carrying code === "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) and register(provider2) are called with provider1.name === provider2.name
  • THEN the second call throws SmsProvidersError(SMS_PROVIDERS.PROVIDER_ALREADY_REGISTERED) with provider discriminant 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, Authorization header equals Basic ${base64(AC1:t)}, Content-Type is application/x-www-form-urlencoded, and the body contains To=%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 contains api_key=k, api_secret=s, from=Sender, to=447…, text=hi, and type is unicode whenever 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 Authorization header equals "AccessKey K", Content-Type is application/json, and the JSON body has recipients: ["+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/, Authorization equals Basic ${base64("A:T")}, Content-Type is application/json, and the JSON body has src, 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, Authorization starts with AWS4-HMAC-SHA256 Credential=K/, contains SignedHeaders=, contains Signature= (64 hex chars), and the form body contains Action=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) with provider === "twilio" and increments sms_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_total counter 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).