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..9291480 --- /dev/null +++ b/src/server/capture.ts @@ -0,0 +1,30 @@ +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. + * + * 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, 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, { mode: 0o600 }); + 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..1a228a3 --- /dev/null +++ b/test/capture.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +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"; + +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 verbatim (no added bytes) to a file under the capture dir", () => { + const file = captureTwiml(dir, 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", () => { + 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..522dbbd --- /dev/null +++ b/test/server-capture.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +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"; + +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("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`, + }); + await initiate(app, "c-3"); + expect(readdirSync(dir)).toHaveLength(0); + }); +});