Initial Commit
This commit is contained in:
114
openspec/specs/sms-providers/spec.md
Normal file
114
openspec/specs/sms-providers/spec.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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).
|
||||
|
||||
**Verified by:** `tests/branch-coverage-2.test.ts > http.ts — headers.get('content-type') returns null > treats a missing content-type header as empty and falls back to start-byte JSON sniffing` (covers the `headers.get("content-type") ?? ""` nullish default arm).
|
||||
|
||||
### 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).
|
||||
|
||||
**Verified by:** `tests/branch-coverage.test.ts` + `tests/branch-coverage-2.test.ts` for the conditional-spread arms and default-config paths — `getStatus` with/without `errorCode`/`errorMessage`/`error_code`/`date_created`; default `baseUrl` arms for Twilio, Vonage, MessageBird, Plivo, AWS SNS; AWS SNS default `now` factory; `extractSnsMessageId` non-object + non-string-MessageId fallbacks; AWS SNS `parseInbound` delivery-receipt without `errorCode`, with empty `status`, and with a non-object JSON Message payload; AWS SNS `fetchPem` default-`globalThis.fetch` arm; MessageBird `verifyWebhookSignature` default `nowMs` arm; MessageBird/Plivo/Vonage `parseInbound` fallthrough defaults (`originator`/`from`/`text`/`payload`/`body` absent); per-provider `common()` "no explicit fetch" arm via swapped `globalThis.fetch`; deletion of the unreachable `charCodeAt(i) ?? 0` defensive coalesce in `vonage.ts`.
|
||||
|
||||
### 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).
|
||||
Reference in New Issue
Block a user