From 1c7a23633d1ff70a8a306f34bb4e91e1c8b454a4 Mon Sep 17 00:00:00 2001 From: Kush Date: Fri, 17 Jul 2026 12:01:21 -0700 Subject: [PATCH 1/2] feat(server): capture customer TwiML for standalone BXML generation Add opt-in ADAPTER_CAPTURE_DIR: when set, the live adapter persists each customer TwiML response verbatim (content-addressed, deduped) as it proxies a call. Feeding that dir to `npm run generate` turns the paths a test call actually exercised into standalone BXML. This closes the previously documented-but-unimplemented SDK path: apps that build TwiML at runtime have no static markup to transpile, so generate could only flag them for manual capture. The adapter now produces the capture files generate ingests. Filenames derive from a content hash, never request input, matching the existing filesystem-addressing guardrail. --- AGENTS.md | 7 +++- README.md | 1 + src/server/app.ts | 13 ++++++ src/server/capture.ts | 24 +++++++++++ src/server/index.ts | 1 + test/capture.test.ts | 47 ++++++++++++++++++++++ test/server-capture.test.ts | 80 +++++++++++++++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/server/capture.ts create mode 100644 test/capture.test.ts create mode 100644 test/server-capture.test.ts diff --git a/AGENTS.md b/AGENTS.md index cd6aea5..ef430fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,12 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess. (`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. + there is no static markup to transpile. Run the adapter with + `ADAPTER_CAPTURE_DIR=` and place a few test calls; each customer TwiML + response is written there verbatim (content-addressed, deduped). Then + `npm run generate -- ` produces standalone BXML for the paths those + calls exercised. Capture only covers exercised paths — branches you never dial + won't appear, so drive every flow you care about (or port the rest by hand). ## Errors diff --git a/README.md b/README.md index d1bf433..b801244 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ npm run doctor # readiness check — see AGENTS.md Phase 5 | `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), provisioned via `band` — see [`AGENTS.md`](AGENTS.md) | | `BW_ENVIRONMENT` | Optional — `test` targets BW's test hosts; defaults to `prod` | +| `ADAPTER_CAPTURE_DIR` | Optional — dir to persist each customer TwiML response (verbatim, content-addressed) so `generate` can turn the paths a test call exercised into standalone BXML. Essential for SDK-built apps with no static TwiML to transpile. Off by default | | `ADAPTER_LOG=1` | Optional — enable request logging | Run `npm run doctor` (or `GET /readyz?deep=1` once the server is up) to confirm diff --git a/src/server/app.ts b/src/server/app.ts index b80a6e3..c37d7de 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -23,6 +23,7 @@ import { CallStore, type CallRecord } from "./call-store.js"; import { isSafeBwId, type BwClient } from "../bw/client.js"; import { checkReadiness } from "./readiness.js"; import { safeEqual } from "./safe-equal.js"; +import { captureTwiml } from "./capture.js"; export interface AdapterConfig { accountSid: string; @@ -36,6 +37,8 @@ export interface AdapterConfig { /** Basic-auth credentials Bandwidth presents on inbound webhooks (must match the app's CallbackCreds). */ webhookUser: string; webhookPassword: string; + /** Opt-in dir to persist each customer TwiML response for later `generate`. Undefined → no capture. */ + captureDir?: string; } export interface AdapterDeps { @@ -155,6 +158,16 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta } throw err; } + // Capture the raw customer TwiML (URLs verbatim, pre-rewrite) before we + // translate it — this is exactly what `generate` ingests to produce + // standalone BXML for the paths a test call exercised. + if (config.captureDir) { + try { + captureTwiml(config.captureDir, twiml); + } catch (err) { + app.log.error({ err }, "twiml capture failed"); + } + } const translateStart = performance.now(); const result = translateTwiml(twiml, { rewriteUrl: rewriter(customerUrl), diff --git a/src/server/capture.ts b/src/server/capture.ts new file mode 100644 index 0000000..79526de --- /dev/null +++ b/src/server/capture.ts @@ -0,0 +1,24 @@ +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +/** + * Persist a raw TwiML response the adapter fetched from the customer app, so a + * later `npm run generate` over the capture dir can turn the paths a test call + * actually exercised into standalone BXML. + * + * The filename is a content hash — never request input — so identical responses + * dedupe to one file and untrusted input never participates in filesystem + * addressing (same guardrail as scripts/capture-server.mjs). We store the raw + * TwiML verbatim (customer URLs intact, no proxy rewrite), which is exactly what + * `generate` ingests. + * + * Returns the path written, or the existing path if this TwiML was already seen. + */ +export function captureTwiml(dir: string, twiml: string): string { + mkdirSync(dir, { recursive: true }); + const hash = createHash("sha256").update(twiml).digest("hex").slice(0, 16); + const file = join(dir, `${hash}.xml`); + if (!existsSync(file)) writeFileSync(file, `${twiml}\n`); + return file; +} diff --git a/src/server/index.ts b/src/server/index.ts index ec3a456..87f916f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -19,6 +19,7 @@ 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), + captureDir: process.env.ADAPTER_CAPTURE_DIR, }, { fetchImpl: fetch, diff --git a/test/capture.test.ts b/test/capture.test.ts new file mode 100644 index 0000000..bc6b0f4 --- /dev/null +++ b/test/capture.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { captureTwiml } from "../src/server/capture.js"; + +const TWIML = `Hello`; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "capture-test-")); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("captureTwiml", () => { + it("writes the raw TwiML to a file under the capture dir", () => { + const file = captureTwiml(dir, TWIML); + expect(readFileSync(file, "utf8")).toContain(TWIML); + }); + + it("names the file from the content hash only, never request input", () => { + const file = captureTwiml(dir, TWIML); + // A content-addressed hex name — no path, params, or caller-supplied string. + expect(file.split("/").pop()).toMatch(/^[0-9a-f]{16}\.xml$/); + }); + + it("dedupes identical TwiML to a single file", () => { + captureTwiml(dir, TWIML); + captureTwiml(dir, TWIML); + captureTwiml(dir, TWIML); + expect(readdirSync(dir)).toHaveLength(1); + }); + + it("writes distinct files for distinct TwiML", () => { + captureTwiml(dir, TWIML); + captureTwiml(dir, `Goodbye`); + expect(readdirSync(dir)).toHaveLength(2); + }); + + it("creates the capture dir if it does not exist", () => { + const nested = join(dir, "does", "not", "exist"); + const file = captureTwiml(nested, TWIML); + expect(readFileSync(file, "utf8")).toContain(TWIML); + }); +}); diff --git a/test/server-capture.test.ts b/test/server-capture.test.ts new file mode 100644 index 0000000..8c960b9 --- /dev/null +++ b/test/server-capture.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/server/app.js"; + +const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64"); + +function baseConfig(captureDir?: string) { + return { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://adapter.test", + voiceUrl: "https://customer.test/voice", + allowPrivateEgress: true, + webhookUser: "u", + webhookPassword: "p", + captureDir, + }; +} + +function appWithTwiml(config: ReturnType, twimlByUrl: Record) { + const fetchImpl = vi.fn(async (url: any) => { + const twiml = twimlByUrl[String(url)]; + if (!twiml) return new Response("not found", { status: 404 }); + return new Response(twiml, { 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(config, { fetchImpl, bwClient }); +} + +async function initiate(app: ReturnType, callId: string) { + return app.inject({ + method: "POST", + url: "/bw/initiate", + headers: { authorization: webhookAuth }, + payload: { eventType: "initiate", callId, from: "+1", to: "+2", direction: "inbound" }, + }); +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "server-capture-")); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("live capture", () => { + it("writes the customer's raw TwiML (not the translated BXML) when captureDir is set", async () => { + const raw = `Hello`; + const app = appWithTwiml(baseConfig(dir), { "https://customer.test/voice": raw }); + await initiate(app, "c-1"); + + const files = readdirSync(dir); + expect(files).toHaveLength(1); + const content = readFileSync(join(dir, files[0]), "utf8"); + expect(content).toContain(raw); + // The captured artifact is the source TwiML, not the proxy's BXML output. + expect(content).not.toContain("SpeakSentence"); + }); + + it("preserves the customer's URLs verbatim (no proxy rewrite) so generate can reuse them", async () => { + const raw = `Press 1`; + const app = appWithTwiml(baseConfig(dir), { "https://customer.test/voice": raw }); + await initiate(app, "c-2"); + + const content = readFileSync(join(dir, readdirSync(dir)[0]), "utf8"); + expect(content).toContain(`action="/menu"`); + expect(content).not.toContain("/bw/continue"); + }); + + it("writes nothing when captureDir is unset", async () => { + const app = appWithTwiml(baseConfig(undefined), { + "https://customer.test/voice": `Hello`, + }); + await initiate(app, "c-3"); + expect(readdirSync(dir)).toHaveLength(0); + }); +}); From 83c5c601c9e13e9d1cf30197bcd7a070e2872ff5 Mon Sep 17 00:00:00 2001 From: Kush Date: Fri, 17 Jul 2026 12:07:38 -0700 Subject: [PATCH 2/2] fix(server): harden TwiML capture per review - Write captures private-by-default (dir 0700, file 0600); raw TwiML can carry tokenized callback URLs. - Store TwiML verbatim (drop appended newline) so file content matches the hashed bytes and the stated contract. - Document the deliberate synchronous-I/O tradeoff (opt-in, test-volume, local disk; serial writes keep dedup race-free). - Tests: assert verbatim content + private modes; add a regression test proving a failed capture still serves the call. --- src/server/capture.ts | 10 ++++++++-- test/capture.test.ts | 12 +++++++++--- test/server-capture.test.ts | 13 ++++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/server/capture.ts b/src/server/capture.ts index 79526de..9291480 100644 --- a/src/server/capture.ts +++ b/src/server/capture.ts @@ -13,12 +13,18 @@ import { join } from "node:path"; * TwiML verbatim (customer URLs intact, no proxy rewrite), which is exactly what * `generate` ingests. * + * Written private-by-default (dir 0700, file 0600): raw TwiML can carry callback + * URLs with embedded tokens, so captures are not world-readable. Synchronous I/O + * is deliberate — this is an opt-in, test-call-volume eval feature on local disk, + * and serial writes keep the dedup check (existsSync → writeFileSync) race-free + * within the process; we accept the event-loop cost over async write races. + * * Returns the path written, or the existing path if this TwiML was already seen. */ export function captureTwiml(dir: string, twiml: string): string { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); const hash = createHash("sha256").update(twiml).digest("hex").slice(0, 16); const file = join(dir, `${hash}.xml`); - if (!existsSync(file)) writeFileSync(file, `${twiml}\n`); + if (!existsSync(file)) writeFileSync(file, twiml, { mode: 0o600 }); return file; } diff --git a/test/capture.test.ts b/test/capture.test.ts index bc6b0f4..1a228a3 100644 --- a/test/capture.test.ts +++ b/test/capture.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { captureTwiml } from "../src/server/capture.js"; @@ -15,9 +15,15 @@ afterEach(() => { }); describe("captureTwiml", () => { - it("writes the raw TwiML to a file under the capture dir", () => { + it("writes the raw TwiML verbatim (no added bytes) to a file under the capture dir", () => { const file = captureTwiml(dir, TWIML); - expect(readFileSync(file, "utf8")).toContain(TWIML); + expect(readFileSync(file, "utf8")).toBe(TWIML); + }); + + it("writes captures private (0600 file, 0700 dir)", () => { + const file = captureTwiml(dir, TWIML); + expect(statSync(file).mode & 0o777).toBe(0o600); + expect(statSync(dir).mode & 0o777).toBe(0o700); }); it("names the file from the content hash only, never request input", () => { diff --git a/test/server-capture.test.ts b/test/server-capture.test.ts index 8c960b9..522dbbd 100644 --- a/test/server-capture.test.ts +++ b/test/server-capture.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildApp } from "../src/server/app.js"; @@ -70,6 +70,17 @@ describe("live capture", () => { expect(content).not.toContain("/bw/continue"); }); + it("still serves the call when capture fails (unwritable target)", async () => { + // Parent path is a file, so mkdir under it throws — capture must not fail the call. + writeFileSync(join(dir, "blocker"), "x"); + const app = appWithTwiml(baseConfig(join(dir, "blocker", "sub")), { + "https://customer.test/voice": `Hello`, + }); + const res = await initiate(app, "c-4"); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("Hello"); + }); + it("writes nothing when captureDir is unset", async () => { const app = appWithTwiml(baseConfig(undefined), { "https://customer.test/voice": `Hello`,