diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd6aea5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,143 @@ +# AGENTS.md — Driving a Twilio→Bandwidth cutover with this adapter + +This adapter translates a **live call flow** (Twilio TwiML ⇄ Bandwidth BXML) so a +customer's existing Twilio voice app runs over Bandwidth with a single URL change. +It does **not** provision anything on a Bandwidth account. + +## The paired model + +Use two tools together: + +- **This adapter** — translates the call flow and proxies live calls. +- **The `band` CLI** — executes account-side actions on the user's Bandwidth + account (numbers, applications, service activation). `band` is agent-native: + JSON output by default, `--plain` for stable parsing, `--wait` for async ops, + `--if-not-exists` for idempotency. + +An agent runs `band` to provision, then configures and runs this adapter. + +## What an agent can and cannot do + +**Can do unattended:** preflight analysis, provisioning on an *existing* +Bandwidth account via `band`, adapter configuration, and the readiness check. + +**🧍 Human required** (flagged inline below): creating a brand-new Bandwidth +account, enabling the account "HTTP Voice" feature, providing a public HTTPS +host, and final by-ear call verification. + +## Runbook + +### Phase 1 — Preflight +```bash +npm install +npm run preflight -- # complexity + per-file verdict +npm run generate -- # writes out/bxml/*, out/MIGRATION.md, out/coverage.json +``` +Read `out/coverage.json` for the machine-readable verdict. If a source file uses +the Twilio SDK to build TwiML at runtime, it is a **dynamic source** — there is no +static markup to transpile. See "Capability boundaries". + +### Phase 2 — Provision (via `band`) +This runbook uses the **Universal Platform (VCP)** path, `band`'s default for +new accounts; legacy-platform accounts provision a SIP peer instead via the +`--legacy` subcommands (see `band quickstart --help`) and skip the +`vcp create`/`vcp assign` steps below. +```bash +band auth login # BW_CLIENT_ID / BW_CLIENT_SECRET; --plain for JSON. Needs a keychain backend on headless hosts. +band subaccount create --name "" --if-not-exists +band number search --area-code --quantity 1 +band number order --subaccount --wait +band app create --name "" --type voice --callback-url "/bw/initiate" --if-not-exists # PUBLIC_BASE_URL already includes the https:// scheme +band vcp create --name "" --app-id --if-not-exists +band vcp assign +band number activate --voice-inbound --wait +``` +🧍 **Human required:** if the account is brand-new, `band account register` needs +email/SMS OTP first. If `band app create` errors that the account lacks the "HTTP +Voice" feature, a human must request it from Bandwidth support — the CLI cannot +enable it. + +### Phase 3 — Configure the adapter +Set these env vars (the server reads **these exact names** — note the checked-in +`.env` uses different, non-functional names): + +| Var | Who sets it | +|---|---| +| `ADAPTER_ACCOUNT_SID`, `ADAPTER_AUTH_TOKEN` | agent (adapter's own Twilio-compat creds) | +| `CUSTOMER_VOICE_URL` | agent (the customer's unchanged Twilio app URL) | +| `WEBHOOK_USER`, `WEBHOOK_PASSWORD` | agent — Basic-auth creds Bandwidth presents on inbound `/bw/*` webhooks; **must match the `CallbackCreds` set on the BW Voice Application in Phase 2** | +| `PUBLIC_BASE_URL` | 🧍 **Human required** (public HTTPS host/tunnel) | +| `BW_ACCOUNT_ID`, `BW_CLIENT_ID`, `BW_CLIENT_SECRET`, `BW_APPLICATION_ID` | from Phase 2 | + +> Non-loopback deploys: the listen host defaults to `127.0.0.1`; set `HOST=0.0.0.0` (or a specific interface) to expose the adapter behind your `PUBLIC_BASE_URL`. + +### Phase 4 — Deploy +🧍 **Human required:** provide a public HTTPS host (or tunnel) for +`PUBLIC_BASE_URL`. The BW Voice Application's callback (set in Phase 2) must point +at `/bw/initiate`. + +### Phase 5 — Verify +```bash +npm run doctor # full local gate: which env is set AND whether the BW token exchange works. Exit 0 = ready. +curl -s localhost:3000/readyz | jq # running-server config/liveness check (env presence only; no token probe, no secrets) +``` +🧍 **Human required:** place a real call to the Bandwidth number and walk the IVR +by ear. There is no scripted end-to-end call check. + +## Capability boundaries + +Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess. + +- **Supported:** most core voice verbs — Say, Play, Gather, Pause, Hangup, + Redirect, Record, Number, Sip (translate cleanly to BXML). A few of these + still drop attributes safely rather than silently: `Say`/`Play`'s + `loop="0"` (infinite) can't be expressed inline in BXML, so it emits once and + warns; `Record`'s `transcribe` and `playBeep` attributes have no direct BXML + equivalent. +- **Lossy / partial (feature loss — needs a human decision):** + - `Reject` — maps to `Hangup`, but Bandwidth answers before hanging up, so the + caller may be billed for a short call. + - `Dial` — `Number`/`Sip` nouns map to `Transfer`, `Conference` noun maps to + `Conference`; the `Queue` and `Client` nouns are unsupported, and deep Dial + semantics (`answerOnBridge`, child-call status propagation) are not + replicated. + - `Start` — the `Transcription` noun maps to `StartTranscription`; the + `Siprec` and `VirtualAgent` nouns are unsupported. + - `Refer` — Bandwidth only honors `Refer` on inbound SIP URI calls, so a PSTN + call leg cannot be REFER'd (a platform constraint, not a translation gap). + - `Connect` — the `Stream` noun maps to `StartStream` via the Media Streams + bridge; `ConversationRelay` and `VirtualAgent` are unsupported (separate + IoV). + - `Stream` — Twilio's WS message schema is emulated by the adapter's stream + bridge; live Bandwidth-side binding requires fixture capture. + - `Conference` — basic named conferences work, but `waitUrl` hold music has + no Bandwidth equivalent, `beep` is only partially supported, and + `startConferenceOnEnter`/`endConferenceOnExit`/`maxParticipants` have no + verb-level equivalent — those are managed via the Bandwidth Conferences + **REST API**, not the BXML verb. +- **Unsupported (no BXML equivalent — a business decision to drop/redesign):** + `Enqueue`, `Leave`, `Queue`, `Client`, `Pay`. Bandwidth has no queue primitive + (`Enqueue`/`Leave`/`Queue` fail loudly), no WebRTC client endpoint + (`Client`), and PCI payment capture (`Pay`) is out of scope for the adapter. +- **Dynamic SDK-built TwiML:** if the customer app generates TwiML at runtime, + capture live responses and re-run `generate`, or port the logic by hand. + +## Errors + +Operational failures return a Twilio-shaped JSON body: `{ code, message, more_info, status }`. +Adapter-specific codes use a private range and are documented here: + +| code | status | meaning | +|---|---|---| +| 90001 | 400 | Missing required request parameter | +| 90002 | 500 | Internal adapter error (detail is in server logs, not the response) | +| 90003 | 4xx | Malformed request rejected before handling | +| 90004 | 400 | Request parameter present but failed validation (e.g. an unsafe identifier) | + +Twilio-API-compat errors (e.g. `401`/`404` on the `/2010-04-01/...` facade) keep +their Twilio codes and semantics. + +One exception: a request to a path with **no route at all** (e.g. the removed +number-provisioning endpoints — use `band` instead) returns the framework's +default `404` body, not a Twilio-shaped one. Only routed operations go through +the structured-error path above. diff --git a/README.md b/README.md index e803eff..d1bf433 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Run an existing Twilio voice app on Bandwidth's network — without rewriting it.** Point your app at the adapter, change one URL, and its calls now run on Bandwidth. The code never changes. -> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls. Number **search** is live-verified; number **ordering/release** are experimental (see [Number lifecycle](#number-lifecycle)). 265 automated tests, typecheck clean. +> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls, with an automated test suite and clean typecheck. This adapter does not order or manage Bandwidth numbers itself — pair it with the [`band` CLI](#pairs-with-the-band-cli) for account-side provisioning. --- @@ -19,6 +19,18 @@ Same concepts, different words — like British vs. American English. This adapt The translation is a fixed rulebook driven by a single [compatibility matrix](src/matrix/twilio-voice.json), not an AI guessing — for live calls, "mostly right" isn't good enough. When a customer uses something the adapter *can't* do yet, it **says so and stops**, rather than breaking the call silently. +### Pairs with the `band` CLI + +This adapter only translates the **call flow** — it does not touch a customer's +Bandwidth account. Account-side actions (buying a number, creating a Voice +Application, activating voice on a number) are a separate concern, handled by +the [`band` CLI](https://dev.bandwidth.com/tools/cli/). The two pair naturally: an +agent runs `band` to provision the account, then configures and runs this +adapter to carry the calls. If you're an agent driving an end-to-end cutover, +start with **[`AGENTS.md`](AGENTS.md)** — it's the phased runbook with the +exact `band` commands, the env vars this adapter reads, and every step that +still needs a human. + --- ## Capabilities @@ -46,12 +58,9 @@ The translation is a fixed rulebook driven by a single [compatibility matrix](sr | Recording callback (`recordingStatusCallback`) | ✅ | Unit | | Media Streams bridge (AI-voice path) | ✅ | Unit | -### Number lifecycle -| Operation | Status | Verified | -|---|:--:|:--:| -| OAuth2 auth · **Search** | ✅ | **Live** | -| Order · Release | ⚠️ | Experimental — not verified ([details](#number-lifecycle)) | -| Activate (route a bought number) · Update | ❌ | — not built | +Number provisioning (search/order/activate) isn't part of this adapter — it's +handled by the [`band` CLI](#pairs-with-the-band-cli); see the Phase 2 runbook +in [`AGENTS.md`](AGENTS.md). --- @@ -72,13 +81,14 @@ npm run preflight -- examples/full-pv-app ``` Prints a migration-complexity score and a per-feature *works-as-is / heads-up / blocker* breakdown. -**Run the translation loop locally (no credentials):** see [`docs/demo.md`](docs/demo.md) §1–§2 — the customer's sample app + the adapter + simulated Bandwidth webhooks, all on localhost. §4 covers the recording/status callbacks and number search. +**Run the translation loop locally (no credentials):** see [`docs/demo.md`](docs/demo.md) §1–§2 — the customer's sample app + the adapter + simulated Bandwidth webhooks, all on localhost. §4 covers the recording/status callbacks. **Run the adapter** (Node 20+): ```bash npm start # adapter on :3000 -npm test # 303 tests +npm test # run the test suite npm run typecheck +npm run doctor # readiness check — see AGENTS.md Phase 5 ``` | Env var | Meaning | @@ -90,12 +100,14 @@ npm run typecheck | `EGRESS_ALLOW_HOSTS` | Optional comma-separated host allowlist; when set, outbound fetches are restricted to exactly these hosts (default-deny) | | `PUBLIC_BASE_URL` | Public HTTPS base of this adapter | | `CUSTOMER_VOICE_URL` | The customer's Twilio voice webhook (inbound calls) | -| `BW_ACCOUNT_ID` / `BW_CLIENT_ID` / `BW_CLIENT_SECRET` / `BW_APPLICATION_ID` | Bandwidth credentials (OAuth2 client-credentials) — shared by Voice and the number facade | -| `BW_SITE_ID` / `BW_PEER_ID` | Optional — enable number ordering (search/release use the shared `BW_CLIENT_*` creds) | -| `BW_NUMBERS_BASE_URL` | Optional — override the Numbers API v2 base (tests/staging) | +| `BW_ACCOUNT_ID` / `BW_CLIENT_ID` / `BW_CLIENT_SECRET` / `BW_APPLICATION_ID` | Bandwidth credentials (OAuth2 client-credentials), provisioned via `band` — see [`AGENTS.md`](AGENTS.md) | | `BW_ENVIRONMENT` | Optional — `test` targets BW's test hosts; defaults to `prod` | | `ADAPTER_LOG=1` | Optional — enable request logging | +Run `npm run doctor` (or `GET /readyz?deep=1` once the server is up) to confirm +this env is set and the Bandwidth OAuth2 token exchange works before routing +real calls. + --- ## REST facade @@ -111,11 +123,11 @@ Backend calls the Twilio SDK makes are served in Twilio's shape (under `/2010-04 | `GET /Recordings/{sid}.json` | `recordings(sid).fetch()` | recording metadata | | `GET /Recordings/{sid}.{mp3,wav}` | recording media URL | audio, stream-proxied from Bandwidth | | `POST /Calls/{sid}/Recordings/{recSid}.json` | `recordings(sid).update({status})` | pause / resume (`stopped` fails loudly — no BW REST stop) | -| `GET /AvailablePhoneNumbers/{Country}/Local.json` | `availablePhoneNumbers(c).local.list()` | search inventory — **verified live** | -| `POST /IncomingPhoneNumbers.json` | `incomingPhoneNumbers.create()` | order — ⚠️ **experimental, not verified** | -| `DELETE /IncomingPhoneNumbers/{sid}.json` | `incomingPhoneNumbers(sid).remove()` | release — ⚠️ **experimental, not verified** | -Unknown calls/recordings return Twilio's `20404`; bad credentials return `20003`. Call/recording state is in-memory (single-instance): a recording must be listed before it can be fetched by SID on a fresh instance. +Unknown calls/recordings return Twilio's `20404`; bad credentials return `20003`. Call/recording state is in-memory (single-instance): a recording must be listed before it can be fetched by SID on a fresh instance. Adapter-specific operational failures (missing params, internal errors) use a separate private code range — see [`AGENTS.md#errors`](AGENTS.md#errors). + +This facade does **not** include number search/order/release — those routes +were removed in favor of the `band` CLI (see [`AGENTS.md`](AGENTS.md) Phase 2). ### Callbacks to your app (egress) @@ -124,16 +136,6 @@ The adapter also calls *you* back in Twilio's shape, mapped from Bandwidth's eve - **Status callback** — when a call ends, the `StatusCallback` set on `calls.create()` gets a Twilio-shaped `completed` callback (`CallSid`, `CallStatus`, `CallDuration`), mapped from Bandwidth's disconnect event. - **Recording callback** — a `` maps to Bandwidth's `recordingAvailableUrl`; when the recording is ready the adapter forwards a Twilio `recordingStatusCallback`, with `RecordingUrl` pointing back at this facade so a later fetch resolves through the adapter. -### Number lifecycle - -Shares the platform OAuth2 client-credentials with the Voice API (one token; the account's roles decide what it can do). **Verified live (2026-06-19):** - -- **Search** — ✅ verified. OAuth2 token exchange and `GET /accounts/{id}/availableNumbers` both confirmed against real Bandwidth, returning real inventory. -- **Order** (`POST /IncomingPhoneNumbers.json`) — ⚠️ experimental. The v2 JSON order endpoint rejects the current request body; the real order schema must be captured (e.g. from the `band` CLI) before this works. Needs `BW_SITE_ID`. -- **Release** (`DELETE`) — ⚠️ experimental. The real disconnect endpoint returns XML, not JSON; the client needs an XML path. - -Reproduce the live check (read-only by default) with [`scripts/verify-numbers-live.ts`](scripts/verify-numbers-live.ts). - --- ## What it can't do yet @@ -142,7 +144,10 @@ Reproduce the live check (read-only by default) with [`scripts/verify-numbers-li - **Conference hold music** — no Bandwidth equivalent. - **Speech recognition** — translated correctly, but must be enabled on the Bandwidth account. - **Stopping a recording via REST** — Bandwidth pauses/resumes over REST but has no REST *stop* (`StopRecording` is a BXML verb), so `Status=stopped` fails loudly rather than silently. -- **Number ordering / release / activate** — see [Number lifecycle](#number-lifecycle). +- **Number provisioning** — this adapter doesn't order, activate, or otherwise manage Bandwidth numbers; use the `band` CLI (see [`AGENTS.md`](AGENTS.md) Phase 2). + +See [`AGENTS.md`](AGENTS.md) for the full unsupported/lossy verb breakdown and +the private error codes this adapter raises. --- @@ -154,11 +159,14 @@ All translation is driven by one declarative compatibility matrix (`src/matrix/t src/translator TwiML → BXML translation src/twilio Twilio-shaped REST facade + signed webhooks (egress) src/streams Media Streams bridge -src/numbers number-lifecycle facade (search/order/release) -src/server the proxy (Fastify) wiring it together +src/server the proxy (Fastify) wiring it together, readiness check (/readyz, npm run doctor) src/preflight static migration-complexity report +src/generate batch BXML generation + coverage report (see AGENTS.md Phase 1) web Migration Preflight playground (double-click HTML demo) -scripts build-playground.ts + benches / live-verify helpers +scripts build-playground.ts + benches ``` Tests live in `test/` (Vitest); CI gates `npm run typecheck` + `npm test`. + +For driving an end-to-end cutover (including the account-side `band` steps and +every point a human still needs to be in the loop), see [`AGENTS.md`](AGENTS.md). diff --git a/docs/demo.md b/docs/demo.md index 5e18399..8c2462e 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -24,13 +24,20 @@ ADAPTER_ACCOUNT_SID=AC123 ADAPTER_AUTH_TOKEN=demo \ PUBLIC_BASE_URL=http://localhost:3000 \ CUSTOMER_VOICE_URL=http://localhost:4000/voice \ BW_ACCOUNT_ID=x BW_CLIENT_ID=x BW_CLIENT_SECRET=x BW_APPLICATION_ID=x \ +WEBHOOK_USER=demo WEBHOOK_PASSWORD=demo \ +EGRESS_ALLOW_PRIVATE=1 \ npm start ``` +> `WEBHOOK_USER`/`WEBHOOK_PASSWORD` gate the inbound `/bw/*` webhooks (the curls +> below pass them with `-u demo:demo`). `EGRESS_ALLOW_PRIVATE=1` is needed only +> because this demo's customer app runs on `localhost` — the egress guard blocks +> loopback/private targets by default. + Terminal C — simulate a Bandwidth `initiate` webhook (a real inbound call): ```bash -curl -s -X POST http://localhost:3000/bw/initiate \ +curl -s -u demo:demo -X POST http://localhost:3000/bw/initiate \ -H 'Content-Type: application/json' \ -d '{"eventType":"initiate","callId":"demo-1","from":"+15550001111","to":"+15552223333","direction":"inbound"}' ``` @@ -40,7 +47,7 @@ Expected: BXML with `` + `` to the sales number. ## 3. Live call (requires BW account + numbers) Provisioning checklist: BW test account with Voice API enabled, a sub-account/ -site + SIP peer, 2–3 voice-enabled numbers, a Voice application pointed at the -public adapter URL (`/bw/initiate`), and a public HTTPS tunnel (ngrok) or small -host. Call the BW number, walk the IVR by ear. Then exercise outbound via the -REST facade with the real `twilio` SDK pointed at the adapter base URL. +site, 2–3 voice-enabled numbers, a Voice application pointed at the public +adapter URL (`/bw/initiate`), and a public HTTPS tunnel (ngrok) or small host. +(A SIP peer is only needed on the legacy platform; the default Universal +Platform path uses a VCP instead — see `AGENTS.md` Phase 2.) Call the BW number, +walk the IVR by ear. Then exercise outbound via the REST facade with the real +`twilio` SDK pointed at the adapter base URL. P0 exit criteria for the live milestone: 1. Inbound IVR (Say/Gather/Transfer) works on a real phone call. @@ -61,11 +70,11 @@ P0 exit criteria for the live milestone: native StartStream (the AI-voice segment is latency-sensitive — this number decides whether the compat mode is demo-grade or product-grade). -## 4. Callbacks & number lifecycle +## 4. Callbacks -These layer on top of the loop above. The webhook callbacks can be shown with -no credentials; live number ordering is gated on the Numbers-role / OAuth2 -credential (see `src/numbers/client.ts`). +These layer on top of the loop above and can be shown with no credentials. +Number provisioning is not part of this adapter — it's handled by the `band` +CLI; see the Phase 2 runbook in [`AGENTS.md`](../AGENTS.md). Add a stand-in for the customer's callback receiver: @@ -74,16 +83,16 @@ Add a stand-in for the customer's callback receiver: node -e "require('http').createServer((q,s)=>{let b='';q.on('data',d=>b+=d);q.on('end',()=>{console.log('\n['+q.url+'] '+b);s.end('ok')})}).listen(4001,()=>console.log('catcher :4001'))" ``` -### 4a. Recording callback (no credentials needed) +### 4a. Recording callback Seed a call record via `initiate` (Terminal A from §2 makes it return 200), then fire Bandwidth's "recording available" event: ```bash -curl -s -X POST http://localhost:3000/bw/initiate -H 'Content-Type: application/json' \ +curl -s -u demo:demo -X POST http://localhost:3000/bw/initiate -H 'Content-Type: application/json' \ -d '{"eventType":"initiate","callId":"demo-1","from":"+15550001111","to":"+15552223333","direction":"inbound"}' >/dev/null -curl -s -X POST 'http://localhost:3000/bw/recording-status?cb=http%3A%2F%2Flocalhost%3A4001%2Frec-ready' \ +curl -s -u demo:demo -X POST 'http://localhost:3000/bw/recording-status?cb=http%3A%2F%2Flocalhost%3A4001%2Frec-ready' \ -H 'Content-Type: application/json' \ -d '{"eventType":"recordingAvailable","callId":"demo-1","recordingId":"r-abc","duration":"PT12S","status":"complete"}' ``` @@ -110,29 +119,11 @@ adapter fires a signed Twilio `completed` callback (`CallStatus=completed`, `CallDuration`, `CallSid`) to that URL. No-telephony proof: `npx vitest run test/server-status-callback.test.ts`. -### 4c. Number search (verified live); order & release (experimental) - -The facade uses the same `BW_CLIENT_ID` / `BW_CLIENT_SECRET` OAuth2 credentials -as the Voice API. - -**Search is verified against real Bandwidth.** A live read-only check (OAuth2 -token exchange + `availableNumbers`) is in `scripts/verify-numbers-live.ts`: - -```bash -set -a; . ./.env; set +a -BW_ACCOUNT_ID= npx tsx scripts/verify-numbers-live.ts # token + live search -``` - -**Order and release are experimental and NOT verified live** (2026-06-19): the -v2 JSON order endpoint rejects the current request body, and the disconnect -endpoint returns XML rather than JSON. Don't demo these as working — the real -order schema must be captured (e.g. from the `band` CLI) and an XML disconnect -path added first. The same script can attempt a real order + immediate release -with `VERIFY_ORDER=1` once a site is set, but it currently fails at the order step. - -Unit coverage (no creds) exercises the translation + response-shaping logic: +### 4c. Number provisioning — now via `band` -```bash -npx vitest run test/server-numbers.test.ts # search, order, release (mocked BW) -npx vitest run test/numbers-client.test.ts # OAuth2 token exchange + Bearer + caching + live search shape -``` +Number search/order/activate used to be served through this adapter's own +REST facade; that surface has been removed in favor of the `band` CLI, which +is the account-side tool of record. To demo provisioning, run the Phase 2 +commands from [`AGENTS.md`](../AGENTS.md) (`band number search`, `band number +order --wait`, `band number activate --voice-inbound --wait`, etc.) against a +real Bandwidth account instead. diff --git a/package.json b/package.json index 0295943..47072fc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "test": "vitest run", "typecheck": "tsc --noEmit", "start": "tsx src/server/index.ts", + "doctor": "tsx src/server/doctor.ts", "preflight": "tsx src/preflight/cli.ts", "generate": "tsx src/generate/cli.ts", "bench": "tsx scripts/bench-translate.ts", diff --git a/scripts/capture-server.mjs b/scripts/capture-server.mjs index 93ead92..bf6f40e 100644 --- a/scripts/capture-server.mjs +++ b/scripts/capture-server.mjs @@ -27,8 +27,11 @@ createServer((req, res) => { contentType: req.headers["content-type"] ?? null, bodyRaw: raw, bodyParams: Object.fromEntries(new URLSearchParams(raw)), + // `leg` is recorded here for reference but never used in the filename — + // request input must not participate in filesystem addressing. + leg: url.searchParams.get("leg") ?? null, }; - const file = join(OUT, `req-${String(n).padStart(2, "0")}-${url.searchParams.get("leg") ?? "x"}.json`); + const file = join(OUT, `req-${String(n).padStart(2, "0")}.json`); writeFileSync(file, JSON.stringify(record, null, 2)); console.log(`captured #${n} ${req.method} ${url.pathname}?${url.search.slice(1)} sig=${record.twilioSignature ? "yes" : "no"}`); res.writeHead(200, { "Content-Type": "text/xml" }); diff --git a/scripts/verify-numbers-live.ts b/scripts/verify-numbers-live.ts deleted file mode 100644 index 03a3e65..0000000 --- a/scripts/verify-numbers-live.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Live verification for the numbers facade. Read-only by default: verifies the - * OAuth2 token exchange and (if an account is configured) a search. It will ONLY - * attempt a real order when BW_SITE_ID is present AND VERIFY_ORDER=1 is set, and - * it always releases exactly the number it ordered. Prints raw API shapes (never - * the access token) so we can tighten the schema against reality. - */ -import { TokenManager } from "../src/bw/token.js"; -import { createNumbersClient } from "../src/numbers/client.js"; - -const clientId = process.env.BANDWIDTH_CLIENT_ID ?? process.env.BW_CLIENT_ID; -const clientSecret = process.env.BANDWIDTH_CLIENT_SECRET ?? process.env.BW_CLIENT_SECRET; -const accountId = process.env.BW_ACCOUNT_ID; -const siteId = process.env.BW_SITE_ID; -const areaCode = process.env.VERIFY_AREA_CODE ?? "919"; - -if (!clientId || !clientSecret) { - console.error("MISSING client credentials (BANDWIDTH_CLIENT_ID / _SECRET)"); - process.exit(2); -} - -const apiHost = "https://api.bandwidth.com"; - -async function main() { - // 1) Token exchange — proves the OAuth2 rewire against real Bandwidth. - console.log("== 1. OAuth2 token exchange =="); - const tokens = new TokenManager({ clientId, clientSecret, apiHost }); - try { - const tok = await tokens.getToken(); - console.log(` OK token acquired (len=${tok.length}, value not printed)`); - } catch (e) { - console.log(` FAIL ${(e as Error).message}`); - return; - } - - // 2) Search — read-only. Learn the real response shape. - console.log("== 2. Search (read-only) =="); - if (!accountId) { - console.log(" SKIP BW_ACCOUNT_ID not set — cannot address account endpoints"); - } else { - const client = createNumbersClient({ accountId, clientId, clientSecret, apiHost }); - try { - const res = await client.searchAvailable({ areaCode, quantity: 3 }); - console.log(` OK ${res.resultCount} numbers; first=${res.numbers[0]?.fullNumber ?? "(none)"}`); - } catch (e) { - console.log(` FAIL ${(e as Error).message}`); - } - } - - // 3) Order + release — ONLY when explicitly enabled and a site exists. - console.log("== 3. Order + release =="); - if (!siteId) { - console.log(" BLOCKED BW_SITE_ID not set — a Bandwidth order requires a site/sub-account."); - return; - } - if (process.env.VERIFY_ORDER !== "1") { - console.log(" SKIP set VERIFY_ORDER=1 to place a real (billable) order + immediate release."); - return; - } - if (!accountId) { - console.log(" BLOCKED BW_ACCOUNT_ID not set."); - return; - } - - const client = createNumbersClient({ accountId, clientId, clientSecret, apiHost }); - const search = await client.searchAvailable({ areaCode, quantity: 1 }); - const number = search.numbers[0]?.fullNumber; - if (!number) { - console.log(` ABORT no available number in area code ${areaCode}`); - return; - } - console.log(` ordering ${number} into site ${siteId} ...`); - // Assume the POST provisions the number; we always release this exact N below, - // even if response parsing throws — so nothing is ever orphaned. - try { - // EXPERIMENTAL: the v2 JSON order body below is rejected live - // ("Invalid data type for field 'existingTelephoneNumberOrderType'"). Stripping - // to bare 10-digit did not resolve it — the real order schema still needs to be - // captured from the `band` CLI. Left here as the reproduction for that follow-up. - const bare = number.replace(/^\+1/, ""); - const order = await client.createOrder({ - name: "adapter live verification", - siteId, - existingTelephoneNumberOrderType: { telephoneNumberList: [bare] }, - }); - console.log(" RAW createOrder response:", JSON.stringify(order)); - if (order.id) { - const polled = await client.getOrder(order.id); - console.log(" RAW getOrder response:", JSON.stringify(polled)); - } - } catch (e) { - console.log(` createOrder/getOrder threw (will still release ${number}): ${(e as Error).message}`); - } finally { - try { - const dc = await client.disconnect({ phoneNumbers: [number], customerOrderId: "adapter-verify" }); - console.log(" RAW disconnect response:", JSON.stringify(dc)); - console.log(` RELEASED ${number}`); - } catch (e) { - console.log(` RELEASE FAILED for ${number} — clean up via 'band number release': ${(e as Error).message}`); - } - } -} - -main().catch((e) => { - console.error("unexpected:", e); - process.exit(1); -}); diff --git a/src/bw/client.ts b/src/bw/client.ts index ce6d1b0..1972b05 100644 --- a/src/bw/client.ts +++ b/src/bw/client.ts @@ -1,6 +1,27 @@ import { Readable } from "node:stream"; import { TokenManager } from "./token.js"; +/** + * Validate an identifier before interpolating it into a Bandwidth API URL path. + * BW call/recording ids are alphanumerics with hyphens/underscores (e.g. + * `c-`, `r-`). Anything else — dots (incl. `.`/`..` path segments, + * which encodeURIComponent does NOT neutralize), slashes, or reserved chars — + * could reshape the authenticated request path on the BW host, so reject it + * rather than sanitize (sanitizing would silently target a different resource). + * encodeURIComponent is applied as belt-and-suspenders; it is a no-op for a + * valid id. + */ +export function isSafeBwId(id: unknown): id is string { + return typeof id === "string" && id.length > 0 && id.length <= 256 && /^[A-Za-z0-9_-]+$/.test(id); +} + +function safeId(id: string): string { + if (!isSafeBwId(id)) { + throw new Error(`Invalid Bandwidth identifier: ${JSON.stringify(id)}`); + } + return encodeURIComponent(id); +} + export interface CreateCallOpts { to: string; from: string; @@ -99,7 +120,7 @@ export function createBwClient(cfg: { return { callId: json.callId }; }, async modifyCall(callId, opts) { - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${callId}`, { + const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${safeId(callId)}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: await authHeader() }, body: JSON.stringify(opts), @@ -108,7 +129,7 @@ export function createBwClient(cfg: { throw new Error(`Bandwidth modifyCall failed: ${res.status} ${await res.text()}`); }, async getCall(callId) { - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${callId}`, { + const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${safeId(callId)}`, { headers: { Accept: "application/json", Authorization: await authHeader() }, }); if (!res.ok) @@ -118,7 +139,7 @@ export function createBwClient(cfg: { return Array.isArray(json) ? json[0] : json; }, async listRecordings(callId) { - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${callId}/recordings`, { + const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${safeId(callId)}/recordings`, { headers: { Accept: "application/json", Authorization: await authHeader() }, }); if (!res.ok) @@ -128,7 +149,7 @@ export function createBwClient(cfg: { }, async getRecording(callId, recordingId) { const res = await fetchImpl( - `${base}/accounts/${cfg.accountId}/calls/${callId}/recordings/${recordingId}`, + `${base}/accounts/${cfg.accountId}/calls/${safeId(callId)}/recordings/${safeId(recordingId)}`, { headers: { Accept: "application/json", Authorization: await authHeader() } }, ); if (!res.ok) @@ -138,7 +159,7 @@ export function createBwClient(cfg: { }, async getRecordingMedia(callId, recordingId) { const res = await fetchImpl( - `${base}/accounts/${cfg.accountId}/calls/${callId}/recordings/${recordingId}/media`, + `${base}/accounts/${cfg.accountId}/calls/${safeId(callId)}/recordings/${safeId(recordingId)}/media`, { headers: { Authorization: await authHeader() } }, ); if (!res.ok || !res.body) @@ -150,7 +171,7 @@ export function createBwClient(cfg: { }; }, async updateRecording(callId, state) { - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${callId}/recording`, { + const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/calls/${safeId(callId)}/recording`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: await authHeader() }, body: JSON.stringify({ state }), diff --git a/src/numbers/client.ts b/src/numbers/client.ts deleted file mode 100644 index 0aba304..0000000 --- a/src/numbers/client.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * NumbersClient interface + minimal real implementation for the Bandwidth - * Numbers API. - * - * Mirrors the pattern established in src/bw/client.ts: - * - A plain TypeScript interface that tests can mock easily - * - A createNumbersClient factory that wires in real HTTP calls - * - No live credentials required; the base URL is configurable so tests - * can point at a mock server if desired - * - * API surface covered: - * search → GET /numbers (available-number search) - * order → POST /accounts/{accountId}/orders (number acquisition) - * listTns → GET /accounts/{accountId}/tns (numbers on the account) - * disconnect → POST /accounts/{accountId}/disconnects - * - * Sources: - * https://dev.bandwidth.com/apis/numbers-apis/numbers - * https://dev.bandwidth.com/apis/numbers-apis/number-acquisition - * https://dev.bandwidth.com/docs/numbers/guides/disconnectNumbers - * @bandwidth/node-numbers SDK README - */ - -import type { BwSearchParams, BwOrderRequest, BwOrderResponse } from "./schema.js"; -import { TokenManager } from "../bw/token.js"; - -// ── Available-number search result ─────────────────────────────────────────── - -export interface AvailableNumber { - /** Full E.164 phone number. */ - fullNumber: string; - /** Rate center abbreviation, if available. */ - rateCenter?: string; - /** City name, if available. */ - city?: string; - /** Two-letter state abbreviation, if available. */ - state?: string; - /** Whether the number is within the local calling area. */ - lca?: boolean; -} - -export interface SearchResult { - numbers: AvailableNumber[]; - resultCount: number; -} - -// ── Active telephone number (operate stage) ────────────────────────────────── - -export interface TelephoneNumber { - /** Full E.164 phone number. */ - fullNumber: string; - /** Two-letter state. */ - state?: string; - /** Rate center abbreviation. */ - rateCenter?: string; - /** Current number status. */ - status?: string; -} - -export interface ListTnsResult { - telephoneNumbers: TelephoneNumber[]; - totalCount: number; -} - -// ── Disconnect order ───────────────────────────────────────────────────────── - -export interface DisconnectRequest { - /** Customer-supplied reference ID. */ - customerOrderId?: string; - /** E.164 numbers to release. Maximum 5000 per order. */ - phoneNumbers: string[]; -} - -export interface DisconnectResponse { - id: string; - orderStatus: string; - orderCreateDate?: string; -} - -// ── Interface ──────────────────────────────────────────────────────────────── - -/** - * Minimal interface for the Bandwidth Numbers API. - * Keep this focused on the lifecycle operations: discover, acquire, operate. - * Tests can implement this with a simple object literal — no class needed. - */ -export interface NumbersClient { - /** - * Search for available telephone numbers. - * Maps to: GET /numbers (new Numbers API) - */ - searchAvailable(params: BwSearchParams): Promise; - - /** - * Create a number order (purchase). - * Maps to: POST /accounts/{accountId}/orders - */ - createOrder(req: BwOrderRequest): Promise; - - /** - * Poll an order's current status. - * Maps to: GET /accounts/{accountId}/orders/{orderId} - */ - getOrder(orderId: string): Promise; - - /** - * List telephone numbers currently on the account. - * Maps to: GET /accounts/{accountId}/tns - */ - listTns(opts?: { quantity?: number; page?: number }): Promise; - - /** - * Disconnect (release) telephone numbers. - * Maps to: POST /accounts/{accountId}/disconnects - */ - disconnect(req: DisconnectRequest): Promise; -} - -// ── Factory ────────────────────────────────────────────────────────────────── - -export interface NumbersClientConfig { - /** Bandwidth account ID. */ - accountId: string; - /** OAuth2 client-credentials ID (same platform credential as the Voice API). */ - clientId: string; - /** OAuth2 client-credentials secret. */ - clientSecret: string; - /** - * Base URL for the Numbers API v2 JSON endpoints. - * Defaults to the production Bandwidth Numbers API. - * Override in tests to point at a mock server. - */ - baseUrl?: string; - /** API host serving the OAuth2 token endpoint. Defaults to https://api.bandwidth.com. */ - apiHost?: string; - /** Injectable fetch for tests/staging. */ - fetchImpl?: typeof fetch; -} - -/** - * Create a real NumbersClient backed by the Bandwidth Numbers API. - * - * Auth mirrors createBwClient (src/bw/client.ts): a shared OAuth2 - * client-credentials Bearer token via TokenManager. The same platform token - * is honored across Voice and Numbers — whether it can place orders depends on - * the account credential carrying the Numbers role. - * - * Targets the JSON-native endpoints documented at: - * https://dev.bandwidth.com/apis/numbers-apis/numbers - * https://dev.bandwidth.com/apis/numbers-apis/number-acquisition - */ -export function createNumbersClient(cfg: NumbersClientConfig): NumbersClient { - // Bandwidth's Numbers API lives under api.bandwidth.com (not numbers.bandwidth.com). - const base = cfg.baseUrl ?? "https://api.bandwidth.com/api/v2"; - const fetchImpl = cfg.fetchImpl ?? fetch; - const tokens = new TokenManager({ - clientId: cfg.clientId, - clientSecret: cfg.clientSecret, - apiHost: cfg.apiHost ?? "https://api.bandwidth.com", - fetchImpl, - }); - - async function headers(): Promise> { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${await tokens.getToken()}`, - }; - } - - async function expectOk(res: Response, context: string): Promise { - if (!res.ok) { - throw new Error( - `Bandwidth Numbers API ${context} failed: ${res.status} ${await res.text()}`, - ); - } - return res; - } - - return { - async searchAvailable(params) { - const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v !== undefined) qs.set(k, String(v)); - } - // Verified live (2026-06-19): GET /accounts/{id}/availableNumbers returns - // {"phoneNumbers":["+1919…"],"resultCount":N} as JSON. Without - // enableTNDetail the response is just E.164 strings (no city/state/rateCenter). - const res = await fetchImpl( - `${base}/accounts/${cfg.accountId}/availableNumbers?${qs.toString()}`, - { headers: { ...(await headers()), Accept: "application/json" } }, - ); - await expectOk(res, "searchAvailable"); - const json = (await res.json()) as { - phoneNumbers?: string[]; - TelephoneNumberList?: string[]; - resultCount?: number; - ResultCount?: number; - }; - // Prefer phoneNumbers (E.164); TelephoneNumberList carries the same set - // without the leading "+". - const raw = json.phoneNumbers ?? json.TelephoneNumberList ?? []; - const numbers: AvailableNumber[] = raw.map((fullNumber) => ({ - fullNumber: fullNumber.startsWith("+") ? fullNumber : `+${fullNumber}`, - })); - return { numbers, resultCount: json.resultCount ?? json.ResultCount ?? numbers.length }; - }, - - async createOrder(req) { - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/orders`, { - method: "POST", - headers: await headers(), - body: JSON.stringify(req), - }); - await expectOk(res, "createOrder"); - return (await res.json()) as BwOrderResponse; - }, - - async getOrder(orderId) { - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/orders/${orderId}`, { - headers: await headers(), - }); - await expectOk(res, "getOrder"); - return (await res.json()) as BwOrderResponse; - }, - - async listTns(opts = {}) { - const qs = new URLSearchParams(); - if (opts.quantity !== undefined) qs.set("quantity", String(opts.quantity)); - if (opts.page !== undefined) qs.set("page", String(opts.page)); - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/tns?${qs.toString()}`, { - headers: await headers(), - }); - await expectOk(res, "listTns"); - const json = (await res.json()) as { - telephoneNumbers?: TelephoneNumber[]; - totalCount?: number; - }; - return { - telephoneNumbers: json.telephoneNumbers ?? [], - totalCount: json.totalCount ?? 0, - }; - }, - - async disconnect(req) { - const body = { - customerOrderId: req.customerOrderId, - disconnectOrderType: { - disconnectMode: "NORMAL", - phoneNumbers: req.phoneNumbers, - }, - }; - const res = await fetchImpl(`${base}/accounts/${cfg.accountId}/disconnects`, { - method: "POST", - headers: await headers(), - body: JSON.stringify(body), - }); - await expectOk(res, "disconnect"); - return (await res.json()) as DisconnectResponse; - }, - }; -} diff --git a/src/numbers/schema.ts b/src/numbers/schema.ts deleted file mode 100644 index dfa1406..0000000 --- a/src/numbers/schema.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Canonical number-lifecycle schemas. - * - * Covers four stages: - * discover – search for available numbers (Twilio AvailablePhoneNumbers) - * acquire – purchase/order a number (Twilio IncomingPhoneNumbers POST) - * activate – configure the number (webhook URLs, capabilities) - * operate – list and disconnect numbers on the account - * - * Twilio field names are taken from: - * https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource - * https://www.twilio.com/docs/phone-numbers/api/incomingphonenumber-resource - * - * Bandwidth field names are taken from: - * https://dev.bandwidth.com/apis/numbers-apis/numbers (GET /numbers) - * https://dev.bandwidth.com/docs/numbers/guides/searchingForNumbers - * https://dev.bandwidth.com/apis/numbers-apis/number-acquisition (POST /accounts/{id}/orders) - * @bandwidth/node-numbers SDK README - */ - -import { z } from "zod"; - -// ── Lifecycle stage ────────────────────────────────────────────────────────── - -/** - * The four stages of a number's life inside a migration: - * - discover → query available inventory - * - acquire → order / purchase - * - activate → attach webhooks / capabilities - * - operate → list active numbers, disconnect - */ -export const NumberLifecycleStageSchema = z.enum(["discover", "acquire", "activate", "operate"]); -export type NumberLifecycleStage = z.infer; - -// ── HTTP method enum (reused for Twilio webhook method fields) ─────────────── - -const HttpMethodSchema = z.enum(["GET", "POST"]); - -// ── Twilio: AvailablePhoneNumbers search criteria ──────────────────────────── -// -// Source: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource -// -// All fields are optional; the caller must supply at least something useful, -// but the schema itself does not enforce combinations (that's the search API's job). - -export const TwilioSearchParamsSchema = z.object({ - /** 3-digit area code. US/Canada only. */ - areaCode: z.string().optional(), - - /** - * Pattern match: 2–16 chars using digits, letters, and meta-characters - * (*, %, +, $). Requires at least two non-meta characters. - */ - contains: z.string().min(2).max(16).optional(), - - /** State or province abbreviation (e.g. "CA", "NC"). US/Canada only. */ - inRegion: z.string().optional(), - - /** Postal/ZIP code. US/Canada only. */ - inPostalCode: z.string().optional(), - - /** City or locality. */ - inLocality: z.string().optional(), - - /** Rate center abbreviation. Must be paired with inRegion. US/Canada only. */ - inRateCenter: z.string().optional(), - - /** Local Access and Transport Area code. US/Canada only. */ - inLata: z.string().optional(), - - /** Search near this E.164 number. US/Canada only. */ - nearNumber: z.string().optional(), - - /** "lat,long" pair. US/Canada only. */ - nearLatLong: z.string().optional(), - - /** Search radius in miles (0–500, default 25). US/Canada only. */ - distance: z.number().int().min(0).max(500).optional(), - - /** ISO-3166-1 alpha-2 country code (e.g. "US", "CA"). Defaults to "US". */ - country: z.string().length(2).optional(), - - voiceEnabled: z.boolean().optional(), - smsEnabled: z.boolean().optional(), - mmsEnabled: z.boolean().optional(), - faxEnabled: z.boolean().optional(), - - /** Exclude numbers that require any type of address registration. */ - excludeAllAddressRequired: z.boolean().optional(), - /** Exclude numbers that require a local address. */ - excludeLocalAddressRequired: z.boolean().optional(), - /** Exclude numbers that require a foreign address. */ - excludeForeignAddressRequired: z.boolean().optional(), - - /** Whether to include beta (newly added) numbers. Default: true. */ - beta: z.boolean().optional(), - - /** Max results to return (1–1000, default 50). */ - pageSize: z.number().int().min(1).max(1000).optional(), -}); - -export type TwilioSearchParams = z.infer; - -// ── Twilio: IncomingPhoneNumbers purchase params ───────────────────────────── -// -// Source: https://www.twilio.com/docs/phone-numbers/api/incomingphonenumber-resource (POST) -// -// Either phoneNumber (E.164) or areaCode must be supplied. - -export const TwilioPurchaseParamsSchema = z - .object({ - /** E.164 phone number to purchase. Required when areaCode is absent. */ - phoneNumber: z.string().optional(), - - /** Area code for automatic number selection. US/Canada only. Required when phoneNumber is absent. */ - areaCode: z.string().optional(), - - /** Human-readable label (up to 64 chars). */ - friendlyName: z.string().max(64).optional(), - - /** Webhook URL called when the number receives a voice call. */ - voiceUrl: z.string().url().optional(), - voiceMethod: HttpMethodSchema.optional(), - voiceFallbackUrl: z.string().url().optional(), - voiceFallbackMethod: HttpMethodSchema.optional(), - - /** Whether to look up caller name from CNAM. */ - voiceCallerIdLookup: z.boolean().optional(), - - /** Webhook URL called for incoming SMS. */ - smsUrl: z.string().url().optional(), - smsMethod: HttpMethodSchema.optional(), - smsFallbackUrl: z.string().url().optional(), - smsFallbackMethod: HttpMethodSchema.optional(), - - /** Call-status webhook. */ - statusCallback: z.string().url().optional(), - statusCallbackMethod: HttpMethodSchema.optional(), - - /** SID of a Twilio Application that handles calls on this number. */ - voiceApplicationSid: z.string().optional(), - /** SID of a Twilio Application that handles SMS on this number. */ - smsApplicationSid: z.string().optional(), - - /** SID of a Twilio Trunk to route calls through. */ - trunkSid: z.string().optional(), - - /** SID of an Address resource for regulatory compliance. */ - addressSid: z.string().optional(), - - /** SID of a Bundle (regulatory package). */ - bundleSid: z.string().optional(), - - /** Whether emergency calling is active ("Active" | "Inactive"). */ - emergencyStatus: z.enum(["Active", "Inactive"]).optional(), - emergencyAddressSid: z.string().optional(), - }) - .refine((v) => v.phoneNumber !== undefined || v.areaCode !== undefined, { - message: "Either phoneNumber or areaCode must be provided", - }); - -export type TwilioPurchaseParams = z.infer; - -// ── Bandwidth: Available-numbers search params ─────────────────────────────── -// -// Sources: -// GET /numbers → https://dev.bandwidth.com/apis/numbers-apis/numbers -// GET /api/v2/accounts/{accountId}/availableNumbers -// → https://dev.bandwidth.com/docs/universal-platform/order-numbers -// @bandwidth/node-numbers AvailableNumbers.listAsync(options) - -export const BwSearchParamsSchema = z.object({ - /** 3-digit area code. */ - areaCode: z.string().optional(), - - /** Two-letter state abbreviation (e.g. "NC", "CA"). */ - state: z.string().optional(), - - /** City name. Requires state when used on the legacy v2 endpoint. */ - city: z.string().optional(), - - /** 5-digit (or 9-digit) ZIP code. */ - zip: z.string().optional(), - - /** LATA code. */ - lata: z.string().optional(), - - /** Rate center abbreviation. */ - rateCenter: z.string().optional(), - - /** 6-digit NPA-NXX prefix. */ - npaNxx: z.string().optional(), - - /** 7-digit NPA-NXXX prefix. */ - npaNxxx: z.string().optional(), - - /** 4–7 character local vanity string (alphanumeric). */ - localVanity: z.string().min(4).max(7).optional(), - - /** Maximum results to return. */ - quantity: z.number().int().min(1).optional(), - - /** Include rate center / city / state detail in each result. */ - enableTNDetail: z.boolean().optional(), - - /** - * ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "GBR"). - * Defaults to "USA,CAN" for NANP searches. - */ - countryCodeA3: z.string().length(3).optional(), - - /** - * Number type filter. - * Docs: https://dev.bandwidth.com/apis/numbers-apis/numbers - */ - phoneNumberType: z - .enum(["GEOGRAPHIC", "NATIONAL", "MOBILE", "TOLL_FREE", "SHARED_COST"]) - .optional(), -}); - -export type BwSearchParams = z.infer; - -// ── Bandwidth: Order request ───────────────────────────────────────────────── -// -// Sources: -// POST /accounts/{accountId}/orders → https://dev.bandwidth.com/apis/numbers-apis/number-acquisition -// @bandwidth/node-numbers Order.create(options, callback) -// -// The BW order API supports many "search-and-order" types; we surface the -// two most useful here: area-code search and explicit telephone number. - -const AreaCodeSearchAndOrderTypeSchema = z.object({ - /** 3-digit area code to search and order from. */ - areaCode: z.string(), - /** Number of TNs to order from this area code. */ - quantity: z.number().int().min(1), -}); - -const ExistingTelephoneNumberOrderTypeSchema = z.object({ - /** E.164 numbers to order (must already appear in BW available inventory). */ - telephoneNumberList: z.array(z.string()).min(1), -}); - -export const BwOrderRequestSchema = z.object({ - /** Human-readable name for this order. */ - name: z.string(), - - /** Bandwidth site (sub-account) ID where numbers will be provisioned. */ - siteId: z.string(), - - /** Bandwidth SIP peer ID under the site. Optional but recommended. */ - peerId: z.string().optional(), - - /** Total quantity of numbers requested. */ - quantity: z.number().int().min(1).optional(), - - /** Customer-supplied reference ID (idempotency key). */ - customerOrderId: z.string().optional(), - - /** Search and order by area code. Mutually exclusive with existingTelephoneNumberOrderType. */ - areaCodeSearchAndOrderType: AreaCodeSearchAndOrderTypeSchema.optional(), - - /** Order specific known numbers. Mutually exclusive with areaCodeSearchAndOrderType. */ - existingTelephoneNumberOrderType: ExistingTelephoneNumberOrderTypeSchema.optional(), -}).refine( - (v) => !(v.areaCodeSearchAndOrderType && v.existingTelephoneNumberOrderType), - { message: "areaCodeSearchAndOrderType and existingTelephoneNumberOrderType are mutually exclusive" }, -); - -export type BwOrderRequest = z.infer; - -// ── Bandwidth: Order response ──────────────────────────────────────────────── - -// UNVERIFIED — EXPERIMENTAL. Auth is OAuth2 Bearer (shared platform token, -// verified live), but live testing (2026-06-19) never got as far as a response: -// the v2 JSON order endpoint rejects the *request* body -// ("Invalid data type for field 'existingTelephoneNumberOrderType'"), so both -// BwOrderRequest and this response envelope are still guesses. Capture `band`'s -// real request/response to pin the schema before relying on ordering. Kept -// lenient (.passthrough, optional fields) so nothing is silently dropped. -export const BwOrderResponseSchema = z - .object({ - /** Bandwidth-assigned order UUID. */ - id: z.string().optional(), - - /** Current status of the order. */ - orderStatus: z.enum(["RECEIVED", "PROCESSING", "COMPLETE", "FAILED", "PARTIAL"]), - - /** ISO-8601 timestamp of order creation. */ - orderCreateDate: z.string().optional(), - - /** Numbers provisioned once the order reaches COMPLETE state. */ - telephoneNumbers: z.array(z.string()).optional(), - }) - .passthrough(); - -export type BwOrderResponse = z.infer; diff --git a/src/numbers/translate.ts b/src/numbers/translate.ts deleted file mode 100644 index 44de6fe..0000000 --- a/src/numbers/translate.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Translators for the two highest-value number-lifecycle operations: - * - * 1. translateSearchParams – Twilio AvailablePhoneNumbers search criteria - * → Bandwidth available-number search params - * - * 2. translatePurchaseToOrder – Twilio IncomingPhoneNumbers POST (purchase) - * → Bandwidth number-order shape - * - * Gaps are surfaced as structured GapItem objects (not thrown as errors) so - * callers can log or display them. This mirrors the finding/warning pattern - * used in the voice-call translator (src/translator/translate.ts). - */ - -import type { - TwilioSearchParams, - TwilioPurchaseParams, - BwSearchParams, - BwOrderRequest, -} from "./schema.js"; - -// ── Gap reporting ───────────────────────────────────────────────────────────── - -/** - * Describes a Twilio field that has no direct Bandwidth equivalent. - * The adapter records it rather than silently dropping it. - */ -export interface GapItem { - /** The Twilio parameter name that could not be mapped. */ - twilioParam: string; - /** Brief explanation of why it cannot be translated. */ - reason: string; -} - -// ── Search translation ──────────────────────────────────────────────────────── - -export interface SearchTranslation { - bwParams: BwSearchParams; - gaps: GapItem[]; -} - -/** - * ISO 3166-1 alpha-2 → alpha-3 for the countries Bandwidth's Numbers API - * explicitly supports. Extend as needed. - * - * Bandwidth docs: countryCodeA3 defaults to "USA,CAN" for NANP searches. - */ -// Only the handful of country codes that Bandwidth's Numbers API explicitly -// supports beyond the NANP default (USA, CAN). Source: dev.bandwidth.com -// countryCodeA3 param docs. International support is limited and varies by -// account type; treat anything outside this table as a gap. -const SUPPORTED_ALPHA2_TO_ALPHA3: Record = { - US: "USA", - CA: "CAN", -}; - -/** - * Translate Twilio AvailablePhoneNumbers search criteria into Bandwidth - * available-number search params. - * - * Field mapping sources: - * Twilio: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource - * Bandwidth: https://dev.bandwidth.com/apis/numbers-apis/numbers - * https://dev.bandwidth.com/docs/numbers/guides/searchingForNumbers - */ -export function translateSearchParams(twilio: TwilioSearchParams): SearchTranslation { - const bwParams: BwSearchParams = {}; - const gaps: GapItem[] = []; - - // Direct / near-direct mappings - if (twilio.areaCode !== undefined) bwParams.areaCode = twilio.areaCode; - if (twilio.inRegion !== undefined) bwParams.state = twilio.inRegion; - if (twilio.inPostalCode !== undefined) bwParams.zip = twilio.inPostalCode; - if (twilio.inLocality !== undefined) bwParams.city = twilio.inLocality; - if (twilio.inRateCenter !== undefined) bwParams.rateCenter = twilio.inRateCenter; - if (twilio.inLata !== undefined) bwParams.lata = twilio.inLata; - - // pageSize → quantity - if (twilio.pageSize !== undefined) bwParams.quantity = twilio.pageSize; - - // country: alpha-2 → alpha-3 for known values; gap otherwise - if (twilio.country !== undefined) { - const alpha3 = SUPPORTED_ALPHA2_TO_ALPHA3[twilio.country.toUpperCase()]; - if (alpha3) { - bwParams.countryCodeA3 = alpha3; - } else { - gaps.push({ - twilioParam: "country", - reason: `Country "${twilio.country}" has no known Bandwidth alpha-3 mapping; set countryCodeA3 manually.`, - }); - } - } - - // ── Fields with no Bandwidth equivalent ────────────────────────────────── - - // Twilio pattern-match (wildcard) search has no BW equivalent. - // BW offers localVanity but it operates on last 4–7 digits, not a full - // wildcard pattern across the whole number. - if (twilio.contains !== undefined) { - gaps.push({ - twilioParam: "contains", - reason: - "Twilio pattern matching (contains) has no direct Bandwidth equivalent. " + - "Use localVanity in BW search params for digit-suffix matching.", - }); - } - - // Geo-proximity search is Twilio-only. - if (twilio.nearNumber !== undefined) { - gaps.push({ - twilioParam: "nearNumber", - reason: - "Bandwidth does not support geo-proximity search by phone number. " + - "Use state/city/rateCenter params to approximate a geographic area.", - }); - } - if (twilio.nearLatLong !== undefined) { - gaps.push({ - twilioParam: "nearLatLong", - reason: - "Bandwidth does not support geo-proximity search by lat/long coordinates. " + - "Use state/city/rateCenter params to approximate a geographic area.", - }); - } - // distance is only meaningful alongside nearNumber/nearLatLong, so no - // separate gap entry — callers will notice those are already gapped. - - // Capability filters: Bandwidth does not expose per-number capability - // filters at search time. All NANP numbers support voice; SMS/MMS capability - // is determined by account configuration, not the number itself. - if (twilio.voiceEnabled !== undefined) { - gaps.push({ - twilioParam: "voiceEnabled", - reason: - "Bandwidth does not expose per-number voice capability filtering at search time.", - }); - } - if (twilio.smsEnabled !== undefined) { - gaps.push({ - twilioParam: "smsEnabled", - reason: - "Bandwidth does not expose per-number SMS capability filtering at search time.", - }); - } - if (twilio.mmsEnabled !== undefined) { - gaps.push({ - twilioParam: "mmsEnabled", - reason: - "Bandwidth does not expose per-number MMS capability filtering at search time.", - }); - } - if (twilio.faxEnabled !== undefined) { - gaps.push({ - twilioParam: "faxEnabled", - reason: - "Bandwidth does not support fax-capable number filtering. Fax is not a supported service.", - }); - } - - // Address-required exclusion filters: Bandwidth handles regulatory - // requirements differently (via BundleRequirements at ordering time). - if (twilio.excludeAllAddressRequired !== undefined) { - gaps.push({ - twilioParam: "excludeAllAddressRequired", - reason: - "Bandwidth handles address requirements at order time via RequirementsPackageId, " + - "not at search time.", - }); - } - if (twilio.excludeLocalAddressRequired !== undefined) { - gaps.push({ - twilioParam: "excludeLocalAddressRequired", - reason: - "Bandwidth handles address requirements at order time via RequirementsPackageId, " + - "not at search time.", - }); - } - if (twilio.excludeForeignAddressRequired !== undefined) { - gaps.push({ - twilioParam: "excludeForeignAddressRequired", - reason: - "Bandwidth handles address requirements at order time via RequirementsPackageId, " + - "not at search time.", - }); - } - - // beta: Bandwidth does not have a concept of beta numbers in its search API. - // Intentionally silently ignored (it's a Twilio platform-administration detail). - - return { bwParams, gaps }; -} - -// ── Purchase translation ────────────────────────────────────────────────────── - -export interface PurchaseContext { - /** Bandwidth site (sub-account) ID. Required for order creation. */ - siteId: string; - /** Bandwidth SIP peer ID (optional but recommended). */ - peerId?: string; - /** Human-readable order name. Defaults to "Migrated from Twilio". */ - orderName?: string; -} - -export interface PurchaseTranslation { - bwOrder: BwOrderRequest; - gaps: GapItem[]; -} - -/** - * Translate a Twilio IncomingPhoneNumbers POST (purchase) into a Bandwidth - * number-order shape. - * - * The two BW search-and-order types used here: - * - existingTelephoneNumberOrderType: for an explicit E.164 phoneNumber - * - areaCodeSearchAndOrderType: for an area-code-only purchase - * - * Sources: - * Twilio: https://www.twilio.com/docs/phone-numbers/api/incomingphonenumber-resource - * Bandwidth: https://dev.bandwidth.com/apis/numbers-apis/number-acquisition - * @bandwidth/node-numbers Order.create() - */ -export function translatePurchaseToOrder( - twilio: TwilioPurchaseParams, - ctx: PurchaseContext, -): PurchaseTranslation { - const gaps: GapItem[] = []; - const orderName = ctx.orderName ?? "Migrated from Twilio"; - - // No top-level `quantity`: the Bandwidth v2 JSON order API rejects it - // ("Invalid data type for field 'quantity'", verified live 2026-06-19). - // Quantity is implicit in the TN list, or nested inside areaCodeSearchAndOrderType. - const bwOrder: BwOrderRequest = { - name: orderName, - siteId: ctx.siteId, - ...(ctx.peerId ? { peerId: ctx.peerId } : {}), - }; - - // Route to the correct BW order type based on whether we have a specific - // number or just an area code. - if (twilio.phoneNumber !== undefined) { - bwOrder.existingTelephoneNumberOrderType = { - telephoneNumberList: [twilio.phoneNumber], - }; - } else if (twilio.areaCode !== undefined) { - bwOrder.areaCodeSearchAndOrderType = { - areaCode: twilio.areaCode, - quantity: 1, - }; - } - - // ── Fields with no Bandwidth order equivalent ───────────────────────────── - // - // Twilio's IncomingPhoneNumbers purchase bundles two concerns: acquiring the - // number AND configuring its webhooks/capabilities in one call. Bandwidth - // separates acquisition (Order API) from configuration (Numbers/Phone Numbers - // API). These webhook fields must be applied post-acquisition. - - if (twilio.friendlyName !== undefined) { - gaps.push({ - twilioParam: "friendlyName", - reason: - "Bandwidth orders do not carry a friendly name. Apply via the Phone Numbers API after provisioning.", - }); - } - if (twilio.voiceUrl !== undefined) { - gaps.push({ - twilioParam: "voiceUrl", - reason: - "Bandwidth separates number acquisition from webhook configuration. " + - "Set the application/webhook after the order completes using the Bandwidth Voice API.", - }); - } - if (twilio.voiceMethod !== undefined) { - gaps.push({ - twilioParam: "voiceMethod", - reason: "Webhook HTTP method must be configured post-acquisition on the Bandwidth side.", - }); - } - if (twilio.voiceFallbackUrl !== undefined) { - gaps.push({ - twilioParam: "voiceFallbackUrl", - reason: "Fallback webhooks must be configured post-acquisition on the Bandwidth side.", - }); - } - if (twilio.voiceFallbackMethod !== undefined) { - gaps.push({ - twilioParam: "voiceFallbackMethod", - reason: "Fallback webhook HTTP method must be configured post-acquisition.", - }); - } - if (twilio.voiceCallerIdLookup !== undefined) { - gaps.push({ - twilioParam: "voiceCallerIdLookup", - reason: - "CNAM lookup is controlled per-call in the Bandwidth Voice API, not at number configuration.", - }); - } - if (twilio.smsUrl !== undefined) { - gaps.push({ - twilioParam: "smsUrl", - reason: - "SMS webhook must be configured via the Bandwidth Messaging API after the number is provisioned.", - }); - } - if (twilio.smsMethod !== undefined) { - gaps.push({ - twilioParam: "smsMethod", - reason: "SMS webhook HTTP method must be configured post-acquisition.", - }); - } - if (twilio.smsFallbackUrl !== undefined) { - gaps.push({ - twilioParam: "smsFallbackUrl", - reason: "SMS fallback webhook must be configured post-acquisition.", - }); - } - if (twilio.smsFallbackMethod !== undefined) { - gaps.push({ - twilioParam: "smsFallbackMethod", - reason: "SMS fallback webhook HTTP method must be configured post-acquisition.", - }); - } - if (twilio.statusCallback !== undefined) { - gaps.push({ - twilioParam: "statusCallback", - reason: - "Status callbacks are configured per-call or per-application in Bandwidth, " + - "not at number provisioning time.", - }); - } - if (twilio.statusCallbackMethod !== undefined) { - gaps.push({ - twilioParam: "statusCallbackMethod", - reason: "Status callback HTTP method must be configured post-acquisition.", - }); - } - for (const param of ["voiceApplicationSid", "smsApplicationSid"] as const) { - if (twilio[param] !== undefined) { - gaps.push({ - twilioParam: param, - reason: - "Twilio Application SIDs have no equivalent in Bandwidth. " + - "Configure webhooks directly on the Bandwidth application post-acquisition.", - }); - } - } - if (twilio.trunkSid !== undefined) { - gaps.push({ - twilioParam: "trunkSid", - reason: - "Twilio Elastic SIP Trunking SIDs have no direct equivalent in Bandwidth number orders.", - }); - } - if (twilio.addressSid !== undefined) { - gaps.push({ - twilioParam: "addressSid", - reason: - "Regulatory address requirements in Bandwidth are handled via RequirementsPackageId " + - "at order time or separately through the Compliance API.", - }); - } - if (twilio.bundleSid !== undefined) { - gaps.push({ - twilioParam: "bundleSid", - reason: - "Twilio Bundle SIDs map loosely to Bandwidth RequirementsPackageId. " + - "Set requirementsPackageId on the order if regulatory bundles are needed.", - }); - } - for (const param of ["emergencyStatus", "emergencyAddressSid"] as const) { - if (twilio[param] !== undefined) { - gaps.push({ - twilioParam: param, - reason: - "Emergency services configuration is handled via the Bandwidth 911 API, " + - "not the number order.", - }); - } - } - - return { bwOrder, gaps }; -} diff --git a/src/server/app.ts b/src/server/app.ts index 089ed24..b80a6e3 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -10,20 +10,18 @@ import { postToCustomer, } from "../twilio/egress.js"; import { EgressBlockedError } from "../twilio/egress-guard.js"; -import { toCallSid, toRecordingSid, toIncomingPhoneNumberSid } from "../twilio/call-sid.js"; +import { toCallSid, toRecordingSid } from "../twilio/call-sid.js"; import { createdCallResource, bwStateToTwilioStatus, twilioErrors } from "../twilio/call-resource.js"; +import { adapterErrors } from "./errors.js"; import { recordingList, recordingResource, iso8601DurationToSeconds, bwRecordingStatus, } from "../twilio/recording-resource.js"; -import { availablePhoneNumbersList, incomingPhoneNumberResource, numberErrors } from "../twilio/number-resource.js"; import { CallStore, type CallRecord } from "./call-store.js"; -import type { BwClient } from "../bw/client.js"; -import type { NumbersClient } from "../numbers/client.js"; -import { translateSearchParams, translatePurchaseToOrder } from "../numbers/translate.js"; -import { TwilioSearchParamsSchema, TwilioPurchaseParamsSchema } from "../numbers/schema.js"; +import { isSafeBwId, type BwClient } from "../bw/client.js"; +import { checkReadiness } from "./readiness.js"; import { safeEqual } from "./safe-equal.js"; export interface AdapterConfig { @@ -31,15 +29,6 @@ export interface AdapterConfig { authToken: string; publicBaseUrl: string; voiceUrl: string; - /** - * Bandwidth number-ordering context. Required to serve the IncomingPhoneNumbers - * purchase endpoint (Bandwidth orders need a site, optionally a SIP peer). - * Absent → the purchase route returns a "not configured" error. - */ - numbers?: { - siteId: string; - peerId?: string; - }; /** Allow outbound fetches to private/loopback ranges (local dev). Default false. */ allowPrivateEgress?: boolean; /** Opt-in egress allowlist of expected customer hosts. Empty/undefined → range denylist applies. */ @@ -52,8 +41,6 @@ export interface AdapterConfig { export interface AdapterDeps { fetchImpl: typeof fetch; bwClient: BwClient; - /** Bandwidth Numbers API client. Absent → the number-lifecycle routes 400. */ - numbersClient?: NumbersClient; } interface BwEvent { @@ -84,12 +71,31 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta const app = Fastify({ logger: process.env.ADAPTER_LOG === "1" }); app.register(formbody); + app.setErrorHandler((err, _req, reply) => { + // Full detail (incl. upstream bodies) goes to logs only, never the response. + app.log.error({ err }, "unhandled adapter error"); + const sc = (err as { statusCode?: number }).statusCode; + // Preserve a client-error status Fastify already classified (e.g. malformed + // body → 400); everything else is a neutral internal 500. We do NOT relabel + // arbitrary throws as "Bandwidth" failures or forward err.message. + if (sc && sc >= 400 && sc < 500) { + return reply.code(sc).send({ ...adapterErrors.internal(), status: sc, code: 90003, message: "Invalid request" }); + } + return reply.code(500).send(adapterErrors.internal()); + }); + const expectedWebhookAuth = "Basic " + Buffer.from(`${config.webhookUser}:${config.webhookPassword}`).toString("base64"); // Bandwidth authenticates its inbound webhooks with Basic auth (CallbackCreds // on the Voice app + username/password on the BXML callback verbs we emit). + // Gate on the ROUTER-matched route (req.routeOptions.url), not the raw + // req.url: find-my-way percent-decodes the target before matching, so a raw + // path like /%62%77/continue routes to /bw/continue while never starting with + // "/bw/". Keying on the matched route closes that decode divergence and can't + // be bypassed by encoding. onRequest runs before body parsing, so unauthorized + // requests are rejected before any payload is read. app.addHook("onRequest", async (req, reply) => { - if (!req.url.startsWith("/bw/")) return; + if (!(req.routeOptions?.url ?? "").startsWith("/bw/")) return; if (!safeEqual(req.headers.authorization ?? "", expectedWebhookAuth)) { return reply.code(401).header("WWW-Authenticate", "Basic").send({ error: "unauthorized" }); } @@ -174,6 +180,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta app.post("/bw/initiate", async (req, reply) => { const event = req.body as BwEvent; + if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(adapterErrors.invalidParam("callId")); const query = req.query as { voiceUrl?: string }; const existing = store.get(event.callId); const voiceUrl = query.voiceUrl ?? existing?.voiceUrl ?? config.voiceUrl; @@ -191,8 +198,9 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta app.post("/bw/continue", async (req, reply) => { const event = req.body as BwEvent; + if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(adapterErrors.invalidParam("callId")); const query = req.query as { next?: string }; - if (!query.next) return reply.code(400).send({ error: "missing next" }); + if (!query.next) return reply.code(400).send(adapterErrors.missingParam("next")); const record = store.get(event.callId) ?? ({ @@ -212,6 +220,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta app.post("/bw/disconnect", async (req, reply) => { const event = req.body as BwEvent; + if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(adapterErrors.invalidParam("callId")); const record = store.get(event.callId); if (record) { // Bandwidth bills (and Twilio reports) from answer to end; fall back to 0 @@ -248,6 +257,9 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta // recordingStatusCallback payload and forward it to the customer's callback URL. app.post("/bw/recording-status", async (req, reply) => { const event = req.body as BwRecordingEvent; + if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(adapterErrors.invalidParam("callId")); + if (event.recordingId !== undefined && !isSafeBwId(event.recordingId)) + return reply.code(400).send(adapterErrors.invalidParam("recordingId")); const cb = (req.query as { cb?: string }).cb; const record = store.get(event.callId); if (cb && record && event.recordingId) { @@ -432,165 +444,15 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta return reply.code(400).send(twilioErrors.missingUrl400); }); - // ── Number lifecycle: search ───────────────────────────────────────────── - // GET /2010-04-01/Accounts/:sid/AvailablePhoneNumbers/:Country/Local.json - // Read-only: queries Bandwidth inventory through the Twilio search facade. - app.get( - "/2010-04-01/Accounts/:accountSid/AvailablePhoneNumbers/:country/Local.json", - async (req, reply) => { - if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); - if (!deps.numbersClient) return reply.code(400).send(numberErrors.notConfigured); - const { country } = req.params as { country: string }; - const q = req.query as Record; - - // Twilio query params are PascalCase; map them onto our camelCase schema. - const str = (v?: string) => (v === undefined ? undefined : v); - const num = (v?: string) => (v === undefined ? undefined : Number(v)); - const bool = (v?: string) => (v === undefined ? undefined : v === "true"); - const candidate = { - country, - areaCode: str(q.AreaCode), - contains: str(q.Contains), - inRegion: str(q.InRegion), - inPostalCode: str(q.InPostalCode), - inLocality: str(q.InLocality), - inRateCenter: str(q.InRateCenter), - inLata: str(q.InLata), - nearNumber: str(q.NearNumber), - nearLatLong: str(q.NearLatLong), - distance: num(q.Distance), - voiceEnabled: bool(q.VoiceEnabled), - smsEnabled: bool(q.SmsEnabled), - mmsEnabled: bool(q.MmsEnabled), - faxEnabled: bool(q.FaxEnabled), - excludeAllAddressRequired: bool(q.ExcludeAllAddressRequired), - excludeLocalAddressRequired: bool(q.ExcludeLocalAddressRequired), - excludeForeignAddressRequired: bool(q.ExcludeForeignAddressRequired), - beta: bool(q.Beta), - pageSize: num(q.PageSize), - }; - const parsed = TwilioSearchParamsSchema.safeParse(candidate); - if (!parsed.success) { - return reply.code(400).send({ - code: 21604, - message: `Invalid search parameters: ${parsed.error.issues.map((i) => i.path.join(".")).join(", ")}`, - status: 400, - }); - } - const { bwParams, gaps } = translateSearchParams(parsed.data); - if (gaps.length) app.log.info({ gaps }, "number search: unmappable Twilio params"); - const result = await deps.numbersClient.searchAvailable(bwParams); - return reply.send( - availablePhoneNumbersList(result.numbers, config.accountSid, country.toUpperCase()), - ); - }, - ); - - // ── Number lifecycle: purchase ─────────────────────────────────────────── - // POST /2010-04-01/Accounts/:sid/IncomingPhoneNumbers.json - // - // ⚠️ EXPERIMENTAL — NOT VERIFIED AGAINST LIVE BANDWIDTH. - // Live testing (2026-06-19) showed the v2 JSON order endpoint rejects this - // body: 400 "Invalid data type for field 'existingTelephoneNumberOrderType'". - // The real v2 order schema differs from both this code and the public docs - // snippet; it must be confirmed (capture `band`'s request body) before this is - // trustworthy. Auth + search ARE verified live; ordering is not. - // - // Acquires a number. Bandwidth orders are async (RECEIVED→COMPLETE); we poll - // a bounded number of times. Webhook/config fields on the Twilio request have - // no order-time equivalent in Bandwidth and surface as logged gaps — they must - // be applied post-acquisition via the Bandwidth Voice API. - app.post("/2010-04-01/Accounts/:accountSid/IncomingPhoneNumbers.json", async (req, reply) => { - if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); - if (!deps.numbersClient || !config.numbers?.siteId) { - return reply.code(400).send(numberErrors.notConfigured); - } - const body = req.body as Record; - if (!body.PhoneNumber && !body.AreaCode) { - return reply.code(400).send(numberErrors.missingNumberOrAreaCode); - } - const parsed = TwilioPurchaseParamsSchema.safeParse({ - phoneNumber: body.PhoneNumber, - areaCode: body.AreaCode, - friendlyName: body.FriendlyName, - voiceUrl: body.VoiceUrl, - voiceMethod: body.VoiceMethod, - statusCallback: body.StatusCallback, - statusCallbackMethod: body.StatusCallbackMethod, - voiceApplicationSid: body.VoiceApplicationSid, - trunkSid: body.TrunkSid, - }); - if (!parsed.success) return reply.code(400).send(numberErrors.missingNumberOrAreaCode); - - const { bwOrder, gaps } = translatePurchaseToOrder(parsed.data, { - siteId: config.numbers.siteId, - peerId: config.numbers.peerId, - }); - if (gaps.length) app.log.info({ gaps }, "number purchase: fields requiring post-acquisition config"); - - let order = await deps.numbersClient.createOrder(bwOrder); - // Bounded poll while the order is still in flight. Bandwidth existing-TN - // orders typically complete near-instantly; a production path would use - // order-lifecycle callbacks rather than spin here. - for (let i = 0; i < 5 && order.id && (order.orderStatus === "RECEIVED" || order.orderStatus === "PROCESSING"); i++) { - order = await deps.numbersClient.getOrder(order.id); - } - if (order.orderStatus === "FAILED") { - return reply.code(400).send(numberErrors.orderFailed(`order ${order.id ?? ""} returned FAILED`)); - } - - const acquired = order.telephoneNumbers?.[0] ?? parsed.data.phoneNumber; - if (!acquired) { - // Area-code order still provisioning and no number assigned yet. - return reply.code(201).send({ - status: "pending", - bandwidth_order_id: order.id ?? null, - order_status: order.orderStatus, - }); - } - // Remember SID → number so a later DELETE (release) can resolve it. - store.putNumber(toIncomingPhoneNumberSid(acquired), acquired); - return reply.code(201).send( - incomingPhoneNumberResource({ - phoneNumber: acquired, - accountSid: config.accountSid, - friendlyName: parsed.data.friendlyName, - voiceUrl: parsed.data.voiceUrl, - voiceMethod: parsed.data.voiceMethod, - statusCallback: parsed.data.statusCallback, - }), - ); + // Config/liveness check: reports required-env presence only. Deliberately + // side-effect-free and secret-free — no outbound Bandwidth call — so it is + // safe to expose unauthenticated. The live token probe lives only in the + // local `npm run doctor` CLI, so an anonymous caller can neither trigger an + // authenticated OAuth exchange nor observe upstream auth detail here. + app.get("/readyz", async (_req, reply) => { + const report = await checkReadiness({ env: process.env }); + return reply.code(report.ready ? 200 : 503).send(report); }); - // ── Number lifecycle: release ──────────────────────────────────────────── - // DELETE /2010-04-01/Accounts/:sid/IncomingPhoneNumbers/:numberSid.json - // - // ⚠️ EXPERIMENTAL — NOT VERIFIED AGAINST LIVE BANDWIDTH. - // Live testing (2026-06-19) showed the real disconnect endpoint returns XML, - // not JSON, so the JSON-based client.disconnect() cannot parse it. Needs an - // XML request/response path before this is trustworthy. - // - // Twilio releases by SID; we resolve the SID to its E.164 (persisted at - // purchase) and disconnect it on Bandwidth. State is in-memory, so only - // numbers acquired by this instance can be released by SID. - app.delete( - "/2010-04-01/Accounts/:accountSid/IncomingPhoneNumbers/:numberSid.json", - async (req, reply) => { - if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); - if (!deps.numbersClient) return reply.code(400).send(numberErrors.notConfigured); - const { numberSid } = req.params as { numberSid: string }; - const phoneNumber = store.getNumber(numberSid); - if (!phoneNumber) { - return reply.code(404).send(numberErrors.notFound(config.accountSid, numberSid)); - } - await deps.numbersClient.disconnect({ - phoneNumbers: [phoneNumber], - customerOrderId: numberSid, - }); - // Twilio returns 204 No Content on a successful release. - return reply.code(204).send(); - }, - ); - return app; } diff --git a/src/server/call-store.ts b/src/server/call-store.ts index 97be3b7..4f75033 100644 --- a/src/server/call-store.ts +++ b/src/server/call-store.ts @@ -22,8 +22,6 @@ export class CallStore { private byBwId = new Map(); private bySid = new Map(); private recordingsBySid = new Map(); - /** Resolves a Twilio IncomingPhoneNumber SID back to the E.164 it provisioned. */ - private numbersBySid = new Map(); put(bwCallId: string, record: CallRecord): void { this.byBwId.set(bwCallId, record); this.bySid.set(record.sid, record); @@ -40,10 +38,4 @@ export class CallStore { getRecording(recordingSid: string): RecordingRef | undefined { return this.recordingsBySid.get(recordingSid); } - putNumber(sid: string, phoneNumber: string): void { - this.numbersBySid.set(sid, phoneNumber); - } - getNumber(sid: string): string | undefined { - return this.numbersBySid.get(sid); - } } diff --git a/src/server/doctor.ts b/src/server/doctor.ts new file mode 100644 index 0000000..6c9307b --- /dev/null +++ b/src/server/doctor.ts @@ -0,0 +1,33 @@ +import { checkReadiness } from "./readiness.js"; +import { TokenManager } from "../bw/token.js"; + +/** Build a token-probe closure iff BW credentials are present in `env`. */ +export function buildDoctorProbe( + env: Record, +): (() => Promise<{ ok: boolean; error?: string }>) | undefined { + const clientId = env.BW_CLIENT_ID; + const clientSecret = env.BW_CLIENT_SECRET; + if (!clientId || !clientSecret) return undefined; + const apiHost = + env.BW_ENVIRONMENT === "test" ? "https://test.api.bandwidth.com" : "https://api.bandwidth.com"; + return async () => { + try { + const tm = new TokenManager({ clientId, clientSecret, apiHost, fetchImpl: fetch }); + await tm.getToken(); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + }; +} + +async function main() { + const report = await checkReadiness({ env: process.env, probeToken: buildDoctorProbe(process.env) }); + console.log(JSON.stringify(report, null, 2)); + process.exit(report.ready ? 0 : 1); +} + +// Run only when executed directly (not when imported by tests). +if (process.argv[1] && process.argv[1].endsWith("doctor.ts")) { + void main(); +} diff --git a/src/server/errors.ts b/src/server/errors.ts new file mode 100644 index 0000000..c90739f --- /dev/null +++ b/src/server/errors.ts @@ -0,0 +1,30 @@ +// src/server/errors.ts + +/** Structured error body — same shape as twilioErrors in src/twilio/call-resource.ts. */ +export interface AdapterError { + code: number; + message: string; + more_info: string; + status: number; +} + +// Adapter-private code range. Deliberately NOT in Twilio's registry — reusing a +// real Twilio code (e.g. 21610 = STOP/unsubscribed) with a twilio.com link would +// misdiagnose an adapter failure. +const DOCS = "https://github.com/Bandwidth/bw-voice-adapter/blob/main/AGENTS.md#errors"; + +/** Operational errors the adapter itself raises (distinct from Twilio-API-compat errors). */ +export const adapterErrors = { + /** A required request parameter was absent. */ + missingParam(name: string): AdapterError { + return { code: 90001, message: `Missing required parameter: ${name}`, more_info: DOCS, status: 400 }; + }, + /** An unhandled internal failure. Neutral by design — no upstream detail leaked. */ + internal(): AdapterError { + return { code: 90002, message: "Internal adapter error", more_info: DOCS, status: 500 }; + }, + /** A request parameter was present but failed validation. */ + invalidParam(name: string): AdapterError { + return { code: 90004, message: `Invalid parameter: ${name}`, more_info: DOCS, status: 400 }; + }, +} as const; diff --git a/src/server/index.ts b/src/server/index.ts index 8d36985..ec3a456 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,5 @@ import { buildApp } from "./app.js"; import { createBwClient } from "../bw/client.js"; -import { createNumbersClient } from "../numbers/client.js"; function env(name: string): string { const v = process.env[name]; @@ -8,24 +7,7 @@ function env(name: string): string { return v; } -function optEnv(name: string): string | undefined { - return process.env[name] || undefined; -} - -// The number-lifecycle facade shares the platform OAuth2 client-credentials with -// the Voice API (one token, account roles decide what it can do). Search/release -// work whenever the credential carries the Numbers role; ordering additionally -// needs a site (BW_SITE_ID), enforced by the route. const bwEnv = process.env.BW_ENVIRONMENT === "test" ? "test" : "prod"; -const numbersApiHost = bwEnv === "test" ? "https://test.api.bandwidth.com" : "https://api.bandwidth.com"; -const siteId = optEnv("BW_SITE_ID"); -const numbersClient = createNumbersClient({ - accountId: env("BW_ACCOUNT_ID"), - clientId: env("BW_CLIENT_ID"), - clientSecret: env("BW_CLIENT_SECRET"), - apiHost: numbersApiHost, - baseUrl: optEnv("BW_NUMBERS_BASE_URL") ?? `${numbersApiHost}/api/v2`, -}); const app = buildApp( { @@ -37,7 +19,6 @@ const app = buildApp( webhookPassword: env("WEBHOOK_PASSWORD"), allowPrivateEgress: process.env.EGRESS_ALLOW_PRIVATE === "1", egressAllowHosts: process.env.EGRESS_ALLOW_HOSTS?.split(",").map((s) => s.trim()).filter(Boolean), - ...(siteId ? { numbers: { siteId, peerId: optEnv("BW_PEER_ID") } } : {}), }, { fetchImpl: fetch, @@ -48,7 +29,6 @@ const app = buildApp( applicationId: env("BW_APPLICATION_ID"), environment: bwEnv, }), - numbersClient, }, ); diff --git a/src/server/readiness.ts b/src/server/readiness.ts new file mode 100644 index 0000000..b04c5f9 --- /dev/null +++ b/src/server/readiness.ts @@ -0,0 +1,48 @@ +/** Env vars the adapter server requires to start and serve calls. */ +export const REQUIRED_ENV = [ + "ADAPTER_ACCOUNT_SID", + "ADAPTER_AUTH_TOKEN", + "PUBLIC_BASE_URL", + "CUSTOMER_VOICE_URL", + "BW_ACCOUNT_ID", + "BW_CLIENT_ID", + "BW_CLIENT_SECRET", + "BW_APPLICATION_ID", + "WEBHOOK_USER", + "WEBHOOK_PASSWORD", +]; + +export interface ReadinessReport { + ready: boolean; + missingEnv: string[]; + env: { name: string; present: boolean }[]; + bwToken: { probed: boolean; ok: boolean; error?: string }; +} + +/** + * Report whether the adapter is configured to serve calls. Pure over its + * inputs: pass `env` (usually process.env) and an optional `probeToken` that + * attempts a live Bandwidth OAuth2 token exchange. + */ +export async function checkReadiness(opts: { + env: Record; + probeToken?: () => Promise<{ ok: boolean; error?: string }>; +}): Promise { + const env = REQUIRED_ENV.map((name) => ({ name, present: Boolean(opts.env[name]) })); + const missingEnv = env.filter((e) => !e.present).map((e) => e.name); + + let bwToken: ReadinessReport["bwToken"]; + if (opts.probeToken) { + try { + const res = await opts.probeToken(); + bwToken = { probed: true, ok: res.ok, ...(res.error ? { error: res.error } : {}) }; + } catch (e) { + // A probe that throws is a failed readiness check, not a 500. + bwToken = { probed: true, ok: false, error: e instanceof Error ? e.message : String(e) }; + } + } else { + bwToken = { probed: false, ok: true }; + } + + return { ready: missingEnv.length === 0 && bwToken.ok, missingEnv, env, bwToken }; +} diff --git a/src/translator/translate.ts b/src/translator/translate.ts index 074e711..15e3668 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -195,6 +195,7 @@ function stampCallbackAuth(els: XmlEl[], auth: { username: string; password: str } export function translateTwiml(twiml: string, opts: TranslateOptions = {}): TranslateResult { + bxmlByteBudget = MAX_TOTAL_BXML_BYTES; const root = parseTwiml(twiml); const findings: Finding[] = []; const rewrite = opts.rewriteUrl ?? ((u: string) => u); @@ -226,6 +227,23 @@ function warn(verb: string, message: string, findings: Finding[]): void { findings.push({ severity: "warning", verb, message, docsUrl: matrix.verbs[verb]?.docsUrl }); } +// Twilio's documented ceiling for Say/Play loop counts; also our hard bound on +// a single verb's expansion so one attribute cannot force unbounded allocation. +const MAX_LOOP = 1000; + +// Document-wide ceiling on total loop-expanded OUTPUT BYTES. The per-verb +// MAX_LOOP bounds one verb's repeat count, but that alone doesn't bound size: +// a single with a large text body is only 1000 elements yet +// serializes to hundreds of MB. Budgeting serialized bytes bounds both the +// many-verbs and the one-huge-verb amplifications. 2 MiB is far above any real +// call flow, far below anything that stresses memory. +const MAX_TOTAL_BXML_BYTES = 2 * 1024 * 1024; +// Remaining byte budget for the current translateTwiml call. Reset at the start +// of each call; safe as module state because translateTwiml runs synchronously +// with no await. (The one caveat: a caller-supplied rewriteUrl must not itself +// call translateTwiml re-entrantly — ours is a pure URL builder that doesn't.) +let bxmlByteBudget = MAX_TOTAL_BXML_BYTES; + /** * Twilio's loop="N" repeats a /. BXML has no loop attribute, so a * finite count is expanded into the verb repeated N times. loop="0" means @@ -244,8 +262,30 @@ function applyLoop(els: XmlEl[], loop: string | undefined, verb: string, finding warn(verb, `loop="${loop}" is not a valid repeat count; content will play once.`, findings); return els; } + // Bound the expansion. Twilio's documented maximum for Say/Play loop is 1000; + // without a cap, a value like loop="999999999" (or loop="9e9") would allocate + // billions of elements and a multi-gigabyte string on the event loop from a + // single attacker-controlled document. Clamp to the max and warn. + const count = Math.min(n, MAX_LOOP); + if (n > MAX_LOOP) + warn(verb, `loop="${loop}" exceeds the maximum of ${MAX_LOOP}; content will repeat ${MAX_LOOP} times.`, findings); + // Enforce the document-wide byte budget BEFORE expanding, so a document + // cannot amplify to a giant serialized string. Serialize one un-looped copy + // to size the unit (bounded by input size), then project the repeated cost. + const unitBytes = bxmlDocument(els).length; + const projected = count * unitBytes; + if (projected > bxmlByteBudget) { + findings.push({ + severity: "error", + verb, + message: `Total output exceeds the ${MAX_TOTAL_BXML_BYTES}-byte document budget; document rejected.`, + docsUrl: matrix.verbs[verb]?.docsUrl, + }); + return els; + } + bxmlByteBudget -= projected; const out: XmlEl[] = []; - for (let i = 0; i < n; i++) out.push(...els); + for (let i = 0; i < count; i++) out.push(...els); return out; } diff --git a/src/twilio/call-sid.ts b/src/twilio/call-sid.ts index 56694c9..d64685b 100644 --- a/src/twilio/call-sid.ts +++ b/src/twilio/call-sid.ts @@ -8,10 +8,3 @@ export function toRecordingSid(bwRecordingId: string): string { return "RE" + createHash("md5").update(bwRecordingId).digest("hex"); } -/** - * Twilio IncomingPhoneNumber SID (PN...). Derived deterministically from the - * E.164 number so the same provisioned number always maps to the same SID. - */ -export function toIncomingPhoneNumberSid(phoneNumber: string): string { - return "PN" + createHash("md5").update(phoneNumber).digest("hex"); -} diff --git a/src/twilio/egress.ts b/src/twilio/egress.ts index a5aebac..c427e6e 100644 --- a/src/twilio/egress.ts +++ b/src/twilio/egress.ts @@ -108,6 +108,41 @@ export function recordingStatusParams( }; } +// Cap on the customer webhook response we will buffer. TwiML documents are +// small; an unbounded read lets a malicious/compromised endpoint stream a huge +// body that we then parse and (via loop expansion) amplify. 256 KiB is well +// above any legitimate TwiML document. +const MAX_TWIML_BYTES = 256 * 1024; + +/** + * Read a fetch Response body as text, aborting if it exceeds maxBytes. Streams + * and counts decoded bytes rather than trusting Content-Length, so a lying or + * absent header cannot bypass the cap. + */ +async function readCappedText(res: Response, maxBytes: number, url: string): Promise { + const declared = Number(res.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + // Release the connection; don't leave the oversized body streaming. + await res.body?.cancel().catch(() => {}); + throw new Error(`Customer webhook ${url} response too large (${declared} bytes)`); + } + if (!res.body) return await res.text(); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); // don't let a cancel error mask the "too large" error + throw new Error(`Customer webhook ${url} response too large (>${maxBytes} bytes)`); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + export async function postToCustomer(opts: { url: string; params: Record; @@ -117,6 +152,7 @@ export async function postToCustomer(opts: { allowHosts?: string[]; lookup?: (host: string) => Promise; timeoutMs?: number; + maxBytes?: number; }): Promise { const doFetch = opts.fetchImpl ?? fetch; // Validate + resolve BEFORE any network call. Throws EgressBlockedError on a @@ -138,7 +174,7 @@ export async function postToCustomer(opts: { if (res.status >= 300 && res.status < 400) throw new Error(`Customer webhook ${opts.url} attempted a redirect (${res.status})`); if (!res.ok) throw new Error(`Customer webhook ${opts.url} returned ${res.status}`); - return await res.text(); + return await readCappedText(res, opts.maxBytes ?? MAX_TWIML_BYTES, opts.url); } finally { clearTimeout(timer); } diff --git a/src/twilio/number-resource.ts b/src/twilio/number-resource.ts deleted file mode 100644 index 40974d0..0000000 --- a/src/twilio/number-resource.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Twilio phone-number resource shapes, for the number-lifecycle REST facade: - * - * GET .../AvailablePhoneNumbers/{Country}/Local.json → availablePhoneNumbersList - * POST .../IncomingPhoneNumbers.json (purchase) → incomingPhoneNumberResource - * - * Mirrors the shaping pattern in call-resource.ts / recording-resource.ts. - * These shapes follow Twilio's documented JSON but are NOT fixture-verified - * against a live order (the same Numbers-role gap noted in numbers/schema.ts), - * so fields Bandwidth's search does not return are emitted as null rather than - * fabricated. - */ - -import type { AvailableNumber } from "../numbers/client.js"; -import { toIncomingPhoneNumberSid } from "./call-sid.js"; -import { twilioDate } from "./call-resource.js"; - -/** Format a NANP E.164 number as Twilio's friendly "(NXX) NXX-XXXX"; pass others through. */ -export function friendlyName(e164: string): string { - const m = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(e164); - return m ? `(${m[1]}) ${m[2]}-${m[3]}` : e164; -} - -/** - * Shape one Bandwidth available number as a Twilio AvailablePhoneNumber. - * Bandwidth's search does not return lat/long/LATA/postal or per-number - * capability flags; those are emitted as null/best-effort. All NANP numbers are - * voice-capable, so `voice` is true; SMS/MMS depend on account messaging - * configuration (not the number), so they are reported true as the NANP default. - */ -export function availablePhoneNumberResource( - n: AvailableNumber, - isoCountry: string, -): Record { - return { - friendly_name: friendlyName(n.fullNumber), - phone_number: n.fullNumber, - lata: null, - rate_center: n.rateCenter ?? null, - latitude: null, - longitude: null, - locality: n.city ?? null, - region: n.state ?? null, - postal_code: null, - iso_country: isoCountry, - address_requirements: "none", - beta: false, - capabilities: { voice: true, SMS: true, MMS: true }, - }; -} - -/** Twilio's list envelope for an AvailablePhoneNumbers search. */ -export function availablePhoneNumbersList( - numbers: AvailableNumber[], - accountSid: string, - isoCountry: string, -): Record { - return { - available_phone_numbers: numbers.map((n) => availablePhoneNumberResource(n, isoCountry)), - uri: `/2010-04-01/Accounts/${accountSid}/AvailablePhoneNumbers/${isoCountry}/Local.json`, - }; -} - -/** - * Shape a provisioned number as a Twilio IncomingPhoneNumber resource — the - * body returned from a successful purchase. `voiceUrl` reflects what the caller - * asked us to set even though Bandwidth applies webhook config post-acquisition - * (see the gaps surfaced by translatePurchaseToOrder). - */ -export function incomingPhoneNumberResource(opts: { - phoneNumber: string; - accountSid: string; - friendlyName?: string; - voiceUrl?: string; - voiceMethod?: string; - statusCallback?: string; - now?: Date; -}): Record { - const sid = toIncomingPhoneNumberSid(opts.phoneNumber); - const date = twilioDate(opts.now ?? new Date()); - return { - sid, - account_sid: opts.accountSid, - friendly_name: opts.friendlyName ?? friendlyName(opts.phoneNumber), - phone_number: opts.phoneNumber, - voice_url: opts.voiceUrl ?? null, - voice_method: opts.voiceMethod ?? "POST", - voice_fallback_url: null, - voice_fallback_method: "POST", - status_callback: opts.statusCallback ?? null, - status_callback_method: "POST", - sms_url: null, - sms_method: "POST", - voice_caller_id_lookup: false, - api_version: "2010-04-01", - date_created: date, - date_updated: date, - capabilities: { voice: true, SMS: true, MMS: true, fax: false }, - beta: false, - origin: "origin", - trunk_sid: null, - emergency_status: "Inactive", - emergency_address_sid: null, - uri: `/2010-04-01/Accounts/${opts.accountSid}/IncomingPhoneNumbers/${sid}.json`, - }; -} - -/** Adapter-specific error bodies for the numbers facade, shaped like Twilio errors. */ -export const numberErrors = { - /** The adapter has no Numbers client / siteId configured. */ - notConfigured: { - code: 21601, - message: - "Number provisioning is not configured on this adapter (Bandwidth Numbers client / siteId missing).", - more_info: "https://www.twilio.com/docs/errors/21601", - status: 400, - }, - /** Neither PhoneNumber nor AreaCode supplied on a purchase. */ - missingNumberOrAreaCode: { - code: 21602, - message: "Either a 'PhoneNumber' or an 'AreaCode' is required to purchase a number.", - more_info: "https://www.twilio.com/docs/errors/21602", - status: 400, - }, - /** 404 for an unknown IncomingPhoneNumber SID (path mirrors live Twilio). */ - notFound(accountSid: string, sid: string) { - return { - code: 20404, - message: `The requested resource /2010-04-01/Accounts/${accountSid}/IncomingPhoneNumbers/${sid}.json was not found`, - more_info: "https://www.twilio.com/docs/errors/20404", - status: 404, - }; - }, - /** The Bandwidth order came back FAILED. */ - orderFailed(detail: string) { - return { - code: 21603, - message: `The number order could not be completed: ${detail}`, - more_info: "https://www.twilio.com/docs/errors/21603", - status: 400, - }; - }, -} as const; diff --git a/test/bw-client-id-validation.test.ts b/test/bw-client-id-validation.test.ts new file mode 100644 index 0000000..846b657 --- /dev/null +++ b/test/bw-client-id-validation.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { createBwClient, isSafeBwId } from "../src/bw/client.js"; + +// Guards against path/argument injection: ids planted via webhooks must not be +// able to reshape the authenticated Bandwidth API request path. The client +// rejects unsafe ids before constructing the URL (encodeURIComponent alone does +// NOT neutralize "." / ".." path segments). + +function fetchWithToken() { + return vi.fn(async (url: string) => { + if (String(url).endsWith("/api/v1/oauth2/token")) + return new Response(JSON.stringify({ access_token: "tok", expires_in: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + return new Response(JSON.stringify({ state: "active" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; +} +const base = { accountId: "9900", clientId: "C", clientSecret: "s", applicationId: "app", environment: "prod" as const }; + +const BAD = ".."; + +// Every method that interpolates an id into the upstream URL, invoked with an +// unsafe id in each id position. +const SINKS: Array<[string, (c: ReturnType) => Promise]> = [ + ["modifyCall(callId)", (c) => c.modifyCall(BAD, { state: "completed" })], + ["getCall(callId)", (c) => c.getCall(BAD)], + ["listRecordings(callId)", (c) => c.listRecordings(BAD)], + ["getRecording(callId)", (c) => c.getRecording(BAD, "r-1")], + ["getRecording(recordingId)", (c) => c.getRecording("c-1", BAD)], + ["getRecordingMedia(callId)", (c) => c.getRecordingMedia(BAD, "r-1")], + ["getRecordingMedia(recordingId)", (c) => c.getRecordingMedia("c-1", BAD)], + ["updateRecording(callId)", (c) => c.updateRecording(BAD, "paused")], +]; + +describe("bw client rejects unsafe identifiers at every sink", () => { + for (const [label, call] of SINKS) { + it(`${label} throws and makes NO network call`, async () => { + const fetchImpl = fetchWithToken(); + const client = createBwClient({ ...base, fetchImpl }); + await expect(call(client)).rejects.toThrow(/Invalid Bandwidth identifier/); + expect(fetchImpl).not.toHaveBeenCalled(); // rejected before token fetch or API call + }); + } +}); + +describe("isSafeBwId", () => { + it("rejects path syntax, reserved chars, empty, and over-long ids", () => { + for (const id of [".", "..", "../secret", "a/b", "a?b", "a#b", "a@b", "a%2Fb", "", "c 1", "a".repeat(257), 5 as unknown as string]) + expect(isSafeBwId(id)).toBe(false); + }); + it("accepts real Bandwidth id shapes", () => { + for (const id of ["c-95ac8d6e-1a2b-4c3d", "r-1", "abc123", "A_B-9"]) expect(isSafeBwId(id)).toBe(true); + }); +}); diff --git a/test/doctor.test.ts b/test/doctor.test.ts new file mode 100644 index 0000000..f89c272 --- /dev/null +++ b/test/doctor.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { buildDoctorProbe } from "../src/server/doctor.js"; + +describe("buildDoctorProbe", () => { + it("returns undefined when BW credentials are absent", () => { + expect(buildDoctorProbe({})).toBeUndefined(); + }); + + it("returns a probe fn when BW credentials are present", () => { + const probe = buildDoctorProbe({ BW_CLIENT_ID: "id", BW_CLIENT_SECRET: "sec" }); + expect(typeof probe).toBe("function"); + }); +}); diff --git a/test/egress-post.test.ts b/test/egress-post.test.ts index 8fbfc67..3eeaa07 100644 --- a/test/egress-post.test.ts +++ b/test/egress-post.test.ts @@ -31,3 +31,50 @@ describe("postToCustomer egress guard", () => { expect(fetchImpl).toHaveBeenCalled(); }); }); + +describe("postToCustomer response-size cap", () => { + it("aborts an oversized streamed body early and cancels the reader", async () => { + let pulls = 0; + let cancelled = false; + const chunk = new Uint8Array(64 * 1024); // 64 KiB per pull + const stream = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(chunk); // never-ending source + }, + cancel() { + cancelled = true; + }, + }); + const fetchImpl = vi.fn(async () => new Response(stream, { status: 200 })) as unknown as typeof fetch; + await expect( + postToCustomer({ url: "http://public.test/hook", params: {}, authToken: "t", fetchImpl, lookup: async () => ["93.184.216.34"] }), + ).rejects.toThrow(/too large/); + expect(cancelled).toBe(true); // reader was cancelled, not drained + expect(pulls).toBeLessThan(16); // stopped ~5 pulls in (256 KiB / 64 KiB), not unbounded + }); + + it("rejects (and cancels) when Content-Length alone declares oversize", async () => { + let cancelled = false; + const stream = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(8)); + c.close(); + }, + cancel() { + cancelled = true; + }, + }); + const res = new Response(stream, { status: 200, headers: { "content-length": String(300 * 1024) } }); + const fetchImpl = vi.fn(async () => res) as unknown as typeof fetch; + await expect( + postToCustomer({ url: "http://public.test/hook", params: {}, authToken: "t", fetchImpl, lookup: async () => ["93.184.216.34"] }), + ).rejects.toThrow(/too large/); + expect(cancelled).toBe(true); + }); + it("accepts a normal-sized response", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + const body = await postToCustomer({ url: "http://public.test/hook", params: {}, authToken: "t", fetchImpl, lookup: async () => ["93.184.216.34"] }); + expect(body).toBe(""); + }); +}); diff --git a/test/numbers-client.test.ts b/test/numbers-client.test.ts deleted file mode 100644 index b9becf2..0000000 --- a/test/numbers-client.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { createNumbersClient } from "../src/numbers/client.js"; - -describe("createNumbersClient OAuth2 wiring", () => { - it("exchanges client credentials for a Bearer token and uses it on API calls", async () => { - const calls: Array<{ url: string; init?: RequestInit }> = []; - const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { - calls.push({ url, init }); - if (url.endsWith("/api/v1/oauth2/token")) { - return new Response(JSON.stringify({ access_token: "tok-123", expires_in: 3600 }), { - status: 200, - }); - } - // Real live shape (verified 2026-06-19): E.164 strings under phoneNumbers. - return new Response(JSON.stringify({ phoneNumbers: ["+19195551234"], resultCount: 1 }), { - status: 200, - }); - }) as unknown as typeof fetch; - - const client = createNumbersClient({ - accountId: "acct-1", - clientId: "cid", - clientSecret: "secret", - apiHost: "https://api.bandwidth.com", - baseUrl: "https://api.bandwidth.com/api/v2", - fetchImpl, - }); - - const result = await client.searchAvailable({ areaCode: "919" }); - expect(result.numbers[0].fullNumber).toBe("+19195551234"); - - // Token endpoint hit with Basic client creds + client_credentials grant. - const tokenCall = calls.find((c) => c.url.endsWith("/api/v1/oauth2/token")); - expect(tokenCall).toBeTruthy(); - const tokenHeaders = tokenCall!.init!.headers as Record; - expect(tokenHeaders.Authorization).toMatch(/^Basic /); - expect(tokenCall!.init!.body).toContain("grant_type=client_credentials"); - - // The API call carries the Bearer token and the translated query. - const apiCall = calls.find((c) => c.url.includes("/availableNumbers")); - expect(apiCall!.url).toContain("/api/v2/accounts/acct-1/availableNumbers"); - expect((apiCall!.init!.headers as Record).Authorization).toBe("Bearer tok-123"); - expect(apiCall!.url).toContain("areaCode=919"); - }); - - it("caches the token across calls instead of re-fetching it", async () => { - let tokenHits = 0; - const fetchImpl = vi.fn(async (url: string) => { - if (url.endsWith("/api/v1/oauth2/token")) { - tokenHits++; - return new Response(JSON.stringify({ access_token: "t", expires_in: 3600 }), { status: 200 }); - } - return new Response(JSON.stringify({ orderStatus: "COMPLETE" }), { status: 200 }); - }) as unknown as typeof fetch; - - const client = createNumbersClient({ - accountId: "a", - clientId: "c", - clientSecret: "s", - fetchImpl, - }); - await client.getOrder("o1"); - await client.getOrder("o2"); - expect(tokenHits).toBe(1); - }); -}); diff --git a/test/numbers-schema.test.ts b/test/numbers-schema.test.ts deleted file mode 100644 index 001fceb..0000000 --- a/test/numbers-schema.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Unit tests for the number-lifecycle zod schemas. - * Written first (TDD) — these fail until src/numbers/schema.ts is implemented. - */ - -import { describe, it, expect } from "vitest"; -import { - TwilioSearchParamsSchema, - TwilioPurchaseParamsSchema, - BwSearchParamsSchema, - BwOrderRequestSchema, - BwOrderResponseSchema, - NumberLifecycleStageSchema, -} from "../src/numbers/schema.js"; - -// ── TwilioSearchParamsSchema ───────────────────────────────────────────────── - -describe("TwilioSearchParamsSchema", () => { - it("accepts a minimal valid search (areaCode only)", () => { - const result = TwilioSearchParamsSchema.safeParse({ areaCode: "919" }); - expect(result.success).toBe(true); - }); - - it("accepts a full US search payload", () => { - const result = TwilioSearchParamsSchema.safeParse({ - areaCode: "415", - contains: "555****", - inRegion: "CA", - inPostalCode: "94103", - inLocality: "San Francisco", - inRateCenter: "SNFC CNTRL", - inLata: "722", - nearNumber: "+14155550001", - nearLatLong: "37.7749,-122.4194", - distance: 25, - voiceEnabled: true, - smsEnabled: true, - mmsEnabled: false, - faxEnabled: false, - excludeAllAddressRequired: false, - excludeLocalAddressRequired: false, - excludeForeignAddressRequired: false, - beta: false, - country: "US", - pageSize: 20, - }); - expect(result.success).toBe(true); - }); - - it("rejects a negative distance", () => { - const result = TwilioSearchParamsSchema.safeParse({ areaCode: "919", distance: -1 }); - expect(result.success).toBe(false); - }); - - it("rejects pageSize > 1000", () => { - const result = TwilioSearchParamsSchema.safeParse({ areaCode: "919", pageSize: 9999 }); - expect(result.success).toBe(false); - }); - - it("accepts an empty object (all fields optional)", () => { - const result = TwilioSearchParamsSchema.safeParse({}); - expect(result.success).toBe(true); - }); -}); - -// ── TwilioPurchaseParamsSchema ─────────────────────────────────────────────── - -describe("TwilioPurchaseParamsSchema", () => { - it("accepts purchase by explicit phoneNumber", () => { - const result = TwilioPurchaseParamsSchema.safeParse({ phoneNumber: "+14155550001" }); - expect(result.success).toBe(true); - }); - - it("accepts purchase by areaCode (US shortcut)", () => { - const result = TwilioPurchaseParamsSchema.safeParse({ areaCode: "415" }); - expect(result.success).toBe(true); - }); - - it("accepts a full purchase payload", () => { - const result = TwilioPurchaseParamsSchema.safeParse({ - phoneNumber: "+14155550001", - friendlyName: "My Line", - voiceUrl: "https://example.com/voice", - voiceMethod: "POST", - voiceFallbackUrl: "https://example.com/voice-fallback", - voiceFallbackMethod: "POST", - statusCallback: "https://example.com/status", - statusCallbackMethod: "POST", - smsUrl: "https://example.com/sms", - smsMethod: "POST", - smsFallbackUrl: "https://example.com/sms-fallback", - smsFallbackMethod: "POST", - voiceCallerIdLookup: true, - }); - expect(result.success).toBe(true); - }); - - it("rejects an invalid voiceMethod value", () => { - const result = TwilioPurchaseParamsSchema.safeParse({ - phoneNumber: "+14155550001", - voiceMethod: "DELETE", - }); - expect(result.success).toBe(false); - }); - - it("rejects an empty object (must have phoneNumber OR areaCode)", () => { - const result = TwilioPurchaseParamsSchema.safeParse({}); - expect(result.success).toBe(false); - }); -}); - -// ── BwSearchParamsSchema ───────────────────────────────────────────────────── - -describe("BwSearchParamsSchema", () => { - it("accepts a minimal BW search by areaCode", () => { - const result = BwSearchParamsSchema.safeParse({ areaCode: "919" }); - expect(result.success).toBe(true); - }); - - it("accepts a full BW search payload", () => { - const result = BwSearchParamsSchema.safeParse({ - areaCode: "919", - state: "NC", - city: "Raleigh", - zip: "27606", - lata: "426", - rateCenter: "RALEIGH", - npaNxx: "919832", - localVanity: "2982", - quantity: 10, - enableTNDetail: true, - countryCodeA3: "USA", - phoneNumberType: "GEOGRAPHIC", - }); - expect(result.success).toBe(true); - }); - - it("rejects an invalid phoneNumberType", () => { - const result = BwSearchParamsSchema.safeParse({ areaCode: "919", phoneNumberType: "LANDLINE" }); - expect(result.success).toBe(false); - }); - - it("accepts an empty object (all fields optional)", () => { - const result = BwSearchParamsSchema.safeParse({}); - expect(result.success).toBe(true); - }); -}); - -// ── BwOrderRequestSchema ───────────────────────────────────────────────────── - -describe("BwOrderRequestSchema", () => { - it("accepts a minimal order with areaCode search type", () => { - const result = BwOrderRequestSchema.safeParse({ - name: "My Order", - siteId: "1111", - quantity: 1, - areaCodeSearchAndOrderType: { areaCode: "919", quantity: 1 }, - }); - expect(result.success).toBe(true); - }); - - it("accepts an order with a specific phone number", () => { - const result = BwOrderRequestSchema.safeParse({ - name: "Specific TN Order", - siteId: "1111", - quantity: 1, - existingTelephoneNumberOrderType: { - telephoneNumberList: ["+14155550001"], - }, - }); - expect(result.success).toBe(true); - }); - - it("accepts an order with peerId", () => { - const result = BwOrderRequestSchema.safeParse({ - name: "Peer Order", - siteId: "1111", - peerId: "2222", - quantity: 1, - areaCodeSearchAndOrderType: { areaCode: "415", quantity: 1 }, - }); - expect(result.success).toBe(true); - }); - - it("requires name", () => { - const result = BwOrderRequestSchema.safeParse({ - siteId: "1111", - quantity: 1, - areaCodeSearchAndOrderType: { areaCode: "919", quantity: 1 }, - }); - expect(result.success).toBe(false); - }); - - it("requires siteId", () => { - const result = BwOrderRequestSchema.safeParse({ - name: "Order", - quantity: 1, - areaCodeSearchAndOrderType: { areaCode: "919", quantity: 1 }, - }); - expect(result.success).toBe(false); - }); -}); - -// ── BwOrderResponseSchema ──────────────────────────────────────────────────── - -describe("BwOrderResponseSchema", () => { - it("accepts a valid order response", () => { - const result = BwOrderResponseSchema.safeParse({ - id: "order-123", - orderStatus: "RECEIVED", - orderCreateDate: "2024-01-15T12:00:00Z", - telephoneNumbers: ["+14155550001"], - }); - expect(result.success).toBe(true); - }); - - it("accepts response without telephoneNumbers (async order pending)", () => { - const result = BwOrderResponseSchema.safeParse({ - id: "order-123", - orderStatus: "RECEIVED", - orderCreateDate: "2024-01-15T12:00:00Z", - }); - expect(result.success).toBe(true); - }); - - it("requires id and orderStatus", () => { - const result = BwOrderResponseSchema.safeParse({ orderCreateDate: "2024-01-15T12:00:00Z" }); - expect(result.success).toBe(false); - }); -}); - -// ── NumberLifecycleStageSchema ─────────────────────────────────────────────── - -describe("NumberLifecycleStageSchema", () => { - it("accepts all valid stage values", () => { - for (const stage of ["discover", "acquire", "activate", "operate"]) { - expect(NumberLifecycleStageSchema.safeParse(stage).success).toBe(true); - } - }); - - it("rejects an unknown stage", () => { - expect(NumberLifecycleStageSchema.safeParse("port").success).toBe(false); - }); -}); diff --git a/test/numbers-translate.test.ts b/test/numbers-translate.test.ts deleted file mode 100644 index 6fbae50..0000000 --- a/test/numbers-translate.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Unit tests for Twilio→Bandwidth number-lifecycle translations. - * Written first (TDD) — these fail until src/numbers/translate.ts is implemented. - */ - -import { describe, it, expect } from "vitest"; -import { - translateSearchParams, - translatePurchaseToOrder, - type SearchTranslation, - type PurchaseTranslation, -} from "../src/numbers/translate.js"; -import { BwOrderRequestSchema } from "../src/numbers/schema.js"; - -// ── translateSearchParams ───────────────────────────────────────────────────── - -describe("translateSearchParams: Twilio search → BW search params", () => { - it("maps areaCode directly", () => { - const result: SearchTranslation = translateSearchParams({ areaCode: "919" }); - expect(result.bwParams.areaCode).toBe("919"); - expect(result.gaps).toHaveLength(0); - }); - - it("maps inRegion → state", () => { - const result = translateSearchParams({ inRegion: "CA" }); - expect(result.bwParams.state).toBe("CA"); - }); - - it("maps inPostalCode → zip", () => { - const result = translateSearchParams({ inPostalCode: "94103" }); - expect(result.bwParams.zip).toBe("94103"); - }); - - it("maps inLocality → city", () => { - const result = translateSearchParams({ inLocality: "Raleigh" }); - expect(result.bwParams.city).toBe("Raleigh"); - }); - - it("maps inRateCenter → rateCenter", () => { - const result = translateSearchParams({ inRateCenter: "RALEIGH" }); - expect(result.bwParams.rateCenter).toBe("RALEIGH"); - }); - - it("maps inLata → lata", () => { - const result = translateSearchParams({ inLata: "426" }); - expect(result.bwParams.lata).toBe("426"); - }); - - it("maps pageSize → quantity", () => { - const result = translateSearchParams({ pageSize: 15 }); - expect(result.bwParams.quantity).toBe(15); - }); - - it("surfaces contains as a gap (no BW equivalent)", () => { - const result = translateSearchParams({ contains: "555****" }); - expect(result.gaps.some((g) => /contains/i.test(g.twilioParam))).toBe(true); - expect(result.bwParams).not.toHaveProperty("contains"); - }); - - it("surfaces nearNumber as a gap (no BW equivalent)", () => { - const result = translateSearchParams({ nearNumber: "+14155550001" }); - expect(result.gaps.some((g) => /nearNumber/i.test(g.twilioParam))).toBe(true); - }); - - it("surfaces nearLatLong as a gap (no BW equivalent)", () => { - const result = translateSearchParams({ nearLatLong: "37.7749,-122.4194" }); - expect(result.gaps.some((g) => /nearLatLong/i.test(g.twilioParam))).toBe(true); - }); - - it("surfaces voiceEnabled/smsEnabled/mmsEnabled/faxEnabled as gaps", () => { - const result = translateSearchParams({ - voiceEnabled: true, - smsEnabled: true, - mmsEnabled: false, - faxEnabled: false, - }); - const gapParams = result.gaps.map((g) => g.twilioParam); - expect(gapParams).toContain("voiceEnabled"); - expect(gapParams).toContain("smsEnabled"); - expect(gapParams).toContain("mmsEnabled"); - expect(gapParams).toContain("faxEnabled"); - }); - - it("surfaces excludeAllAddressRequired as a gap", () => { - const result = translateSearchParams({ excludeAllAddressRequired: true }); - expect(result.gaps.some((g) => /excludeAllAddressRequired/i.test(g.twilioParam))).toBe(true); - }); - - it("maps country to countryCodeA3 for known values", () => { - const result = translateSearchParams({ country: "US" }); - expect(result.bwParams.countryCodeA3).toBe("USA"); - }); - - it("maps country=CA to countryCodeA3 CAN", () => { - const result = translateSearchParams({ country: "CA" }); - expect(result.bwParams.countryCodeA3).toBe("CAN"); - }); - - it("surfaces unknown country codes as a gap", () => { - const result = translateSearchParams({ country: "FR" }); - expect(result.gaps.some((g) => /country/i.test(g.twilioParam))).toBe(true); - }); - - it("handles a combined search (areaCode + region + quantity)", () => { - const result = translateSearchParams({ areaCode: "919", inRegion: "NC", pageSize: 5 }); - expect(result.bwParams.areaCode).toBe("919"); - expect(result.bwParams.state).toBe("NC"); - expect(result.bwParams.quantity).toBe(5); - expect(result.gaps).toHaveLength(0); - }); - - it("returns empty bwParams and no gaps for empty input", () => { - const result = translateSearchParams({}); - expect(result.bwParams).toEqual({}); - expect(result.gaps).toHaveLength(0); - }); -}); - -// ── translatePurchaseToOrder ────────────────────────────────────────────────── - -describe("translatePurchaseToOrder: Twilio purchase → BW order", () => { - it("maps an explicit E.164 phoneNumber to existingTelephoneNumberOrderType", () => { - const result: PurchaseTranslation = translatePurchaseToOrder( - { phoneNumber: "+14155550001" }, - { siteId: "1111", orderName: "Test Order" }, - ); - expect(result.bwOrder.existingTelephoneNumberOrderType?.telephoneNumberList).toContain( - "+14155550001", - ); - expect(result.bwOrder.name).toBe("Test Order"); - expect(result.bwOrder.siteId).toBe("1111"); - // No top-level quantity: the live v2 JSON order endpoint rejects it - // ("Invalid data type for field 'quantity'", verified 2026-06-19). - expect(result.bwOrder.quantity).toBeUndefined(); - }); - - it("maps areaCode purchase to areaCodeSearchAndOrderType", () => { - const result = translatePurchaseToOrder( - { areaCode: "919" }, - { siteId: "1111", orderName: "Area Code Order" }, - ); - expect(result.bwOrder.areaCodeSearchAndOrderType?.areaCode).toBe("919"); - expect(result.bwOrder.areaCodeSearchAndOrderType?.quantity).toBe(1); - expect(result.bwOrder.existingTelephoneNumberOrderType).toBeUndefined(); - }); - - it("surfaces friendlyName as a gap (no BW order equivalent)", () => { - const result = translatePurchaseToOrder( - { phoneNumber: "+14155550001", friendlyName: "My Main Line" }, - { siteId: "1111", orderName: "Order" }, - ); - expect(result.gaps.some((g) => /friendlyName/i.test(g.twilioParam))).toBe(true); - }); - - it("emits a separate gap for EACH set field, not just the first (app SIDs)", () => { - const result = translatePurchaseToOrder( - { - phoneNumber: "+14155550001", - voiceApplicationSid: "APvoice", - smsApplicationSid: "APsms", - }, - { siteId: "1111", orderName: "Order" }, - ); - const params = result.gaps.map((g) => g.twilioParam); - expect(params).toContain("voiceApplicationSid"); - expect(params).toContain("smsApplicationSid"); - }); - - it("surfaces voiceUrl as a gap", () => { - const result = translatePurchaseToOrder( - { phoneNumber: "+14155550001", voiceUrl: "https://app.example.com/voice" }, - { siteId: "1111", orderName: "Order" }, - ); - expect(result.gaps.some((g) => /voiceUrl/i.test(g.twilioParam))).toBe(true); - }); - - it("surfaces smsUrl as a gap", () => { - const result = translatePurchaseToOrder( - { phoneNumber: "+14155550001", smsUrl: "https://app.example.com/sms" }, - { siteId: "1111", orderName: "Order" }, - ); - expect(result.gaps.some((g) => /smsUrl/i.test(g.twilioParam))).toBe(true); - }); - - it("surfaces statusCallback as a gap", () => { - const result = translatePurchaseToOrder( - { phoneNumber: "+14155550001", statusCallback: "https://app.example.com/status" }, - { siteId: "1111", orderName: "Order" }, - ); - expect(result.gaps.some((g) => /statusCallback/i.test(g.twilioParam))).toBe(true); - }); - - it("includes peerId when provided in context", () => { - const result = translatePurchaseToOrder( - { phoneNumber: "+14155550001" }, - { siteId: "1111", peerId: "2222", orderName: "Order" }, - ); - expect(result.bwOrder.peerId).toBe("2222"); - }); - - it("produces a valid BwOrderRequest shape that passes schema validation", () => { - const { bwOrder } = translatePurchaseToOrder( - { phoneNumber: "+14155550001" }, - { siteId: "1111", orderName: "Validated Order" }, - ); - expect(BwOrderRequestSchema.safeParse(bwOrder).success).toBe(true); - }); -}); diff --git a/test/readiness.test.ts b/test/readiness.test.ts new file mode 100644 index 0000000..e0230a8 --- /dev/null +++ b/test/readiness.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { checkReadiness, REQUIRED_ENV } from "../src/server/readiness.js"; + +const fullEnv = Object.fromEntries(REQUIRED_ENV.map((n) => [n, "x"])); + +describe("checkReadiness", () => { + it("reports ready when all env present and no probe requested", async () => { + const r = await checkReadiness({ env: fullEnv }); + expect(r.ready).toBe(true); + expect(r.missingEnv).toEqual([]); + expect(r.bwToken).toEqual({ probed: false, ok: true }); + }); + + it("lists missing env and is not ready", async () => { + const env = { ...fullEnv }; + delete env.BW_CLIENT_ID; + const r = await checkReadiness({ env }); + expect(r.ready).toBe(false); + expect(r.missingEnv).toContain("BW_CLIENT_ID"); + expect(r.env.find((e) => e.name === "BW_CLIENT_ID")?.present).toBe(false); + }); + + it("probes the token when a probe is supplied", async () => { + const r = await checkReadiness({ env: fullEnv, probeToken: async () => ({ ok: false, error: "401" }) }); + expect(r.ready).toBe(false); + expect(r.bwToken).toEqual({ probed: true, ok: false, error: "401" }); + }); + + it("is ready when env complete and token probe succeeds", async () => { + const r = await checkReadiness({ env: fullEnv, probeToken: async () => ({ ok: true }) }); + expect(r.ready).toBe(true); + expect(r.bwToken).toEqual({ probed: true, ok: true }); + }); + + it("treats a rejecting probe as a failed (not thrown) token check", async () => { + const r = await checkReadiness({ + env: fullEnv, + probeToken: async () => { throw new Error("network down"); }, + }); + expect(r.ready).toBe(false); + expect(r.bwToken).toEqual({ probed: true, ok: false, error: "network down" }); + }); +}); diff --git a/test/server-errors.test.ts b/test/server-errors.test.ts new file mode 100644 index 0000000..c9f9a27 --- /dev/null +++ b/test/server-errors.test.ts @@ -0,0 +1,58 @@ +// test/server-errors.test.ts +import { describe, it, expect } from "vitest"; +import { buildApp, type AdapterConfig, type AdapterDeps } from "../src/server/app.js"; + +const config: AdapterConfig = { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://adapter.test", + voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", +}; +const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); +const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64"); + +describe("structured adapter errors", () => { + it("returns the Twilio-shaped body for a missing parameter", async () => { + const app = buildApp(config, { fetchImpl: fetch, bwClient: {} as AdapterDeps["bwClient"] }); + // /bw/continue with no ?next= is a missing-param error (with valid webhook auth) + const res = await app.inject({ + method: "POST", + url: "/bw/continue", + headers: { authorization: webhookAuth }, + payload: { callId: "c1" }, + }); + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body).toMatchObject({ code: 90001, message: expect.any(String), more_info: expect.any(String), status: 400 }); + }); + + it("maps an unhandled route throw to a neutral structured 500 without leaking detail", async () => { + const bwClient = { + // createCall must succeed so the store gets seeded and we learn the real SID. + createCall: async () => ({ callId: "bw-call-1" }), + getCall: async () => { throw new Error("BW 503 SECRET-UPSTREAM-BODY"); }, + } as unknown as AdapterDeps["bwClient"]; + const app = buildApp(config, { fetchImpl: fetch, bwClient }); + + const created = await app.inject({ + method: "POST", + url: "/2010-04-01/Accounts/AC123/Calls.json", + headers: { authorization: auth }, + payload: { To: "+15551112222", From: "+15553334444", Url: "https://customer.test/voice" }, + }); + expect(created.statusCode).toBe(201); + const sid = created.json().sid as string; + + const res = await app.inject({ + method: "GET", + url: `/2010-04-01/Accounts/AC123/Calls/${sid}.json`, + headers: { authorization: auth }, + }); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ code: 90002, status: 500 }); + // Never echo the upstream error detail to the client. + expect(JSON.stringify(res.json())).not.toContain("SECRET-UPSTREAM-BODY"); + }); +}); diff --git a/test/server-numbers.test.ts b/test/server-numbers.test.ts deleted file mode 100644 index 7a32120..0000000 --- a/test/server-numbers.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { buildApp } from "../src/server/app.js"; -import type { NumbersClient } from "../src/numbers/client.js"; - -const config = { - accountSid: "AC123", - authToken: "tok", - publicBaseUrl: "https://adapter.test", - voiceUrl: "https://customer.test/voice", - numbers: { siteId: "site-1", peerId: "peer-1" }, - webhookUser: "u", - webhookPassword: "p", -}; -const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); - -function bwClientStub() { - return { - createCall: vi.fn(), - modifyCall: vi.fn(), - getCall: vi.fn(), - listRecordings: vi.fn(), - getRecording: vi.fn(), - getRecordingMedia: vi.fn(), - updateRecording: vi.fn(), - }; -} - -function makeApp(numbersClient?: Partial) { - const bwClient = bwClientStub(); - const app = buildApp(config, { - fetchImpl: fetch, - bwClient, - numbersClient: numbersClient as NumbersClient | undefined, - }); - return { app, bwClient }; -} - -describe("GET AvailablePhoneNumbers (search facade)", () => { - it("translates Twilio search params and shapes BW inventory as Twilio results", async () => { - const searchAvailable = vi.fn(async () => ({ - numbers: [ - { fullNumber: "+19195551234", rateCenter: "RTP", city: "Durham", state: "NC", lca: true }, - ], - resultCount: 1, - })); - const { app } = makeApp({ searchAvailable }); - - const res = await app.inject({ - method: "GET", - url: "/2010-04-01/Accounts/AC123/AvailablePhoneNumbers/US/Local.json?AreaCode=919&PageSize=5", - headers: { authorization: auth }, - }); - - expect(res.statusCode).toBe(200); - // BW received translated params: areaCode passthrough, pageSize → quantity. - expect(searchAvailable).toHaveBeenCalledWith( - expect.objectContaining({ areaCode: "919", quantity: 5 }), - ); - const body = res.json(); - expect(body.available_phone_numbers).toHaveLength(1); - expect(body.available_phone_numbers[0]).toMatchObject({ - phone_number: "+19195551234", - friendly_name: "(919) 555-1234", - region: "NC", - locality: "Durham", - rate_center: "RTP", - iso_country: "US", - capabilities: { voice: true }, - }); - }); - - it("401s without auth and 400s when no numbers client is configured", async () => { - const noAuth = await makeApp({ searchAvailable: vi.fn() }).app.inject({ - method: "GET", - url: "/2010-04-01/Accounts/AC123/AvailablePhoneNumbers/US/Local.json?AreaCode=919", - }); - expect(noAuth.statusCode).toBe(401); - - const unconfigured = await makeApp(undefined).app.inject({ - method: "GET", - url: "/2010-04-01/Accounts/AC123/AvailablePhoneNumbers/US/Local.json?AreaCode=919", - headers: { authorization: auth }, - }); - expect(unconfigured.statusCode).toBe(400); - expect(unconfigured.json().code).toBe(21601); - }); -}); - -describe("POST IncomingPhoneNumbers (purchase facade)", () => { - it("orders a specific number and returns a Twilio IncomingPhoneNumber resource", async () => { - const createOrder = vi.fn(async () => ({ - id: "order-1", - orderStatus: "COMPLETE" as const, - telephoneNumbers: ["+19195551234"], - })); - const getOrder = vi.fn(); - const { app } = makeApp({ createOrder, getOrder }); - - const res = await app.inject({ - method: "POST", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers.json", - headers: { authorization: auth }, - payload: { PhoneNumber: "+19195551234", VoiceUrl: "https://customer.test/voice" }, - }); - - expect(res.statusCode).toBe(201); - // Order routed to the explicit-number type, carrying the configured site. - expect(createOrder).toHaveBeenCalledWith( - expect.objectContaining({ - siteId: "site-1", - peerId: "peer-1", - existingTelephoneNumberOrderType: { telephoneNumberList: ["+19195551234"] }, - }), - ); - expect(getOrder).not.toHaveBeenCalled(); // already COMPLETE, no poll needed - const body = res.json(); - expect(body.phone_number).toBe("+19195551234"); - expect(body.sid).toMatch(/^PN[0-9a-f]{32}$/); - expect(body.voice_url).toBe("https://customer.test/voice"); - }); - - it("polls a pending order until it completes", async () => { - const createOrder = vi.fn(async () => ({ id: "order-2", orderStatus: "RECEIVED" as const })); - const getOrder = vi - .fn() - .mockResolvedValueOnce({ id: "order-2", orderStatus: "PROCESSING" }) - .mockResolvedValueOnce({ - id: "order-2", - orderStatus: "COMPLETE", - telephoneNumbers: ["+19195559999"], - }); - const { app } = makeApp({ createOrder, getOrder }); - - const res = await app.inject({ - method: "POST", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers.json", - headers: { authorization: auth }, - payload: { PhoneNumber: "+19195559999" }, - }); - - expect(res.statusCode).toBe(201); - expect(getOrder).toHaveBeenCalledTimes(2); - expect(res.json().phone_number).toBe("+19195559999"); - }); - - it("returns a Twilio-shaped error when the order fails", async () => { - const createOrder = vi.fn(async () => ({ id: "order-3", orderStatus: "FAILED" as const })); - const { app } = makeApp({ createOrder, getOrder: vi.fn() }); - - const res = await app.inject({ - method: "POST", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers.json", - headers: { authorization: auth }, - payload: { PhoneNumber: "+19195551234" }, - }); - - expect(res.statusCode).toBe(400); - expect(res.json().code).toBe(21603); - }); - - it("400s when neither PhoneNumber nor AreaCode is supplied", async () => { - const { app } = makeApp({ createOrder: vi.fn() }); - const res = await app.inject({ - method: "POST", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers.json", - headers: { authorization: auth }, - payload: { FriendlyName: "nope" }, - }); - expect(res.statusCode).toBe(400); - expect(res.json().code).toBe(21602); - }); -}); - -describe("DELETE IncomingPhoneNumbers (release facade)", () => { - async function buyNumber(app: ReturnType["app"]) { - const res = await app.inject({ - method: "POST", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers.json", - headers: { authorization: auth }, - payload: { PhoneNumber: "+19195551234" }, - }); - return res.json().sid as string; - } - - it("releases a previously purchased number via disconnect", async () => { - const disconnect = vi.fn(async () => ({ id: "d-1", orderStatus: "RECEIVED" })); - const { app } = makeApp({ - createOrder: vi.fn(async () => ({ - id: "o-1", - orderStatus: "COMPLETE" as const, - telephoneNumbers: ["+19195551234"], - })), - getOrder: vi.fn(), - disconnect, - }); - const sid = await buyNumber(app); - - const res = await app.inject({ - method: "DELETE", - url: `/2010-04-01/Accounts/AC123/IncomingPhoneNumbers/${sid}.json`, - headers: { authorization: auth }, - }); - - expect(res.statusCode).toBe(204); - expect(disconnect).toHaveBeenCalledWith( - expect.objectContaining({ phoneNumbers: ["+19195551234"] }), - ); - }); - - it("404s for a SID this instance never provisioned", async () => { - const disconnect = vi.fn(); - const { app } = makeApp({ disconnect }); - const res = await app.inject({ - method: "DELETE", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers/PNdeadbeef.json", - headers: { authorization: auth }, - }); - expect(res.statusCode).toBe(404); - expect(res.json().code).toBe(20404); - expect(disconnect).not.toHaveBeenCalled(); - }); - - it("401s without auth", async () => { - const { app } = makeApp({ disconnect: vi.fn() }); - const res = await app.inject({ - method: "DELETE", - url: "/2010-04-01/Accounts/AC123/IncomingPhoneNumbers/PNx.json", - }); - expect(res.statusCode).toBe(401); - }); -}); diff --git a/test/server-readyz.test.ts b/test/server-readyz.test.ts new file mode 100644 index 0000000..cc8052e --- /dev/null +++ b/test/server-readyz.test.ts @@ -0,0 +1,57 @@ +// test/server-readyz.test.ts +import { describe, it, expect, afterEach, vi } from "vitest"; +import { buildApp, type AdapterConfig, type AdapterDeps } from "../src/server/app.js"; +import { REQUIRED_ENV } from "../src/server/readiness.js"; + +const config: AdapterConfig = { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://adapter.test", + voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", +}; + +const deps = (over: Partial = {}): AdapterDeps => ({ + fetchImpl: fetch, + // minimal bwClient stub; /readyz never calls it (no outbound work at all) + bwClient: {} as AdapterDeps["bwClient"], + ...over, +}); + +// Hermetic env: stub every required var, restore after each test. Do NOT mutate +// process.env globally at module scope (leaks into other test files). +function stubReadyEnv() { + for (const n of REQUIRED_ENV) vi.stubEnv(n, "test-value"); +} +afterEach(() => vi.unstubAllEnvs()); + +describe("GET /readyz", () => { + it("returns 200 and a config-only report when configured", async () => { + stubReadyEnv(); + const app = buildApp(config, deps()); + const res = await app.inject({ method: "GET", url: "/readyz" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.ready).toBe(true); + // No outbound token probe over HTTP — the endpoint is side-effect-free. + expect(body.bwToken.probed).toBe(false); + }); + + it("returns 503 and lists missing env when unconfigured", async () => { + // no stubbing: required vars are absent + const app = buildApp(config, deps()); + const res = await app.inject({ method: "GET", url: "/readyz" }); + expect(res.statusCode).toBe(503); + expect(res.json().missingEnv.length).toBeGreaterThan(0); + }); + + it("never probes the token, even with ?deep=1 (no anonymous OAuth trigger)", async () => { + stubReadyEnv(); + const app = buildApp(config, deps()); + const res = await app.inject({ method: "GET", url: "/readyz?deep=1" }); + expect(res.statusCode).toBe(200); + // ?deep=1 is inert: the live probe lives only in `npm run doctor`. + expect(res.json().bwToken.probed).toBe(false); + }); +}); diff --git a/test/translate-loop.test.ts b/test/translate-loop.test.ts index eea23d1..c0f4c53 100644 --- a/test/translate-loop.test.ts +++ b/test/translate-loop.test.ts @@ -48,3 +48,35 @@ describe("Play loop", () => { expect(r.findings.some((f) => f.verb === "Play" && f.severity === "warning")).toBe(true); }); }); + +describe("Say loop is bounded (DoS guard)", () => { + it("a huge loop count clamps to 1000 and warns", () => { + const r = translateTwiml(`Hi`); + expect(r.bxml.match(/Hi<\/SpeakSentence>/g)?.length).toBe(1000); + expect( + r.findings.some((f) => f.verb === "Say" && f.severity === "warning" && /exceeds the maximum/.test(f.message)), + ).toBe(true); + }); + it("scientific-notation counts are bounded too (loop=9e9)", () => { + const r = translateTwiml(`Hi`); + expect(r.bxml.match(/Hi<\/SpeakSentence>/g)?.length).toBe(1000); + }); + it("loop exactly at the maximum is allowed without warning", () => { + const r = translateTwiml(`Hi`); + expect(r.bxml.match(/Hi<\/SpeakSentence>/g)?.length).toBe(1000); + expect(r.findings.some((f) => f.verb === "Say")).toBe(false); + }); +}); + +describe("translation output is byte-bounded", () => { + it("rejects a document whose loop expansion would exceed the byte budget", () => { + const big = "x".repeat(3000); // ~3 KB unit * 1000 = ~3 MB > 2 MB budget + const r = translateTwiml(`${big}`); + expect(r.hasErrors).toBe(true); + expect(r.findings.some((f) => f.severity === "error" && /Total output exceeds/.test(f.message))).toBe(true); + }); + it("allows a normal looped document well under the budget", () => { + const r = translateTwiml(`Hi`); + expect(r.hasErrors).toBe(false); + }); +}); diff --git a/test/webhook-auth-encoding.test.ts b/test/webhook-auth-encoding.test.ts new file mode 100644 index 0000000..cba9ec8 --- /dev/null +++ b/test/webhook-auth-encoding.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildApp } from "../src/server/app.js"; + +// Regression coverage for the percent-encoding auth-bypass: the /bw/* auth hook +// must key on the router-matched route, so a raw target that find-my-way decodes +// into /bw/... cannot reach a handler without credentials. + +const config = { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://adapter.test", + voiceUrl: "https://customer.test/voice", + webhookUser: "bw-user", + webhookPassword: "bw-pass", + allowPrivateEgress: true, +}; + +let fetchImpl: ReturnType; +let bwClient: Record>; + +function makeApp() { + fetchImpl = vi.fn(async () => new Response("", { status: 200 })); + bwClient = { + createCall: vi.fn(), + modifyCall: vi.fn(), + getCall: vi.fn(), + listRecordings: vi.fn(), + getRecording: vi.fn(), + getRecordingMedia: vi.fn(), + updateRecording: vi.fn(), + }; + return buildApp(config, { fetchImpl: fetchImpl as unknown as typeof fetch, bwClient: bwClient as never }); +} + +const goodAuth = "Basic " + Buffer.from("bw-user:bw-pass").toString("base64"); + +// Raw targets that find-my-way percent-decodes into a /bw/* route. If the auth +// gate keyed on the raw URL, none of these would start with "/bw/" and would +// slip past unauthenticated. +const ENCODED_TARGETS = [ + "/%62%77/continue?next=https://customer.test/x", // %62%77 -> bw + "/%62w/continue?next=https://customer.test/x", // partial: %62 -> b + "/b%77/continue?next=https://customer.test/x", // partial: %77 -> w + "/bw/%63ontinue?next=https://customer.test/x", // %63 -> c + "/bw/continu%65?next=https://customer.test/x", // %65 -> e + "/bw/co%6Etinue?next=https://customer.test/x", // uppercase hex %6E -> n (co+n+tinue); tests hex-digit case + "/%62%77/initiate", + "/%62%77/disconnect", + "/%62%77/recording-status?cb=https://customer.test/x", +]; + +describe("/bw/* auth is immune to percent-encoding of the path", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + for (const target of ENCODED_TARGETS) { + it(`rejects unauthenticated ${target} with 401 and no side effects`, async () => { + const app = makeApp(); + const res = await app.inject({ + method: "POST", + url: target, + payload: { eventType: "gather", callId: "c-1", digits: "1", from: "+1", to: "+1", recordingId: "r-1" }, + }); + expect(res.statusCode).toBe(401); + expect(res.headers["www-authenticate"]).toContain("Basic"); + // No customer fetch and no Bandwidth API call may occur on an unauthenticated request. + expect(fetchImpl).not.toHaveBeenCalled(); + for (const [, fn] of Object.entries(bwClient)) expect(fn).not.toHaveBeenCalled(); + }); + } + + it("still serves the canonical route with valid credentials", async () => { + const app = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/bw/continue?next=https://customer.test/x", + headers: { authorization: goodAuth }, + payload: { eventType: "gather", callId: "c-1", digits: "1" }, + }); + expect(res.statusCode).toBe(200); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("still serves the encoded route WITH valid credentials (decode is honored, only auth was the gate)", async () => { + const app = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/%62%77/continue?next=https://customer.test/x", + headers: { authorization: goodAuth }, + payload: { eventType: "gather", callId: "c-1", digits: "1" }, + }); + expect(res.statusCode).toBe(200); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("does not 401 an unmatched route (guard falls through when routeOptions.url is undefined)", async () => { + const app = makeApp(); + const res = await app.inject({ method: "POST", url: "/%62%77/not-a-route" }); + expect(res.statusCode).toBe(404); + }); + + it("authenticates before body parsing: malformed JSON without creds is 401, not 400", async () => { + const app = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/bw/continue?next=https://customer.test/x", + headers: { "content-type": "application/json" }, + payload: "{ this is not valid json", + }); + expect(res.statusCode).toBe(401); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("with valid creds, the same malformed JSON reaches the parser and returns 400", async () => { + const app = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/bw/continue?next=https://customer.test/x", + headers: { authorization: goodAuth, "content-type": "application/json" }, + payload: "{ this is not valid json", + }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/test/webhook-id-validation.test.ts b/test/webhook-id-validation.test.ts new file mode 100644 index 0000000..48680ce --- /dev/null +++ b/test/webhook-id-validation.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildApp } from "../src/server/app.js"; + +// Ingress defense-in-depth: /bw/* event ids are validated at the boundary (after +// Basic auth) so a malformed id never enters the call store, gets hashed into a +// SID, or flows toward the upstream API. + +const config = { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://adapter.test", + voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", + allowPrivateEgress: true, +}; +const auth = "Basic " + Buffer.from("u:p").toString("base64"); + +function makeApp() { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + const bwClient = { + createCall: vi.fn(), + modifyCall: vi.fn(), + getCall: vi.fn(), + listRecordings: vi.fn(), + getRecording: vi.fn(), + getRecordingMedia: vi.fn(), + updateRecording: vi.fn(), + }; + return { app: buildApp(config, { fetchImpl, bwClient }), fetchImpl }; +} + +describe("/bw/* ingress id validation", () => { + it("400s /bw/initiate with a path-traversal callId (and does not fetch)", async () => { + const { app, fetchImpl } = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/bw/initiate", + headers: { authorization: auth }, + payload: { eventType: "initiate", callId: "../../x" }, + }); + expect(res.statusCode).toBe(400); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("400s /bw/recording-status with an unsafe recordingId", async () => { + const { app } = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/bw/recording-status?cb=https://customer.test/x", + headers: { authorization: auth }, + payload: { callId: "c-1", recordingId: ".." }, + }); + expect(res.statusCode).toBe(400); + }); + + it("accepts a valid callId", async () => { + const { app } = makeApp(); + const res = await app.inject({ + method: "POST", + url: "/bw/initiate", + headers: { authorization: auth }, + payload: { eventType: "initiate", callId: "c-1" }, + }); + expect(res.statusCode).toBe(200); + }); +}); + +describe("/bw/* ingress id validation — more cases", () => { + const cfg = { accountSid: "AC123", authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", webhookUser: "u", webhookPassword: "p", allowPrivateEgress: true }; + const authHdr = "Basic " + Buffer.from("u:p").toString("base64"); + function app() { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + const bwClient = { createCall: vi.fn(), modifyCall: vi.fn(), getCall: vi.fn(), listRecordings: vi.fn(), getRecording: vi.fn(), getRecordingMedia: vi.fn(), updateRecording: vi.fn() }; + return buildApp(cfg, { fetchImpl, bwClient }); + } + it("400s /bw/continue with a bad callId", async () => { + const res = await app().inject({ method: "POST", url: "/bw/continue?next=https://customer.test/x", headers: { authorization: authHdr }, payload: { eventType: "gather", callId: "../x", digits: "1" } }); + expect(res.statusCode).toBe(400); + }); + it("400s /bw/disconnect with a bad callId", async () => { + const res = await app().inject({ method: "POST", url: "/bw/disconnect", headers: { authorization: authHdr }, payload: { eventType: "disconnect", callId: "a/b" } }); + expect(res.statusCode).toBe(400); + }); + it("400s /bw/recording-status with a bad callId", async () => { + const res = await app().inject({ method: "POST", url: "/bw/recording-status?cb=https://customer.test/x", headers: { authorization: authHdr }, payload: { callId: "..", recordingId: "r-1" } }); + expect(res.statusCode).toBe(400); + }); + it("400s a null JSON body (does not 500)", async () => { + const res = await app().inject({ method: "POST", url: "/bw/initiate", headers: { authorization: authHdr, "content-type": "application/json" }, payload: "null" }); + expect(res.statusCode).toBe(400); + }); +});