diff --git a/.changeset/browser-pay-interceptor.md b/.changeset/browser-pay-interceptor.md new file mode 100644 index 000000000..57ecf5422 --- /dev/null +++ b/.changeset/browser-pay-interceptor.md @@ -0,0 +1,7 @@ +--- +"@sapiom/browser-pay": minor +--- + +Add `@sapiom/browser-pay`. It pauses a held placeholder over CDP and posts David's relay body to `POST /v1/transactions/{id}/payment/relay`. The idempotency key is sha256 of method, URL, and body. + +The placeholder is `{ number, injectionRef }`. `PayLogger` only has `warn`. `failed.reason` is a closed set, and a match seen after detach is `detached`. `HttpRelayOptions` has no `timeoutMs`. `AttachOptions.timeoutMs` is the relay deadline. A non-2xx relay HTTP status is a transport failure and is not read as an outcome. diff --git a/.github/labeler.yml b/.github/labeler.yml index f2b204fa0..1dc4f185e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -17,6 +17,7 @@ - any-glob-to-any-file: - packages/core/** - packages/analytics-core/** + - packages/browser-pay/** "area: integrations": - changed-files: diff --git a/README.md b/README.md index 704fc9dee..165ea84a5 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ claude mcp add sapiom-dev -- npx -y @sapiom/mcp - **[The two Sapiom MCP servers](./docs/mcp-servers.md)** — local dev vs. remote capabilities - **[@sapiom/agent](./packages/agent/README.md)** — authoring contract - **[@sapiom/tools](./packages/tools/README.md)** — capability client +- **[@sapiom/browser-pay](./packages/browser-pay/README.md)** — browser payment interceptor - **[@sapiom/cli](./packages/cli/README.md)** — command line - **[@sapiom/mcp](./packages/mcp/README.md)** — developer MCP - **[Agent Studio](./packages/harness/README.md)** — local app for building with your coding agent @@ -168,6 +169,8 @@ claude mcp add sapiom-dev -- npx -y @sapiom/mcp @sapiom/tools Typed capability client (sandboxes, repos, models, …) +@sapiom/browser-pay CDP payment interceptor (placeholder card to relay) + @sapiom/harness Agent Studio (local web app; launched via @sapiom/agent-studio) ↑ └── @sapiom/harness-desktop Sapiom Studio desktop app (Electron host, ships as installers) diff --git a/packages/browser-pay/.eslintrc.js b/packages/browser-pay/.eslintrc.js new file mode 100644 index 000000000..640f91428 --- /dev/null +++ b/packages/browser-pay/.eslintrc.js @@ -0,0 +1,20 @@ +module.exports = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: "tsconfig.examples.json", + tsconfigRootDir: __dirname, + sourceType: "module", + }, + plugins: ["@typescript-eslint"], + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: [".eslintrc.js", "dist", "node_modules", "jest.config.js"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + }, +}; diff --git a/packages/browser-pay/CHANGELOG.md b/packages/browser-pay/CHANGELOG.md new file mode 100644 index 000000000..cc6ea7ee1 --- /dev/null +++ b/packages/browser-pay/CHANGELOG.md @@ -0,0 +1,5 @@ +# @sapiom/browser-pay + +## 0.0.0 + +Initial package. The first published version is cut by Changesets. Pauses a held placeholder over CDP and relays it to `POST /v1/transactions/{id}/payment/relay`. diff --git a/packages/browser-pay/LICENSE b/packages/browser-pay/LICENSE new file mode 100644 index 000000000..1fe4bc4bb --- /dev/null +++ b/packages/browser-pay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sapiom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md new file mode 100644 index 000000000..855a2fa6a --- /dev/null +++ b/packages/browser-pay/README.md @@ -0,0 +1,98 @@ +# @sapiom/browser-pay + +Browser-side payment interceptor. The agent types a governance placeholder into checkout. This package pauses that request over CDP and hands it to David's relay. The browser is fulfilled with the processor's response, or the request fails. The placeholder is never continued. + +## Sequence + +1. The agent creates a checkout (UCP `create_checkout`). +2. Governance arms a placeholder and returns it with `continue_url`. +3. The agent opens a browser session and drives checkout, typing the placeholder. +4. `attachInterceptor` pauses the card request, including inside a payment iframe. +5. A match is `POST /v1/transactions/{transactionId}/payment/relay`. +6. The page receives the processor status, headers, and body. A refusal, an unknown outcome, or a relay error fails the paused request. + +## Relay contract + +`POST /v1/transactions/{transactionId}/payment/relay` with `x-api-key`. `transactionId` is only in the path. The JSON body is: + +```json +{ + "injectionRef": "inj_…", + "profileId": "shopify-card-vault", + "pageOrigin": "https://shop.example", + "request": { + "method": "POST", + "url": "https://checkout.pci.shopifyinc.com/sessions", + "headers": { "content-type": "application/json" }, + "body": "" + }, + "idempotencyKey": "" +} +``` + +`pageOrigin` is the top-level page origin. `headers` is a lowercased string map. This client sends only `accept`, `content-type`, and `shopify-identification-signature`, and drops a value longer than 8192 characters. `idempotencyKey` is sha256 of method, URL, and body, separated by NUL. It is not sent as a header. Shopify's card iframe retries share one key. Do not log or persist `request.headers` or `request.body`. + +Success: + +```json +{ + "outcome": "relayed", + "response": { + "status": 200, + "headers": { "content-type": "application/json" }, + "body": "" + } +} +``` + +`response.body` is the string the page should read. `response.headers` is a string map. Hop-by-hop headers are dropped because CDP sends the body bytes itself. + +Refusal, nothing forwarded: + +```json +{ "outcome": "refused", "reason": "already_relayed" } +``` + +The reason string is passed through. Reasons David returns include `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `unknown_injection`, `injection_expired`, `payment_released`, `relay_in_progress`, `payment_not_authorized`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. + +No answer after the send: + +```json +{ "outcome": "unknown" } +``` + +The injection is consumed. The paused request fails and the same body is not sent again. + +A transport error, a redirect, a non-2xx HTTP status, or an unreadable body fails the paused browser request. David's relay returns HTTP 200 for every outcome, so a 401 or 500 is not read as `unknown` or `refused`. It is not fulfilled with the error JSON. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`. + +## What an agent does + +```typescript +import { attachInterceptor, httpPaymentRelay } from "@sapiom/browser-pay"; + +const handle = await attachInterceptor(cdpUrl, { + relay: httpPaymentRelay({ + baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", + apiKey: process.env.SAPIOM_API_KEY, + transactionId, + }), + profileId: "shopify-card-vault", + placeholder, +}); +``` + +Call `handle.on` after `attachInterceptor` resolves. Events emitted while attach was running, including a target that could not arm Fetch, are delivered to that first subscriber. + +A request is relayed when the held placeholder number appears in the URL or body. An exact match counts, and so do digits split by spaces or dashes, including the URL-encoded and JSON-escaped forms of those separators. `placeholder` is `{ number, injectionRef }`. The backend's `checkPlaceholderUse` decides whether the number is valid. A paused request that does not hold the placeholder is continued. + +A relayed response fails the paused request only when that response contains the held placeholder number. + +Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the only deadline on that call. It defaults to 20 seconds. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. + +Captcha widgets, extension pages, and workers are not paused. A blank iframe, including `about:blank`, stays paused so a navigation onto the card URL cannot slip through. A placeholder that shows up on any other page or iframe is relayed. + +Child iframes are auto-attached with `waitForDebuggerOnStart`. `Fetch.enable` runs on each child session before it is resumed. An iframe that already exists is attached before `attachInterceptor` resolves. A paused request whose CDP session was never attached is failed. + +`detach()` fails in-flight paused requests before it disables Fetch. A process crash or a dropped CDP socket can still let Chrome continue those requests. WebSocket frames, client-side-encrypted card payloads, and worklet targets are not visible to this check. + +`examples/attach.ts` is the agent-side script. It is not published. diff --git a/packages/browser-pay/examples/attach.ts b/packages/browser-pay/examples/attach.ts new file mode 100644 index 000000000..8ee27f2a0 --- /dev/null +++ b/packages/browser-pay/examples/attach.ts @@ -0,0 +1,97 @@ +/** + * What an agent platform runs: open a Sapiom browser (captcha solving is the + * capability's own flag), attach the interceptor to the returned CDP URL, and + * let the agent's own driver use that same URL. + * + * A raw CDP URL works the same way: `attachInterceptor(process.env.CDP_URL, …)`. + * + * SAPIOM_API_KEY=… TRANSACTION_ID=… INJECTION_REF=… PROFILE_ID=shopify-card-vault \ + * PLACEHOLDER_NUMBER=… \ + * npx tsx examples/attach.ts + */ +import { randomUUID } from "node:crypto"; +import { createClient } from "@sapiom/tools"; +import { + attachInterceptor, + httpPaymentRelay, + type InterceptorHandle, +} from "../src/index.js"; + +const log = { + info(message: string, fields?: Readonly>) { + process.stdout.write(`${message} ${JSON.stringify(fields ?? {})}\n`); + }, + warn(message: string, fields?: Readonly>) { + process.stderr.write(`${message} ${JSON.stringify(fields ?? {})}\n`); + }, +}; + +async function main(): Promise { + const apiKey = process.env.SAPIOM_API_KEY; + if (!apiKey) throw new Error("SAPIOM_API_KEY is required"); + const transactionId = process.env.TRANSACTION_ID; + const injectionRef = process.env.INJECTION_REF; + const number = process.env.PLACEHOLDER_NUMBER; + const profileId = process.env.PROFILE_ID; + if (!transactionId || !injectionRef || !number || !profileId) { + throw new Error( + "TRANSACTION_ID, INJECTION_REF, PROFILE_ID, and PLACEHOLDER_NUMBER are required", + ); + } + + const sapiom = createClient({ apiKey }); + const session = await sapiom.browserAutomation.sessions.createManaged({ + idempotencyKey: randomUUID(), + recording: false, + captchaSolver: true, + proxy: true, + stealth: true, + headless: false, + }); + let handle: InterceptorHandle | undefined; + const shutdown = async () => { + await handle?.detach()?.catch(() => {}); + await sapiom.browserAutomation.sessions + .closeManaged(session.sessionId) + .catch(() => {}); + await sapiom.shutdown().catch(() => {}); + }; + try { + handle = await attachInterceptor(session.cdpUrl, { + relay: httpPaymentRelay({ + baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", + apiKey, + transactionId, + }), + profileId, + placeholder: { + number, + injectionRef, + }, + logger: log, + }); + const last4 = number.replace(/\D/g, "").slice(-4); + log.info("interceptor attached", { last4 }); + handle.on((event) => { + if (event.type === "relayed") + log.info("relayed", { status: event.status }); + else if (event.type === "refused") { + log.warn("refused", { reason: event.reason }); + } else if (event.type === "failed") { + log.warn("failed", { reason: event.reason }); + } + }); + process.on("SIGINT", () => { + void shutdown().finally(() => process.exit(0)); + }); + } catch (error) { + await shutdown(); + throw error; + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "attach failed"; + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/packages/browser-pay/jest.config.js b/packages/browser-pay/jest.config.js new file mode 100644 index 000000000..88d90cdf5 --- /dev/null +++ b/packages/browser-pay/jest.config.js @@ -0,0 +1,20 @@ +module.exports = { + testEnvironment: "node", + roots: ["/src", "/examples"], + testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"], + testPathIgnorePatterns: ["/node_modules/", "/dist/"], + transform: { + "^.+\\.tsx?$": ["ts-jest", {}], + }, + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + collectCoverageFrom: [ + "src/**/*.ts", + "!src/**/*.d.ts", + "!src/**/*.test.ts", + "!src/**/*.spec.ts", + "!src/index.ts", + ], +}; diff --git a/packages/browser-pay/package.json b/packages/browser-pay/package.json new file mode 100644 index 000000000..2b5699236 --- /dev/null +++ b/packages/browser-pay/package.json @@ -0,0 +1,76 @@ +{ + "name": "@sapiom/browser-pay", + "version": "0.0.0", + "description": "Browser-side payment interceptor. Pause a placeholder card request over CDP and relay it.", + "keywords": [ + "sapiom", + "sdk", + "payments", + "cdp", + "checkout" + ], + "homepage": "https://github.com/sapiom/sapiom-js/tree/main/packages/browser-pay#readme", + "bugs": { + "url": "https://github.com/sapiom/sapiom-js/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/sapiom/sapiom-js.git", + "directory": "packages/browser-pay" + }, + "license": "MIT", + "author": "Sapiom", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "types": "./dist/cjs/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } + }, + "files": [ + "dist", + "!dist/**/*.tsbuildinfo", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "npm run build:cjs && npm run build:esm && npm run build:esm-pkg", + "build:cjs": "tsc --project tsconfig.cjs.json", + "build:esm": "tsc --project tsconfig.esm.json", + "build:esm-pkg": "node -e \"require('fs').writeFileSync('dist/esm/package.json',JSON.stringify({type:'module'}))\"", + "clean": "rm -rf dist *.tsbuildinfo", + "dev": "tsc --watch --project tsconfig.cjs.json", + "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.examples.json", + "lint": "eslint src examples --ext .ts", + "format": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\"", + "prepublishOnly": "pnpm build" + }, + "devDependencies": { + "@sapiom/tools": "workspace:*", + "@types/jest": "^29.5.14", + "@types/node": "^20.11.30", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "jest": "^29.7.0", + "playwright": "^1.61.0", + "prettier": "^3.2.5", + "ts-jest": "^29.1.2", + "tsx": "^4.20.3", + "typescript": "^5.4.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false +} diff --git a/packages/browser-pay/src/cdp.test.ts b/packages/browser-pay/src/cdp.test.ts new file mode 100644 index 000000000..ab4b7d331 --- /dev/null +++ b/packages/browser-pay/src/cdp.test.ts @@ -0,0 +1,134 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import { CdpConnection } from "./cdp"; + +function acceptKey(key: string): string { + return crypto + .createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); +} + +function serverFrame(payload: Buffer): Buffer { + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); +} + +function listen(): Promise<{ + url: string; + onceSocket: Promise; + close: () => Promise; +}> { + let capture: (socket: Duplex) => void = () => {}; + let active: Duplex | undefined; + const onceSocket = new Promise((resolve) => { + capture = (socket) => { + active = socket; + resolve(socket); + }; + }); + const server = http.createServer(); + server.on("upgrade", (request, socket) => { + const key = String(request.headers["sec-websocket-key"] ?? ""); + socket.on("error", () => {}); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`, + ); + capture(socket); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve({ + url: `ws://127.0.0.1:${address.port}`, + onceSocket, + close: () => + new Promise((done) => { + active?.destroy(); + server.close(() => done()); + }), + }); + }); + }); +} + +describe("CdpConnection", () => { + it("reassembles a result that arrives one byte at a time", async () => { + const stub = await listen(); + try { + const connection = await CdpConnection.connect(stub.url); + const socket = await stub.onceSocket; + const pending = connection.send("Runtime.evaluate", {}); + const frame = serverFrame( + Buffer.from(JSON.stringify({ id: 1, result: { value: 7 } })), + ); + for (const byte of frame) { + socket.write(Buffer.from([byte])); + } + await expect(pending).resolves.toEqual({ value: 7 }); + connection.close(); + } finally { + await stub.close(); + } + }); + + it("rejects pending calls when Chrome sends a close frame, and later sends reject", async () => { + const stub = await listen(); + try { + const connection = await CdpConnection.connect(stub.url); + const socket = await stub.onceSocket; + const pending = connection.send("Fetch.enable", {}, undefined, 30_000); + socket.write(Buffer.from([0x88, 0x00])); + await expect(pending).rejects.toThrow(/closed/); + await expect( + connection.send("Runtime.evaluate", {}, undefined, 30_000), + ).rejects.toThrow(/closed/); + } finally { + await stub.close(); + } + }); + + it("rejects a pending call when the socket errors without a close frame", async () => { + const stub = await listen(); + try { + const connection = await CdpConnection.connect(stub.url); + const socket = await stub.onceSocket; + const pending = connection.send("Fetch.enable", {}, undefined, 30_000); + socket.destroy(new Error("reset")); + await expect(pending).rejects.toThrow(/reset|CDP/); + } finally { + await stub.close(); + } + }); + + it("rejects pending calls when a frame claims to exceed the size cap", async () => { + const stub = await listen(); + try { + const connection = await CdpConnection.connect(stub.url); + const socket = await stub.onceSocket; + const pending = connection.send( + "Fetch.getRequestPostData", + {}, + undefined, + 30_000, + ); + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(32 * 1024 * 1024), 2); + socket.write(header); + await expect(pending).rejects.toThrow(/size cap|CDP/); + connection.close(); + } finally { + await stub.close(); + } + }); +}); diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts new file mode 100644 index 000000000..b18f437cc --- /dev/null +++ b/packages/browser-pay/src/cdp.ts @@ -0,0 +1,426 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import https from "node:https"; +import type { Duplex } from "node:stream"; +import { isRecord } from "./guards.js"; + +/** + * A small CDP client so the interceptor can open its own connection to a + * browser websocket. It deliberately does not depend on Playwright or + * Puppeteer: the agent's driver is a second CDP client, and this one has to + * attach beside it. + */ + +export interface CdpEvent { + method: string; + params: Record; + sessionId?: string; +} + +type Pending = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +}; + +function parseCdpMessage(value: unknown): + | { + id?: number; + method?: string; + params: Record; + sessionId?: string; + result?: unknown; + errorMessage?: string; + } + | undefined { + if (!isRecord(value)) return undefined; + const id = typeof value.id === "number" ? value.id : undefined; + const method = typeof value.method === "string" ? value.method : undefined; + if (id === undefined && method === undefined) return undefined; + const params = isRecord(value.params) ? value.params : {}; + const sessionId = + typeof value.sessionId === "string" ? value.sessionId : undefined; + let errorMessage: string | undefined; + if (isRecord(value.error) && typeof value.error.message === "string") { + errorMessage = value.error.message; + } else if (value.error) { + errorMessage = "CDP call failed"; + } + return { + ...(id !== undefined ? { id } : {}), + ...(method !== undefined ? { method } : {}), + params, + ...(sessionId ? { sessionId } : {}), + result: value.result, + ...(errorMessage ? { errorMessage } : {}), + }; +} + +/** One CDP payload, including a large getRequestPostData body, stays under this. */ +const MAX_FRAME_BYTES = 16 * 1024 * 1024; + +class WebSocketClient { + private chunks: Buffer[] = []; + private buffered = 0; + private fragments: Buffer[] = []; + private fragmentBytes = 0; + private closed = false; + onmessage: (data: string) => void = () => {}; + onclose: () => void = () => {}; + onerror: (error: Error) => void = () => {}; + private notified = false; + + private constructor(private readonly socket: Duplex) { + socket.on("data", (chunk: Buffer) => { + this.append(chunk); + }); + socket.on("close", () => { + this.closed = true; + this.notifyClose(); + }); + socket.on("error", (error: Error) => { + this.fail( + error instanceof Error ? error : new Error("CDP websocket error"), + ); + }); + } + + static connect(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const target = new URL(url); + const key = crypto.randomBytes(16).toString("base64"); + const transport = target.protocol === "wss:" ? https : http; + const request = transport.request({ + host: target.hostname, + port: target.port || (target.protocol === "wss:" ? 443 : 80), + path: `${target.pathname}${target.search}`, + timeout: timeoutMs, + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": key, + }, + }); + const fail = (error: Error) => { + request.destroy(); + reject(error); + }; + request.on("upgrade", (response, socket, head) => { + if (response.statusCode !== 101) { + socket.destroy(); + fail( + new Error( + `CDP websocket upgrade refused: HTTP ${response.statusCode}`, + ), + ); + return; + } + const client = new WebSocketClient(socket); + if (head?.length) client.append(Buffer.from(head)); + resolve(client); + }); + request.on("response", (response) => { + fail( + new Error( + `CDP websocket upgrade refused: HTTP ${response.statusCode}`, + ), + ); + }); + request.on("timeout", () => + fail(new Error("CDP websocket connect timeout")), + ); + request.on("error", fail); + request.end(); + }); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + this.onerror(error); + this.socket.destroy(); + this.notifyClose(); + } + + private notifyClose(): void { + if (this.notified) return; + this.notified = true; + this.onclose(); + } + + private append(chunk: Buffer): void { + if (this.closed) return; + this.chunks.push(chunk); + this.buffered += chunk.length; + if (this.buffered > MAX_FRAME_BYTES) { + this.fail(new Error("CDP websocket frame exceeded the size cap")); + return; + } + try { + this.parse(); + } catch (error) { + this.fail( + error instanceof Error + ? error + : new Error("CDP websocket parse failed"), + ); + } + } + + private peek(length: number): Buffer | null { + if (this.buffered < length) return null; + if (this.chunks[0] && this.chunks[0].length >= length) { + return this.chunks[0].subarray(0, length); + } + const out = Buffer.allocUnsafe(length); + let offset = 0; + for (const chunk of this.chunks) { + const take = Math.min(chunk.length, length - offset); + chunk.copy(out, offset, 0, take); + offset += take; + if (offset >= length) break; + } + return out; + } + + private consume(length: number): Buffer | null { + if (this.buffered < length) return null; + const out = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const head = this.chunks[0]; + const need = length - offset; + if (head.length <= need) { + head.copy(out, offset); + offset += head.length; + this.chunks.shift(); + } else { + head.copy(out, offset, 0, need); + this.chunks[0] = head.subarray(need); + offset += need; + } + } + this.buffered -= length; + return out; + } + + private parse(): void { + for (;;) { + const header = this.peek(2); + if (!header) return; + const first = header[0]; + const second = header[1]; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + const extended = this.peek(4); + if (!extended) return; + length = extended.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + const extended = this.peek(10); + if (!extended) return; + length = Number(extended.readBigUInt64BE(2)); + offset = 10; + } + if (length > MAX_FRAME_BYTES) { + this.fail(new Error("CDP websocket frame exceeded the size cap")); + return; + } + const masked = (second & 0x80) !== 0; + const maskOffset = offset; + if (masked) offset += 4; + const frame = this.consume(offset + length); + if (!frame) return; + let payload = frame.subarray(offset, offset + length); + if (masked) { + const mask = frame.subarray(maskOffset, maskOffset + 4); + const unmasked = Buffer.allocUnsafe(payload.length); + for (let index = 0; index < payload.length; index++) { + unmasked[index] = payload[index] ^ mask[index % 4]; + } + payload = unmasked; + } + const opcode = first & 0x0f; + const fin = (first & 0x80) !== 0; + if (opcode === 0x8) { + this.closed = true; + this.notifyClose(); + this.socket.end(); + return; + } + if (opcode === 0x9) { + this.frame(0xa, payload); + continue; + } + if (opcode === 0xa) continue; + if (opcode !== 0x1 && opcode !== 0x0) continue; + this.fragmentBytes += payload.length; + if (this.fragmentBytes > MAX_FRAME_BYTES) { + this.fail(new Error("CDP websocket frame exceeded the size cap")); + return; + } + this.fragments.push(Buffer.from(payload)); + if (fin) { + const message = Buffer.concat(this.fragments).toString("utf8"); + this.fragments = []; + this.fragmentBytes = 0; + this.onmessage(message); + } + } + } + + private frame(opcode: number, data: Buffer): void { + const mask = crypto.randomBytes(4); + const length = data.length; + let header: Buffer; + if (length < 126) { + header = Buffer.from([0x80 | opcode, 0x80 | length]); + } else if (length < 65536) { + header = Buffer.from([ + 0x80 | opcode, + 0x80 | 126, + length >> 8, + length & 255, + ]); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | opcode; + header[1] = 0x80 | 127; + header.writeBigUInt64BE(BigInt(length), 2); + } + const body = Buffer.allocUnsafe(data.length); + for (let index = 0; index < data.length; index++) { + body[index] = data[index] ^ mask[index % 4]; + } + this.socket.write(Buffer.concat([header, mask, body])); + } + + send(data: string): void { + if (this.closed) throw new Error("CDP connection closed"); + this.frame(0x1, Buffer.from(data, "utf8")); + } + + close(): void { + if (this.closed) return; + try { + this.frame(0x8, Buffer.alloc(0)); + } catch { + // The socket may already be gone. + } + this.closed = true; + this.notifyClose(); + this.socket.end(); + setTimeout(() => this.socket.destroy(), 200); + } +} + +export class CdpConnection { + private nextId = 0; + private closed = false; + private readonly pending = new Map(); + private readonly listeners = new Set<(event: CdpEvent) => void>(); + + private constructor(private readonly socket: WebSocketClient) { + socket.onmessage = (data) => { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return; + } + const message = parseCdpMessage(parsed); + if (!message) return; + const pending = + message.id !== undefined ? this.pending.get(message.id) : undefined; + if (pending && message.id !== undefined) { + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.errorMessage) { + pending.reject(new Error(`CDP ${message.errorMessage}`)); + } else { + pending.resolve(message.result); + } + return; + } + if (!message.method) return; + const event: CdpEvent = { + method: message.method, + params: message.params, + ...(message.sessionId ? { sessionId: message.sessionId } : {}), + }; + for (const listener of this.listeners) listener(event); + }; + const rejectAll = (error: Error) => { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + }; + socket.onerror = (error) => { + rejectAll(error); + }; + socket.onclose = () => { + this.closed = true; + rejectAll(new Error("CDP connection closed")); + }; + } + + static async connect( + url: string, + timeoutMs = 15_000, + ): Promise { + return new CdpConnection(await WebSocketClient.connect(url, timeoutMs)); + } + + send( + method: string, + params: Record = {}, + sessionId?: string, + timeoutMs = 15_000, + ): Promise { + const id = ++this.nextId; + return new Promise((resolve, reject) => { + if (this.closed) { + reject(new Error("CDP connection closed")); + return; + } + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { + resolve, + reject, + timer, + }); + try { + this.socket.send( + JSON.stringify({ + id, + method, + params, + ...(sessionId ? { sessionId } : {}), + }), + ); + } catch (error) { + clearTimeout(timer); + this.pending.delete(id); + reject( + error instanceof Error ? error : new Error("CDP connection closed"), + ); + } + }); + } + + onEvent(listener: (event: CdpEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + close(): void { + this.socket.close(); + } +} diff --git a/packages/browser-pay/src/contract.ts b/packages/browser-pay/src/contract.ts new file mode 100644 index 000000000..1a5529850 --- /dev/null +++ b/packages/browser-pay/src/contract.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { isRecord } from "./guards.js"; + +export interface RelayHttpRequest { + method: string; + url: string; + headers: Record; + body: string; +} + +export interface RelayRequest { + injectionRef: string; + profileId: string; + pageOrigin: string; + request: RelayHttpRequest; + idempotencyKey: string; +} + +export interface RelayedHttpResponse { + status: number; + headers: Record; + body: string; +} + +export type RelayResponse = + | { outcome: "relayed"; response: RelayedHttpResponse } + | { outcome: "refused"; reason: string } + | { outcome: "unknown" }; + +export function paymentIdempotencyKey( + method: string, + url: string, + body: string, +): string { + return createHash("sha256") + .update(method) + .update("\0") + .update(url) + .update("\0") + .update(body) + .digest("hex"); +} + +function stringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") return undefined; + out[key] = entry; + } + return out; +} + +export function parseRelayResponse(value: unknown): RelayResponse { + if (!isRecord(value) || typeof value.outcome !== "string") { + throw new Error("Payment relay returned an unreadable response"); + } + if (value.outcome === "unknown") return { outcome: "unknown" }; + if (value.outcome === "refused") { + if (typeof value.reason !== "string" || value.reason.length === 0) { + throw new Error("Payment relay returned an unreadable response"); + } + return { outcome: "refused", reason: value.reason }; + } + if (value.outcome !== "relayed" || !isRecord(value.response)) { + throw new Error("Payment relay returned an unreadable response"); + } + const response = value.response; + const headers = stringRecord(response.headers); + if ( + typeof response.status !== "number" || + !Number.isInteger(response.status) || + response.status < 100 || + response.status > 599 || + typeof response.body !== "string" || + !headers + ) { + throw new Error("Payment relay returned an unreadable response"); + } + return { + outcome: "relayed", + response: { status: response.status, headers, body: response.body }, + }; +} diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts new file mode 100644 index 000000000..d351b7816 --- /dev/null +++ b/packages/browser-pay/src/detect.test.ts @@ -0,0 +1,39 @@ +import { redactHeld, textHoldsPlaceholderNumber } from "./detect"; + +const NUMBER = "4242424242424242"; + +function encodedDigits(digits: string): string { + return [...digits].map((digit) => `%3${digit}`).join(""); +} + +describe("redactHeld", () => { + it("masks grouped and encoded placeholder digits", () => { + const values = [ + `https://checkout.example/pay?number=${NUMBER}`, + "https://checkout.example/pay?number=4242 4242 4242 4242", + "https://checkout.example/pay?number=4242 4242 4242 4242", + "https://checkout.example/pay?number=4242 - 4242 - 4242 - 4242", + "https://checkout.example/pay?number=4242-4242-4242-4242", + "https://checkout.example/pay?number=4242+4242+4242+4242", + "https://checkout.example/pay?number=4242%204242%204242%204242", + "https://checkout.example/pay?number=4242%2D4242%2d4242%2D4242", + "https://checkout.example/pay?number=4242\\u00204242\\u00204242\\u00204242", + `https://checkout.example/pay?number=${encodedDigits(NUMBER)}`, + ]; + for (const value of values) { + const redacted = redactHeld(value, NUMBER); + expect(textHoldsPlaceholderNumber(value, NUMBER)).toBe( + redacted !== value, + ); + expect(redacted).not.toContain("4242"); + expect(redacted).not.toContain("%34"); + expect(textHoldsPlaceholderNumber(redacted, NUMBER)).toBe(false); + } + }); + + it("leaves a url that does not hold the placeholder", () => { + const url = "https://shop.example/orders/100"; + expect(redactHeld(url, NUMBER)).toBe(url); + expect(textHoldsPlaceholderNumber(url, NUMBER)).toBe(false); + }); +}); diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts new file mode 100644 index 000000000..5c6b0d2dc --- /dev/null +++ b/packages/browser-pay/src/detect.ts @@ -0,0 +1,114 @@ +export interface HeldPlaceholder { + number: string; + injectionRef: string; +} + +function placeholderDigits(number: string): string | undefined { + const digits = number.replace(/[\s-]/g, ""); + if (digits.length === 0 || !/^\d+$/.test(digits)) return undefined; + return digits; +} + +function isSeparatorChar(char: string): boolean { + return char === " " || char === "\t" || char === "-" || char === "+"; +} + +function readDecoded( + text: string, + index: number, +): { char: string; next: number } { + if (index >= text.length) return { char: "", next: index }; + if (text.startsWith("\\u", index)) { + const hex = text.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/.test(hex)) { + return { + char: String.fromCharCode(Number.parseInt(hex, 16)), + next: index + 6, + }; + } + } + if (text[index] === "+") return { char: " ", next: index + 1 }; + if (text[index] === "%" && index + 2 < text.length) { + const encoded = text.slice(index, index + 3); + if (/^%[0-9a-fA-F]{2}$/.test(encoded)) { + try { + const char = decodeURIComponent(encoded); + if (char.length === 1) return { char, next: index + 3 }; + } catch { + // keep the literal percent + } + } + } + return { char: text[index] ?? "", next: index + 1 }; +} + +function heldSpanEnd( + text: string, + digits: string, + start: number, +): number | undefined { + const first = readDecoded(text, start); + if (first.char !== digits[0]) return undefined; + let index = first.next; + for (let digit = 1; digit < digits.length; digit++) { + let cursor = index; + while (cursor < text.length) { + const separator = readDecoded(text, cursor); + if (!isSeparatorChar(separator.char) || separator.next === cursor) break; + cursor = separator.next; + } + if (cursor !== index) { + const after = readDecoded(text, cursor); + if (after.char === digits[digit]) { + index = after.next; + continue; + } + } + const raw = readDecoded(text, index); + if (raw.char !== digits[digit]) return undefined; + index = raw.next; + } + return index; +} + +function holdsDigits(text: string, digits: string): boolean { + for (let start = 0; start < text.length; start++) { + if (heldSpanEnd(text, digits, start) !== undefined) return true; + } + return false; +} + +export function textHoldsPlaceholderNumber( + text: string, + number: string, +): boolean { + if (number.length === 0 || text.length === 0) return false; + if (text.includes(number)) return true; + const digits = placeholderDigits(number); + if (!digits) return false; + return holdsDigits(text, digits); +} + +function maskHeldSpans(text: string, digits: string): string { + let out = ""; + let cursor = 0; + for (let start = 0; start < text.length; ) { + const end = heldSpanEnd(text, digits, start); + if (end === undefined) { + start += 1; + continue; + } + out += text.slice(cursor, start); + out += "••••"; + cursor = end; + start = end; + } + return out + text.slice(cursor); +} + +export function redactHeld(value: string, number: string): string { + if (number.length === 0 || value.length === 0) return value; + const digits = placeholderDigits(number); + if (!digits) return value.split(number).join("••••"); + return maskHeldSpans(value, digits); +} diff --git a/packages/browser-pay/src/guards.ts b/packages/browser-pay/src/guards.ts new file mode 100644 index 000000000..35f1d60d9 --- /dev/null +++ b/packages/browser-pay/src/guards.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts new file mode 100644 index 000000000..71a4769b8 --- /dev/null +++ b/packages/browser-pay/src/index.ts @@ -0,0 +1,21 @@ +export { attachInterceptor } from "./interceptor.js"; +export type { + AttachOptions, + FailureReason, + InterceptorEvent, + InterceptorHandle, + InterceptorTarget, +} from "./interceptor.js"; + +export { httpPaymentRelay } from "./relay.js"; +export type { + HttpRelayOptions, + PaymentRelay, + RelayCallOptions, +} from "./relay.js"; + +export type { RelayRequest, RelayResponse } from "./contract.js"; + +export type { HeldPlaceholder } from "./detect.js"; + +export type { PayLogger } from "./logger.js"; diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts new file mode 100644 index 000000000..cb144fbca --- /dev/null +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -0,0 +1,540 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { attachInterceptor, type InterceptorEvent } from "./interceptor"; +import type { PaymentRelay, RelayCallOptions } from "./relay"; +import type { HeldPlaceholder, RelayRequest } from "./index"; +import { stubPaymentRelay } from "../test/stub-relay"; + +const nodeRequire = createRequire(__filename); + +const NUMBER = "4242424242424242"; +const EXP_MONTH = 12; +const EXP_YEAR = 2028; +const CVC = "123"; + +const PLACEHOLDER: HeldPlaceholder = { + number: NUMBER, + injectionRef: "inj_integration", +}; + +const PING = JSON.stringify({ event: "ping" }); +const UNRELATED = JSON.stringify({ + credit_card: { + number: "4111111111111111", + month: 1, + year: 2030, + verification_value: "999", + }, +}); +const MISMATCH = JSON.stringify({ + credit_card: { + number: NUMBER, + month: EXP_MONTH, + year: EXP_YEAR, + verification_value: "999", + }, +}); +const CARD = JSON.stringify({ + credit_card: { + number: NUMBER, + month: EXP_MONTH, + year: EXP_YEAR, + verification_value: CVC, + }, +}); + +const FRAME_HTML = ` + +card +`; + +interface Hit { + path: string; + body: string; +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function startServer( + hits: Hit[], + html: string, + host: string, +): Promise<{ server: http.Server; port: number }> { + const server = http.createServer((req, res) => { + const pathOnly = (req.url ?? "").split("?")[0]; + if ( + req.method === "GET" && + (pathOnly === "/" || pathOnly === "/frame.html") + ) { + res.writeHead(200, { "content-type": "text/html" }); + res.end(html); + return; + } + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + hits.push({ + path: req.url ?? "", + body: Buffer.concat(chunks).toString("utf8"), + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, via: "server" })); + }); + }); + return new Promise((resolve) => { + server.listen(0, host, () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resolve({ server, port }); + }); + }); +} + +function findChrome(): string | undefined { + const candidates = [ + process.env.CHROME_PATH, + "/usr/local/bin/google-chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].filter((candidate): candidate is string => Boolean(candidate)); + const found = candidates.find((candidate) => existsSync(candidate)); + if (found) return found; + try { + const playwrightPath = nodeRequire( + "playwright", + ).chromium.executablePath() as string; + if (playwrightPath && existsSync(playwrightPath)) return playwrightPath; + } catch { + return undefined; + } + return undefined; +} + +async function waitForDebugger( + port: number, + proc: ChildProcess, +): Promise { + const deadline = Date.now() + 15_000; + let last = "not listening"; + while (Date.now() < deadline) { + if (proc.exitCode !== null) { + throw new Error(`Chrome exited before CDP was ready (${proc.exitCode})`); + } + try { + const response = await fetch(`http://127.0.0.1:${port}/json/version`); + if (response.ok) { + const body = (await response.json()) as { + webSocketDebuggerUrl?: string; + }; + if (body.webSocketDebuggerUrl) return body.webSocketDebuggerUrl; + } + } catch (error) { + last = error instanceof Error ? error.message : "fetch failed"; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`CDP endpoint did not come up (${last})`); +} + +const chromeBin = findChrome(); +if (!chromeBin) { + process.stderr.write( + "browser-pay: skipping Chrome integration tests. Install Google Chrome or Chromium, or set CHROME_PATH.\n", + ); +} + +(chromeBin ? describe : describe.skip)( + "attachInterceptor on headless Chromium", + () => { + let chrome: ChildProcess; + let userDataDir: string; + let cdpUrl: string; + let merchant: { server: http.Server; port: number }; + let card: { server: http.Server; port: number }; + const hits: Hit[] = []; + + beforeAll(async () => { + userDataDir = await mkdtemp(path.join(os.tmpdir(), "browser-pay-")); + const port = await freePort(); + chrome = spawn( + chromeBin ?? "chrome-missing", + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--site-per-process", + `--remote-debugging-port=${port}`, + "--remote-allow-origins=*", + `--user-data-dir=${userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "about:blank", + ], + { stdio: "ignore" }, + ); + cdpUrl = await waitForDebugger(port, chrome); + card = await startServer(hits, FRAME_HTML, "127.0.0.1"); + merchant = await startServer( + hits, + `merchant`, + "localhost", + ); + }, 30_000); + + afterAll(async () => { + chrome?.kill("SIGKILL"); + await new Promise((resolve) => merchant?.server.close(resolve)); + await new Promise((resolve) => card?.server.close(resolve)); + if (userDataDir) await rm(userDataDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + hits.length = 0; + }); + + async function drive( + relay: PaymentRelay, + bodies: { ping: string; other?: string; mismatch?: string; card: string }, + timeoutMs = 5_000, + ) { + const events: InterceptorEvent[] = []; + const calls: RelayRequest[] = []; + const wrapped: PaymentRelay = { + relay: async (request, options) => { + calls.push(request); + return relay.relay(request, options); + }, + }; + const handle = await attachInterceptor(cdpUrl, { + relay: wrapped, + placeholder: PLACEHOLDER, + profileId: "shopify-card-vault", + timeoutMs, + }); + handle.on((event) => events.push(event)); + const { chromium } = await import("playwright"); + const browser = await chromium.connectOverCDP(cdpUrl); + let page: import("playwright").Page | undefined; + try { + const context = browser.contexts()[0]; + page = context.pages()[0] ?? (await context.newPage()); + const merchantOrigin = `http://localhost:${merchant.port}`; + await page.goto(`${merchantOrigin}/`, { + waitUntil: "domcontentloaded", + }); + // The driver's Playwright session is a second CDP client on this browser. + expect( + await page.evaluate( + () => + (globalThis as { document?: { title: string } }).document?.title, + ), + ).toBe("merchant"); + let frame = page + .frames() + .find((candidate) => candidate.url().includes(String(card.port))); + const frameDeadline = Date.now() + 5_000; + while (!frame && Date.now() < frameDeadline) { + await page.waitForTimeout(50); + frame = page + .frames() + .find((candidate) => candidate.url().includes(String(card.port))); + } + if (!frame) { + const urls = page.frames().map((candidate) => candidate.url()); + const count = await page + .locator("iframe") + .count() + .catch(() => -1); + throw new Error( + `cross-origin card frame was not attached (iframes=${count}; ${urls.join(" | ")})`, + ); + } + await frame.waitForFunction( + () => typeof (globalThis as { run?: unknown }).run === "function", + ); + const result = await frame.evaluate( + (payload) => + ( + globalThis as unknown as { + run: (bodies: typeof payload) => Promise; + } + ).run(payload), + { + ping: bodies.ping, + other: bodies.other ?? PING, + mismatch: bodies.mismatch ?? bodies.card, + card: bodies.card, + }, + ); + return { events, calls, result, merchantOrigin }; + } finally { + // Closing a connectOverCDP browser can terminate Chrome. Close the page + // only; the interceptor's own connection is detached below. + await page?.close().catch(() => {}); + await handle.detach(); + void browser; + } + } + + it("relays only the placeholder request while a second CDP client drives the page", async () => { + const { events, calls, result, merchantOrigin } = await drive( + stubPaymentRelay(async () => ({ + outcome: "relayed", + response: { + status: 201, + headers: { "content-type": "application/json" }, + body: '{"id":"sess_test"}', + }, + })), + { ping: PING, other: UNRELATED, mismatch: MISMATCH, card: CARD }, + ); + + const paths = hits.map((hit) => hit.path); + expect(paths.filter((item) => item === "/collect").length).toBe(1); + expect(hits.find((hit) => hit.path === "/collect")?.body).toBe(PING); + expect(hits.find((hit) => hit.path === "/other")?.body).toBe(UNRELATED); + expect(paths.filter((item) => item === "/sessions")).toEqual([]); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + profileId: "shopify-card-vault", + injectionRef: "inj_integration", + pageOrigin: merchantOrigin, + request: { + method: "POST", + url: `http://127.0.0.1:${card.port}/sessions`, + body: CARD, + }, + }); + expect(calls[0].request.body).toBe(MISMATCH); + expect(calls[0].idempotencyKey).not.toBe(calls[1].idempotencyKey); + expect(calls[1].pageOrigin).not.toContain(String(card.port)); + expect(calls[0].request.headers["content-type"]).toBe("application/json"); + expect(events.filter((event) => event.type === "paused")).toEqual([ + expect.objectContaining({ type: "paused", targetType: "iframe" }), + expect.objectContaining({ type: "paused", targetType: "iframe" }), + ]); + + expect(result).toEqual({ + ping: { + status: 200, + text: JSON.stringify({ ok: true, via: "server" }), + }, + unrelated: { + status: 200, + text: JSON.stringify({ ok: true, via: "server" }), + }, + mismatch: { status: 201, text: '{"id":"sess_test"}' }, + card: { status: 201, text: '{"id":"sess_test"}' }, + }); + expect(events.map((event) => event.type)).toEqual([ + "paused", + "relayed", + "paused", + "relayed", + ]); + expect(events[3]).toMatchObject({ type: "relayed", status: 201 }); + expect(JSON.stringify(events)).not.toContain(PLACEHOLDER.number); + }, 60_000); + + it("fails a refused placeholder request and never delivers it", async () => { + const { events, calls, result } = await drive( + stubPaymentRelay(async () => ({ + outcome: "refused", + reason: "already_relayed", + })), + { ping: PING, other: PING, mismatch: PING, card: CARD }, + ); + expect( + hits.filter((hit) => hit.body.includes(PLACEHOLDER.number)), + ).toEqual([]); + expect(calls).toHaveLength(1); + expect(calls[0].request.body).toBe(CARD); + expect(result).toMatchObject({ card: { error: "TypeError" } }); + expect(events.map((event) => event.type)).toEqual(["paused", "refused"]); + expect(events[1]).toEqual({ + type: "refused", + url: `http://127.0.0.1:${card.port}/sessions`, + reason: "already_relayed", + }); + expect(JSON.stringify(events)).not.toContain(PLACEHOLDER.number); + }, 60_000); + + it("fails the paused request when the relay throws", async () => { + const { events, result } = await drive( + stubPaymentRelay(async () => { + throw new Error(`network down ${PLACEHOLDER.number}`); + }), + { ping: PING, other: PING, mismatch: PING, card: CARD }, + ); + expect( + hits.filter((hit) => hit.body.includes(PLACEHOLDER.number)), + ).toEqual([]); + expect(result).toMatchObject({ card: { error: "TypeError" } }); + expect(events.map((event) => event.type)).toEqual(["paused", "failed"]); + expect(events[1]).toMatchObject({ type: "failed", reason: "network" }); + expect(JSON.stringify(events)).not.toContain(PLACEHOLDER.number); + }, 60_000); + + it("fails a relay that does not finish before the timeout", async () => { + const { events, result } = await drive( + stubPaymentRelay( + (_request, options?: RelayCallOptions) => + new Promise((_resolve, reject) => { + const abort = () => reject(new Error("Payment relay timed out")); + if (options?.signal?.aborted) abort(); + else + options?.signal?.addEventListener("abort", abort, { + once: true, + }); + }), + ), + { ping: PING, other: PING, mismatch: PING, card: CARD }, + 400, + ); + expect( + hits.filter((hit) => hit.body.includes(PLACEHOLDER.number)), + ).toEqual([]); + expect(result).toMatchObject({ card: { error: "TypeError" } }); + expect(events.map((event) => event.type)).toEqual(["paused", "failed"]); + expect(events[1]).toMatchObject({ type: "failed", reason: "timeout" }); + }, 60_000); + + it("does not deliver a placeholder sent as a query or after a multipart boundary", async () => { + const events: InterceptorEvent[] = []; + const calls: RelayRequest[] = []; + const handle = await attachInterceptor(cdpUrl, { + relay: { + relay: async (request) => { + calls.push(request); + return { + outcome: "relayed", + response: { + status: 201, + headers: { "content-type": "text/plain" }, + body: "ok", + }, + }; + }, + }, + placeholder: PLACEHOLDER, + profileId: "shopify-card-vault", + timeoutMs: 5_000, + }); + handle.on((event) => events.push(event)); + const { chromium } = await import("playwright"); + const browser = await chromium.connectOverCDP(cdpUrl); + const page = + browser.contexts()[0].pages()[0] ?? + (await browser.contexts()[0].newPage()); + try { + await page.goto(`http://localhost:${merchant.port}/`, { + waitUntil: "domcontentloaded", + }); + let frame = page + .frames() + .find((candidate) => candidate.url().includes(String(card.port))); + const frameDeadline = Date.now() + 5_000; + while (!frame && Date.now() < frameDeadline) { + await page.waitForTimeout(50); + frame = page + .frames() + .find((candidate) => candidate.url().includes(String(card.port))); + } + if (!frame) throw new Error("cross-origin card frame was not attached"); + const boundary = "----WebKitFormBoundary123"; + const multipart = `--${boundary}\r\nContent-Disposition: form-data; name="number"\r\n\r\n4242 4242 4242 4242\r\n--${boundary}--`; + const outcome = await frame.evaluate( + async ({ query, multipartBody, formBoundary }) => { + const queryResult = await fetch(query).then( + (response) => ({ status: response.status }), + (error: Error) => ({ error: error.name }), + ); + const formResult = await fetch("/sessions", { + method: "POST", + headers: { + "content-type": `multipart/form-data; boundary=${formBoundary}`, + }, + body: multipartBody, + }).then( + async (response) => ({ + status: response.status, + text: await response.text(), + }), + (error: Error) => ({ error: error.name }), + ); + return { queryResult, formResult }; + }, + { + query: `/lookup?number=${PLACEHOLDER.number}`, + multipartBody: multipart, + formBoundary: boundary, + }, + ); + expect(outcome.queryResult).toEqual({ status: 201 }); + expect(outcome.formResult).toEqual({ status: 201, text: "ok" }); + expect( + hits.filter((hit) => hit.body.includes(PLACEHOLDER.number)), + ).toEqual([]); + expect(hits.map((hit) => hit.path)).not.toContain( + `/lookup?number=${PLACEHOLDER.number}`, + ); + expect(calls).toHaveLength(2); + expect( + calls.some((call) => call.request.url.includes(PLACEHOLDER.number)), + ).toBe(true); + expect( + calls.some((call) => + call.request.body.includes("4242 4242 4242 4242"), + ), + ).toBe(true); + expect(events.filter((event) => event.type === "paused")).toHaveLength( + 2, + ); + expect(events.some((event) => event.type === "failed")).toBe(false); + } finally { + await page.close().catch(() => {}); + await handle.detach(); + void browser; + } + }, 60_000); + }, +); diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts new file mode 100644 index 000000000..9b4291787 --- /dev/null +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -0,0 +1,1845 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import { attachInterceptor, type InterceptorEvent } from "./interceptor"; +import type { HeldPlaceholder, RelayRequest, RelayResponse } from "./index"; +import { stubPaymentRelay } from "../test/stub-relay"; +import type { RelayCallOptions } from "./relay"; + +const HELD: HeldPlaceholder = { + number: "4242424242424242", + injectionRef: "inj_protocol", +}; + +const CARD = JSON.stringify({ + credit_card: { + number: HELD.number, + month: 12, + year: 2028, + verification_value: "123", + }, +}); + +interface Command { + method: string; + params: Record; + sessionId?: string; +} + +interface StubOptions { + failAutoAttach?: boolean; + failFetch?: boolean; + failFulfill?: boolean; + holdFulfill?: boolean; + postData?: string; + postDataError?: boolean; + targets?: { targetId: string; type: string; url: string }[]; + attachedSessionId?: string; +} + +function serverFrame(text: string): Buffer { + const payload = Buffer.from(text); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); +} + +async function startStub(options: StubOptions = {}): Promise<{ + url: string; + commands: Command[]; + sawClose: () => boolean; + emit: (event: Record) => void; + close: () => Promise; +}> { + const commands: Command[] = []; + let socket: Duplex | undefined; + let sawClose = false; + const chunks: Buffer[] = []; + let buffered = 0; + + const reply = (id: number, result: unknown, error?: { message: string }) => { + const message = error ? { id, error } : { id, result }; + socket?.write(serverFrame(JSON.stringify(message))); + }; + + const consume = (length: number): Buffer | null => { + if (buffered < length) return null; + const out = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const head = chunks[0]; + const need = length - offset; + if (head.length <= need) { + head.copy(out, offset); + offset += head.length; + chunks.shift(); + } else { + head.copy(out, offset, 0, need); + chunks[0] = head.subarray(need); + offset += need; + } + } + buffered -= length; + return out; + }; + + const peek = (length: number): Buffer | null => { + if (buffered < length) return null; + if (chunks[0] && chunks[0].length >= length) + return chunks[0].subarray(0, length); + const out = Buffer.allocUnsafe(length); + let offset = 0; + for (const chunk of chunks) { + const take = Math.min(chunk.length, length - offset); + chunk.copy(out, offset, 0, take); + offset += take; + if (offset >= length) break; + } + return out; + }; + + const onMessage = (text: string) => { + const message = JSON.parse(text) as { + id?: number; + method?: string; + params?: Record; + sessionId?: string; + }; + if (!message.id || !message.method) return; + commands.push({ + method: message.method, + params: message.params ?? {}, + ...(message.sessionId ? { sessionId: message.sessionId } : {}), + }); + if (message.method === "Target.setAutoAttach" && options.failAutoAttach) { + reply(message.id, undefined, { message: "auto-attach refused" }); + return; + } + if (message.method === "Fetch.enable" && options.failFetch) { + reply(message.id, undefined, { message: "Fetch is unsupported" }); + return; + } + if (message.method === "Target.getTargets") { + reply(message.id, { targetInfos: options.targets ?? [] }); + return; + } + if (message.method === "Target.attachToTarget") { + reply(message.id, { + sessionId: options.attachedSessionId ?? "iframe-existing", + }); + return; + } + if (message.method === "Runtime.evaluate") { + reply(message.id, {}); + return; + } + if (message.method === "Network.getRequestPostData") { + if (options.postDataError) { + reply(message.id, undefined, { message: "no body" }); + return; + } + reply(message.id, { postData: options.postData ?? "" }); + return; + } + if (message.method === "Fetch.fulfillRequest" && options.failFulfill) { + reply(message.id, undefined, { message: "request is gone" }); + return; + } + if (message.method === "Fetch.fulfillRequest" && options.holdFulfill) { + return; + } + reply(message.id, {}); + }; + + const parse = () => { + for (;;) { + const header = peek(2); + if (!header) return; + const second = header[1]; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + const extended = peek(4); + if (!extended) return; + length = extended.readUInt16BE(2); + offset = 4; + } + const masked = (second & 0x80) !== 0; + const maskOffset = offset; + if (masked) offset += 4; + const frame = consume(offset + length); + if (!frame) return; + let payload = frame.subarray(offset); + if (masked) { + const mask = frame.subarray(maskOffset, maskOffset + 4); + const unmasked = Buffer.allocUnsafe(payload.length); + for (let index = 0; index < payload.length; index++) { + unmasked[index] = payload[index] ^ mask[index % 4]; + } + payload = unmasked; + } + if ((header[0] & 0x0f) === 0x8) { + sawClose = true; + return; + } + if ((header[0] & 0x0f) !== 0x1) continue; + onMessage(payload.toString("utf8")); + } + }; + + const server = http.createServer(); + server.on("upgrade", (request, upgraded, head) => { + const key = String(request.headers["sec-websocket-key"] ?? ""); + const accept = crypto + .createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + upgraded.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket = upgraded; + if (head?.length) { + chunks.push(Buffer.from(head)); + buffered += head.length; + } + upgraded.on("data", (chunk: Buffer) => { + chunks.push(chunk); + buffered += chunk.length; + parse(); + }); + }); + + await awaitListen(server); + const address = server.address() as AddressInfo; + return { + url: `ws://127.0.0.1:${address.port}`, + commands, + sawClose: () => sawClose, + emit(event) { + socket?.write(serverFrame(JSON.stringify(event))); + }, + close() { + socket?.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function awaitListen(server: http.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); +} + +async function waitFor(predicate: () => boolean, label: string): Promise { + const deadline = Date.now() + 3_000; + while (!predicate()) { + if (Date.now() > deadline) + throw new Error(`timed out waiting for ${label}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function multipartPause( + requestId: string, + networkId: string, +): Record { + return { + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId, + networkId, + request: { + url: "https://checkout.example/sessions", + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=b" }, + hasPostData: true, + }, + }, + }; +} + +function attachPage( + emit: (event: Record) => void, + sessionId = "page-1", +) { + emit({ + method: "Target.attachedToTarget", + params: { + sessionId, + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); +} + +const success = (): RelayResponse => ({ + outcome: "relayed", + response: { + status: 201, + headers: { "content-type": "application/json" }, + body: '{"id":"sess"}', + }, +}); + +function header( + headers: RelayRequest["request"]["headers"], + name: string, +): string | undefined { + return headers[name.toLowerCase()]; +} + +describe("interceptor CDP decisions", () => { + it("closes the CDP connection when start fails", async () => { + const stub = await startStub({ failAutoAttach: true }); + try { + await expect( + attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }), + ).rejects.toThrow("CDP auto-attach refused"); + await waitFor(() => stub.sawClose(), "websocket close"); + } finally { + await stub.close(); + } + }); + + it("does not resume a target whose Fetch.enable failed", async () => { + const stub = await startStub({ failFetch: true }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some( + (command) => command.method === "Target.closeTarget", + ), + "closeTarget", + ); + expect(stub.commands.map((command) => command.method)).not.toContain( + "Runtime.runIfWaitingForDebugger", + ); + expect(stub.commands).toContainEqual( + expect.objectContaining({ + method: "Target.closeTarget", + params: { targetId: "target-page" }, + }), + ); + expect(events).toContainEqual({ + type: "failed", + url: "https://shop.example/checkout", + reason: "intercept_unavailable", + }); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("reads a body with missing entry bytes via getRequestPostData and relays it", async () => { + const stub = await startStub({ postData: CARD }); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-1", + networkId: "net-1", + request: { + url: "https://checkout.example/sessions", + method: "POST", + headers: { "content-type": "application/json" }, + hasPostData: true, + postDataEntries: [{}], + }, + resourceType: "Fetch", + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "relayed", + ); + expect(stub.commands).toContainEqual( + expect.objectContaining({ + method: "Network.getRequestPostData", + params: { requestId: "net-1" }, + }), + ); + expect( + stub.commands.some( + (command) => command.method === "Fetch.getRequestPostData", + ), + ).toBe(false); + expect(calls).toHaveLength(1); + expect(calls[0].request.body).toBe(CARD); + expect(calls[0].idempotencyKey).toBe( + crypto + .createHash("sha256") + .update(`POST\0${calls[0].request.url}\0${CARD}`) + .digest("hex"), + ); + expect(calls[0].profileId).toBe("shopify-card-vault"); + expect(header(calls[0].request.headers, "content-type")).toBe( + "application/json", + ); + expect(calls[0].request.headers).not.toHaveProperty("cookie"); + expect(calls[0].request.headers).not.toHaveProperty("sec-fetch-site"); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("continues a text-only multipart form read from the network", async () => { + const body = + '--b\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n--b--'; + const stub = await startStub({ postData: body }); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit(multipartPause("req-text", "net-text")); + await waitFor( + () => + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + "continue", + ); + expect(calls).toHaveLength(0); + expect(events.some((event) => event.type === "failed")).toBe(false); + expect( + stub.commands.some( + (command) => command.method === "Network.getRequestPostData", + ), + ).toBe(true); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("relays a text-only multipart form that holds the placeholder", async () => { + const body = + '--b\r\nContent-Disposition: form-data; name="number"\r\n\r\n4242 4242 4242 4242\r\n--b--'; + const stub = await startStub({ postData: body }); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit(multipartPause("req-text-held", "net-text-held")); + await waitFor(() => calls.length === 1, "relay"); + expect(calls[0].request.body).toBe(body); + expect( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fails closed when a multipart body would omit file bytes", async () => { + const body = [ + '--b\r\nContent-Disposition: form-data; name="number"\r\n\r\n4242 4242 4242 4242', + 'Content-Disposition: form-data; name="file"; filename="a.txt"\r\nContent-Type: text/plain\r\n\r\n', + "--b--", + ].join("\r\n--b\r\n"); + const stub = await startStub({ postData: body }); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit(multipartPause("req-file", "net-file")); + await waitFor( + () => events.some((event) => event.type === "failed"), + "failed", + ); + expect(calls).toHaveLength(0); + expect(events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "unreadable_body" }), + ); + expect( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); + expect( + stub.commands.some( + (command) => command.method === "Network.getRequestPostData", + ), + ).toBe(true); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fails a paused request when the body cannot be read", async () => { + const stub = await startStub({ postDataError: true }); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-2", + networkId: "net-2", + request: { + url: "https://checkout.example/sessions", + method: "POST", + hasPostData: true, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "failed"), + "failed", + ); + expect(calls).toHaveLength(0); + expect(events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "unreadable_body" }), + ); + expect( + stub.commands.some((command) => command.method === "Fetch.failRequest"), + ).toBe(true); + expect( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("relays a request whose URL holds the placeholder even when the body does not", async () => { + const stub = await startStub(); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-url", + request: { + url: "https://checkout.example/pay?number=4242%204242%204242%204242", + method: "POST", + postData: '{"event":"ping"}', + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "relayed", + ); + expect(calls).toHaveLength(1); + expect(calls[0].request.url).toContain("4242%204242%204242%204242"); + expect(calls[0].request.body).toBe('{"event":"ping"}'); + expect(events.map((event) => event.type)).toEqual(["paused", "relayed"]); + expect(JSON.stringify(events)).not.toContain(HELD.number); + expect(JSON.stringify(events)).not.toContain("4242"); + expect(JSON.stringify(events)).not.toContain("%20"); + expect( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("relays a multipart body whose boundary digits follow the placeholder", async () => { + const boundary = "----WebKitFormBoundary123"; + const multipart = `--${boundary}\r\nContent-Disposition: form-data; name="number"\r\n\r\n4242 4242 4242 4242\r\n--${boundary}--`; + const stub = await startStub(); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-multi", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: multipart, + }, + }, + }); + await waitFor(() => calls.length === 1, "relay"); + expect(calls[0].request.body).toBe(multipart); + expect( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("relays a placeholder split by repeated spaces and dashes", async () => { + const body = "4242 4242 - 4242 4242"; + const stub = await startStub(); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-spaced", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: body, + }, + }, + }); + await waitFor(() => calls.length === 1, "relay"); + expect(calls[0].request.body).toBe(body); + expect( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fails an in-flight relay on detach before Fetch is disabled", async () => { + const stub = await startStub(); + let signal: AbortSignal | undefined; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay( + (_request, options?: RelayCallOptions) => + new Promise((_resolve, reject) => { + signal = options?.signal; + const abort = () => reject(new Error("Payment relay timed out")); + if (options?.signal?.aborted) abort(); + else + options?.signal?.addEventListener("abort", abort, { + once: true, + }); + }), + ), + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 30_000, + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-detach", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "paused"), + "paused", + ); + await handle.detach(); + const methods = stub.commands.map((command) => command.method); + expect(methods.indexOf("Fetch.failRequest")).toBeGreaterThan(-1); + expect(methods.indexOf("Fetch.failRequest")).toBeLessThan( + methods.indexOf("Fetch.disable"), + ); + expect(signal?.aborted).toBe(true); + expect(events.map((event) => event.type)).toEqual(["paused", "failed"]); + expect(events[1]).toMatchObject({ type: "failed", reason: "detached" }); + } finally { + await stub.close(); + } + }); + + it("reports relayed_unfulfilled when fulfill fails after a successful relay", async () => { + const stub = await startStub({ failFulfill: true }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-fulfill", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed_unfulfilled"), + "relayed_unfulfilled", + ); + expect(events.map((event) => event.type)).toEqual([ + "paused", + "relayed_unfulfilled", + ]); + expect(events[1]).toMatchObject({ + type: "relayed_unfulfilled", + status: 201, + }); + expect( + stub.commands.some((command) => command.method === "Fetch.failRequest"), + ).toBe(true); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fulfills a response that contains a different card number or a timestamp", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => ({ + outcome: "relayed", + response: { + status: 200, + headers: { "content-type": "text/plain" }, + body: '{"id":"sess","created_at":1760650000123,"pan":"4111111111111111"}', + }, + })), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-leak", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "relayed", + ); + expect(events.map((event) => event.type)).toEqual(["paused", "relayed"]); + expect( + stub.commands.some( + (command) => command.method === "Fetch.fulfillRequest", + ), + ).toBe(true); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("continues an unrelated GET without reading a body, and keeps one session per target", async () => { + const stub = await startStub(); + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit, "page-1"); + await waitFor( + () => + stub.commands.filter((command) => command.method === "Fetch.enable") + .length === 1, + "first Fetch.enable", + ); + stub.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-2", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); + await waitFor( + () => + stub.commands.some( + (command) => command.method === "Target.detachFromTarget", + ), + "detach duplicate", + ); + expect( + stub.commands.filter((command) => command.method === "Fetch.enable"), + ).toHaveLength(1); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-get", + request: { + url: "https://cdn.example/app.js", + method: "GET", + }, + }, + }); + await waitFor( + () => + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + "continue", + ); + expect( + stub.commands.some( + (command) => + command.method === "Network.getRequestPostData" || + command.method === "Fetch.getRequestPostData", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("enables Fetch on an iframe already on the payment host before attach returns", async () => { + const stub = await startStub({ + targets: [ + { + targetId: "target-card", + type: "iframe", + url: "https://checkout.pci.shopifyinc.com/sessions", + }, + ], + attachedSessionId: "iframe-card", + }); + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + const enable = stub.commands.findIndex( + (command) => + command.method === "Fetch.enable" && + command.sessionId === "iframe-card", + ); + const autoAttach = stub.commands.findIndex( + (command) => + command.method === "Target.setAutoAttach" && + command.sessionId === "iframe-card" && + command.params.waitForDebuggerOnStart === true, + ); + expect(enable).toBeGreaterThan(-1); + expect(autoAttach).toBeGreaterThan(-1); + expect(autoAttach).toBeLessThan(enable); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fails a paused request whose CDP session was never attached", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "missing-session", + params: { + requestId: "req-unknown", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => + events.some( + (event) => + event.type === "failed" && event.reason === "unknown_session", + ), + "unknown session", + ); + expect( + stub.commands.some( + (command) => + command.method === "Fetch.failRequest" && + command.sessionId === "missing-session" && + command.params.requestId === "req-unknown", + ), + ).toBe(true); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("sends one relay for identical in-flight retries and logs the refusal once", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + let calls = 0; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const warnings: string[] = []; + const logger = { + warn(message: string) { + warnings.push(message); + }, + }; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => { + calls += 1; + if (calls === 1) await gate; + return { outcome: "refused", reason: "already_relayed" }; + }), + placeholder: HELD, + profileId: "shopify-card-vault", + logger, + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + const paused = { + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-a", + resourceType: "Fetch", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }; + stub.emit(paused); + await waitFor(() => calls === 1, "first relay"); + stub.emit({ + ...paused, + params: { ...paused.params, requestId: "req-b" }, + }); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(calls).toBe(1); + release(); + await waitFor( + () => + stub.commands.filter( + (command) => command.method === "Fetch.failRequest", + ).length >= 2, + "both failed", + ); + expect(calls).toBe(1); + expect(warnings).toEqual(["payment relay refused the paused request"]); + expect(events.filter((event) => event.type === "refused")).toHaveLength( + 2, + ); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("forwards only the headers David accepts", async () => { + const stub = await startStub(); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-cookie", + resourceType: "XHR", + request: { + url: "https://checkout.example/sessions", + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: "cart=1", + Accept: "application/json", + "Shopify-Identification-Signature": "sig", + "X-Unforwarded": "nope", + }, + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "relayed", + ); + expect(calls[0].request.headers).toEqual({ + accept: "application/json", + "content-type": "application/json", + "shopify-identification-signature": "sig", + }); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fails a relayed response that still contains the held placeholder", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => ({ + outcome: "relayed", + response: { + status: 200, + headers: { "content-type": "text/plain" }, + body: `session ${HELD.number}`, + }, + })), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-held-back", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "failed"), + "failed", + ); + expect(events[1]).toMatchObject({ reason: "response_credential" }); + expect( + stub.commands.some( + (command) => command.method === "Fetch.fulfillRequest", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("relays a held number that fails the Luhn check", async () => { + const number = "4242424242424243"; + const stub = await startStub(); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: { ...HELD, number }, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-luhn", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: JSON.stringify({ number }), + }, + }, + }); + await waitFor(() => calls.length === 1, "relay"); + expect(calls[0].request.body).toContain(number); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("fails the paused request when the relay ignores the abort signal", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: { relay: () => new Promise(() => {}) }, + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 40, + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-hang", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "failed"), + "timeout", + ); + expect(events[1]).toMatchObject({ type: "failed", reason: "timeout" }); + expect( + stub.commands.some((command) => command.method === "Fetch.failRequest"), + ).toBe(true); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not let an earlier timeout abort a later retry of the same body", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + let calls = 0; + let aborted = false; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay( + (_request, options) => + new Promise((resolve, reject) => { + calls += 1; + const finish = () => resolve(success()); + options?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }); + void gate.then(finish); + }), + ), + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 400, + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + const paused = { + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-early", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }; + stub.emit(paused); + await waitFor(() => calls === 1, "first relay"); + await new Promise((resolve) => setTimeout(resolve, 250)); + stub.emit({ + ...paused, + params: { ...paused.params, requestId: "req-later" }, + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(aborted).toBe(false); + expect(events.filter((event) => event.type === "failed")).toHaveLength(1); + release(); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "later relay", + ); + expect(calls).toBe(1); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("decodes postDataEntries that split a multibyte character", async () => { + const text = `{"number":"${HELD.number}","name":"é"}`; + const bytes = Buffer.from(text, "utf8"); + const splitAt = + Buffer.from(text.slice(0, text.indexOf("é")), "utf8").length + 1; + const first = bytes.subarray(0, splitAt); + const second = bytes.subarray(splitAt); + const stub = await startStub(); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-bytes", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postDataEntries: [ + { bytes: first.toString("base64") }, + { bytes: second.toString("base64") }, + ], + }, + }, + }); + await waitFor(() => calls.length === 1, "relay"); + expect(calls[0].request.body).toBe(text); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not send a second relay after outcome unknown", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + let calls = 0; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => { + calls += 1; + return { outcome: "unknown" }; + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + const paused = { + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-unknown-1", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }; + stub.emit(paused); + await waitFor( + () => events.some((event) => event.type === "failed"), + "unknown", + ); + stub.emit({ + ...paused, + params: { ...paused.params, requestId: "req-unknown-2" }, + }); + await waitFor( + () => events.filter((event) => event.type === "failed").length === 2, + "second unknown", + ); + expect(calls).toBe(1); + expect(events[1]).toMatchObject({ reason: "unknown" }); + expect(events[3]).toMatchObject({ reason: "unknown" }); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not enable Fetch on a captcha iframe", async () => { + const stub = await startStub(); + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + stub.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "captcha-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-captcha", + type: "iframe", + url: "https://hcaptcha.com/captcha/v1/abc/static/hcaptcha.html", + }, + }, + }); + await waitFor( + () => + stub.commands.some( + (command) => + command.method === "Runtime.runIfWaitingForDebugger" && + command.sessionId === "captcha-1", + ), + "resume captcha", + ); + expect( + stub.commands.some( + (command) => + command.method === "Fetch.enable" && + command.sessionId === "captcha-1", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("delivers an arm failure that happened before the handle was returned", async () => { + const stub = await startStub({ + failFetch: true, + targets: [ + { + targetId: "iframe-existing", + type: "iframe", + url: "https://checkout.example/card", + }, + ], + }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + expect( + stub.commands.some( + (command) => command.method === "Target.closeTarget", + ), + ).toBe(true); + handle.on((event) => events.push(event)); + expect(events).toContainEqual({ + type: "failed", + url: "https://checkout.example/card", + reason: "intercept_unavailable", + }); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not relay a duplicate pause while fulfill is in flight", async () => { + const stub = await startStub({ holdFulfill: true }); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + const pause = { + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-dup", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }; + stub.emit(pause); + await waitFor( + () => + stub.commands.some( + (command) => command.method === "Fetch.fulfillRequest", + ), + "fulfill", + ); + stub.emit(pause); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(calls).toHaveLength(1); + expect( + stub.commands.filter( + (command) => command.method === "Fetch.fulfillRequest", + ), + ).toHaveLength(1); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not keep events that fire after start when nobody subscribed", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-early", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => + stub.commands.some( + (command) => + command.method === "Fetch.fulfillRequest" && + command.params.requestId === "req-early", + ), + "early fulfill", + ); + handle.on((event) => events.push(event)); + expect(events).toEqual([]); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-late", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "late relay", + ); + expect(events.map((event) => event.type)).toEqual(["paused", "relayed"]); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("drops startup events on detach when nothing subscribed", async () => { + const stub = await startStub({ + failFetch: true, + targets: [ + { + targetId: "iframe-existing", + type: "iframe", + url: "https://checkout.example/card", + }, + ], + }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + await handle.detach(); + handle.on((event) => events.push(event)); + expect(events).toEqual([]); + } finally { + await stub.close(); + } + }); + + it("does not fail a request that detach observes during fulfill", async () => { + const stub = await startStub({ holdFulfill: true }); + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => success()), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-fulfill-race", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor( + () => + stub.commands.some( + (command) => command.method === "Fetch.fulfillRequest", + ), + "fulfill", + ); + await handle.detach(); + expect( + stub.commands.filter( + (command) => command.method === "Fetch.fulfillRequest", + ), + ).toHaveLength(1); + expect( + stub.commands.some((command) => command.method === "Fetch.failRequest"), + ).toBe(false); + } finally { + await stub.close(); + } + }); + + it("keeps Fetch armed on a blank iframe across target info changes", async () => { + const stub = await startStub(); + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + attachPage(stub.emit); + await waitFor( + () => + stub.commands.some( + (command) => + command.method === "Fetch.enable" && + command.sessionId === "page-1", + ), + "arm page", + ); + stub.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "iframe-blank", + waitingForDebugger: true, + targetInfo: { + targetId: "target-blank", + type: "iframe", + url: "about:blank", + }, + }, + }); + await waitFor( + () => + stub.commands.some( + (command) => + command.method === "Fetch.enable" && + command.sessionId === "iframe-blank", + ), + "arm blank iframe", + ); + stub.emit({ + method: "Target.targetInfoChanged", + params: { + targetInfo: { targetId: "target-blank", url: "about:blank" }, + }, + }); + stub.emit({ + method: "Target.targetInfoChanged", + params: { + targetInfo: { + targetId: "target-blank", + url: "https://checkout.example/card", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect( + stub.commands.some( + (command) => + command.method === "Fetch.disable" && + command.sessionId === "iframe-blank", + ), + ).toBe(false); + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "iframe-blank", + params: { + requestId: "req-blank-nav", + request: { + url: "https://checkout.example/sessions", + method: "POST", + postData: CARD, + }, + }, + }); + await waitFor(() => calls.length === 1, "relay after blank navigation"); + expect( + stub.commands.filter( + (command) => + command.method === "Fetch.enable" && + command.sessionId === "iframe-blank", + ), + ).toHaveLength(1); + await handle.detach(); + } finally { + await stub.close(); + } + }); +}); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts new file mode 100644 index 000000000..1f73d4721 --- /dev/null +++ b/packages/browser-pay/src/interceptor.ts @@ -0,0 +1,1249 @@ +import { CdpConnection, type CdpEvent } from "./cdp.js"; +import { + paymentIdempotencyKey, + type RelayedHttpResponse, + type RelayResponse, +} from "./contract.js"; +import { + redactHeld, + textHoldsPlaceholderNumber, + type HeldPlaceholder, +} from "./detect.js"; +import { isRecord } from "./guards.js"; +import { silentLogger, type PayLogger } from "./logger.js"; +import { + isRelayTimeout, + RelayTimeoutError, + type PaymentRelay, +} from "./relay.js"; + +export interface AttachOptions { + relay: PaymentRelay; + /** The one placeholder the agent was given for this checkout. */ + placeholder: HeldPlaceholder; + /** Relay profile, for example `shopify-card-vault`. */ + profileId: string; + /** + * How long to wait for the relay before failing the paused request. + * The relay is not retried. `outcome: "unknown"` is not sent again. + * A relay that ignores the abort signal still fails here. One waiter's + * timeout does not abort a shared flight that another waiter still holds. + */ + timeoutMs?: number; + /** Defaults to a logger that discards every line. */ + logger?: PayLogger; +} + +export type FailureReason = + | "intercept_unavailable" + | "unknown_session" + | "unreadable_body" + | "detached" + | "page_origin" + | "unknown" + | "timeout" + | "network" + | "response_credential"; + +export type InterceptorEvent = + | { type: "paused"; url: string; method: string; targetType: string } + | { type: "relayed"; url: string; status: number } + | { type: "relayed_unfulfilled"; url: string; status: number } + | { type: "refused"; url: string; reason: string } + | { type: "failed"; url: string; reason: FailureReason }; + +export interface InterceptorHandle { + detach(): Promise; + /** Subscribe to outcomes. Returns an unsubscribe function. */ + on(listener: (event: InterceptorEvent) => void): () => void; +} + +/** + * A browser CDP websocket URL, or a Playwright/Puppeteer browser or page that + * can provide one. The interceptor always opens its own connection. + */ +export type InterceptorTarget = + | string + | { wsEndpoint(): string } + | { browser(): { wsEndpoint(): string } | null } + | { context(): { browser(): { wsEndpoint(): string } | null } | null }; + +const DEFAULT_TIMEOUT_MS = 20_000; + +const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; +const CHILD_FILTER = [{ type: "iframe" }]; + +/** GET/HEAD with no body and no held number in the URL. Safe to continue immediately. */ +function canContinueWithoutReading( + method: string, + url: string, + hasBody: boolean, + placeholderNumber: string, +): boolean { + const verb = method.toUpperCase(); + if (verb !== "GET" && verb !== "HEAD") return false; + if (hasBody) return false; + return !textHoldsPlaceholderNumber(url, placeholderNumber); +} + +/** + * Captcha widgets and the session solver. Holding them on Fetch or the + * debugger stalls the solver past its own timeout. + */ +function isSolverTraffic(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + const host = parsed.hostname.toLowerCase(); + if ( + host === "hcaptcha.com" || + host.endsWith(".hcaptcha.com") || + host === "recaptcha.net" || + host.endsWith(".recaptcha.net") || + host === "challenges.cloudflare.com" || + host === "anchorbrowser.io" || + host.endsWith(".anchorbrowser.io") + ) { + return true; + } + return parsed.pathname.toLowerCase().includes("/recaptcha/"); +} + +// CDP writes the body bytes itself. These headers would describe a different +// representation (gzip, a length from the processor hop) than the bytes we fulfill. +const RESPONSE_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "content-encoding", + "content-length", +]); + +interface Session { + sessionId: string; + targetId: string; + type: string; + url: string; + parentSessionId?: string; + fetchEnabled: boolean; +} + +// Blank frames stay armed. Disarming about:blank and arming again on the +// next URL drops the navigation that leaves the blank document. +function shouldArm(session: Session): boolean { + return !isHandsOffTarget(session); +} + +function isHandsOffTarget(session: Session): boolean { + if ( + session.type === "worker" || + session.type === "shared_worker" || + session.type === "service_worker" + ) { + return true; + } + if (session.url.startsWith("chrome-extension:")) return true; + return isSolverTraffic(session.url); +} + +type InflightPhase = + | { status: "open" } + | { status: "continuing"; done: Promise } + | { status: "done" }; + +interface Inflight { + key: string; + sessionId: string; + requestId: string; + phase: InflightPhase; +} + +interface PausedRequest { + url: string; + method: string; + headers?: unknown; + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; + networkId?: string; +} + +interface Flight { + promise: Promise; + controller: AbortController; + waiters: number; +} + +type HeldRead = + | { kind: "unreadable" } + | { kind: "miss" } + | { kind: "held"; body: string }; + +type Decision = + | { action: "continue" } + | { action: "fail"; reason: FailureReason } + | { action: "refused"; reason: string } + | { action: "relayed"; response: RelayedHttpResponse }; + +type SettleAction = "continue" | "fail" | { fulfill: RelayedHttpResponse }; + +type Listener = (event: InterceptorEvent) => void; + +export async function attachInterceptor( + target: InterceptorTarget, + options: AttachOptions, +): Promise { + assertAttachOptions(options); + const connection = await CdpConnection.connect(resolveCdpUrl(target)); + try { + return await new PaymentInterceptor(connection, options).start(); + } catch (error) { + connection.close(); + throw error; + } +} + +class PaymentInterceptor { + private readonly sessions = new Map(); + private readonly byTarget = new Map(); + private readonly enabledSessions = new Set(); + private readonly listeners = new Set(); + private readonly inflight = new Map(); + private readonly relayFlights = new Map(); + private readonly unknownKeys = new Set(); + private readonly loggedRefusals = new Set(); + private readonly pendingArms = new Set>(); + private readonly timeoutMs: number; + private readonly logger: PayLogger; + private backlog: InterceptorEvent[] | null = []; + private buffering = true; + private closed = false; + + constructor( + private readonly connection: CdpConnection, + private readonly options: AttachOptions, + ) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.logger = options.logger ?? silentLogger; + } + + private get number(): string { + return this.options.placeholder.number; + } + + async start(): Promise { + this.connection.onEvent((event) => this.dispatch(event)); + await this.connection.send("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: TARGET_FILTER, + }); + await this.connection + .send("Target.setDiscoverTargets", { discover: true }) + .catch(() => {}); + await this.attachExistingFrames(); + await Promise.all([...this.pendingArms]); + this.buffering = false; + return { + on: (listener) => this.subscribe(listener), + detach: () => this.detach(), + }; + } + + private dispatch(event: CdpEvent): void { + if (event.method === "Fetch.requestPaused") { + this.onPaused(event); + return; + } + if (this.closed) return; + if (event.method === "Target.attachedToTarget") this.onAttached(event); + else if (event.method === "Target.detachedFromTarget") + this.onDetached(event); + else if (event.method === "Target.targetInfoChanged") { + this.onTargetInfoChanged(event); + } + } + + private onAttached(event: CdpEvent): void { + const params = parseAttachedTarget(event.params); + if (!params) return; + const sessionId = params.sessionId; + const info = params.targetInfo; + if (this.byTarget.has(info.targetId)) { + // setAutoAttach already owns this target. A second session would pause + // every request twice. + void this.resumeIfWaiting(sessionId, params.waitingForDebugger).then(() => + this.connection + .send("Target.detachFromTarget", { sessionId }) + .catch(() => {}), + ); + return; + } + const session = this.trackSession({ + sessionId, + targetId: info.targetId, + type: info.type, + url: info.url, + fetchEnabled: false, + ...(event.sessionId ? { parentSessionId: event.sessionId } : {}), + }); + const arm = (async () => { + if (info.type === "page" || info.type === "iframe") { + await this.autoAttachChildren(sessionId); + } + await this.enableSession(session.sessionId, params.waitingForDebugger); + })(); + this.pendingArms.add(arm); + void arm.finally(() => { + this.pendingArms.delete(arm); + }); + } + + private onDetached(event: CdpEvent): void { + const sessionId = readStringField(event.params, "sessionId"); + if (!sessionId) return; + const session = this.sessions.get(sessionId); + if (!session) return; + this.sessions.delete(sessionId); + this.enabledSessions.delete(sessionId); + if (this.byTarget.get(session.targetId) === session) { + this.byTarget.delete(session.targetId); + } + } + + private onTargetInfoChanged(event: CdpEvent): void { + const info = parseTargetInfoChange(event.params); + if (!info) return; + const session = this.byTarget.get(info.targetId); + if (!session) return; + session.url = info.url; + if (!shouldArm(session)) { + this.disarm(session); + return; + } + if (!session.fetchEnabled) void this.armOrClose(session); + } + + private disarm(session: Session): void { + if (session.fetchEnabled) { + session.fetchEnabled = false; + void this.connection + .send("Fetch.disable", {}, session.sessionId) + .catch(() => {}); + } + void this.resumeIfWaiting(session.sessionId, true); + } + + private async enableSession( + sessionId: string, + waitingForDebugger: boolean, + ): Promise { + if (this.enabledSessions.has(sessionId)) { + await this.resumeIfWaiting(sessionId, waitingForDebugger); + return; + } + this.enabledSessions.add(sessionId); + const session = this.sessions.get(sessionId); + if (!session || !shouldArm(session)) { + await this.resumeIfWaiting(sessionId, waitingForDebugger); + return; + } + // Fetch has to be on before the target runs, or the card iframe can post + // before we are listening. + const armed = await this.armOrClose(session); + if (!armed) return; + await this.resumeIfWaiting(sessionId, waitingForDebugger); + } + + private async armOrClose(session: Session): Promise { + const armed = await armFetch(this.connection, session); + if (armed) return true; + if (this.closed) return false; + this.emit({ + type: "failed", + url: this.safe(session.url || session.targetId), + reason: "intercept_unavailable", + }); + await this.connection + .send("Target.closeTarget", { targetId: session.targetId }) + .catch(() => {}); + return false; + } + + private async autoAttachChildren(sessionId: string): Promise { + await this.connection + .send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: CHILD_FILTER, + }, + sessionId, + ) + .catch(() => {}); + } + + private async resumeIfWaiting( + sessionId: string, + waitingForDebugger: boolean, + ): Promise { + if (!waitingForDebugger) return; + await this.connection + .send("Runtime.runIfWaitingForDebugger", {}, sessionId) + .catch(() => {}); + } + + private async attachExistingFrames(): Promise { + const listed = await this.connection + .send("Target.getTargets") + .catch(() => undefined); + for (const info of parseTargetInfos(listed)) { + if (info.type !== "iframe") continue; + if (this.byTarget.has(info.targetId)) continue; + const attached = await this.connection + .send("Target.attachToTarget", { + targetId: info.targetId, + flatten: true, + }) + .catch(() => undefined); + const sessionId = readAttachedSessionId(attached); + if (!sessionId || this.sessions.has(sessionId)) continue; + const session = this.trackSession({ + sessionId, + targetId: info.targetId, + type: "iframe", + url: info.url, + fetchEnabled: false, + }); + await this.autoAttachChildren(sessionId); + await this.enableSession(session.sessionId, false); + } + } + + private trackSession(session: Session): Session { + this.sessions.set(session.sessionId, session); + this.byTarget.set(session.targetId, session); + return session; + } + + private onPaused(event: CdpEvent): void { + const sessionId = event.sessionId; + const parsed = parsePaused(event.params); + if (!sessionId || !parsed) return; + const { requestId, request } = parsed; + if (!this.sessions.has(sessionId)) { + void failRequest(this.connection, sessionId, requestId); + this.emit({ + type: "failed", + url: this.safe(request.url), + reason: "unknown_session", + }); + return; + } + const key = inflightKey(sessionId, requestId); + if (this.inflight.has(key)) return; + const entry: Inflight = { + key, + sessionId, + requestId, + phase: { status: "open" }, + }; + this.inflight.set(key, entry); + if ( + canContinueWithoutReading( + request.method, + request.url, + postDataPlan(request) !== "empty", + this.number, + ) + ) { + void this.settle(entry, "continue"); + return; + } + void this.handlePaused(entry, request); + } + + private async handlePaused( + entry: Inflight, + request: PausedRequest, + ): Promise { + const safeUrl = this.safe(request.url); + try { + const decision = await this.decide(entry, request, safeUrl); + await this.apply(entry, safeUrl, decision); + } catch { + await this.settle(entry, "fail"); + this.emit({ type: "failed", url: safeUrl, reason: "network" }); + } + } + + private async decide( + entry: Inflight, + request: PausedRequest, + safeUrl: string, + ): Promise { + const read = await this.readHeld(entry.sessionId, request); + if (read.kind === "unreadable") { + return { action: "fail", reason: "unreadable_body" }; + } + if (read.kind === "miss") return { action: "continue" }; + if (this.closed) return { action: "fail", reason: "detached" }; + const targetType = this.sessions.get(entry.sessionId)?.type ?? ""; + this.emit({ + type: "paused", + url: safeUrl, + method: request.method, + targetType, + }); + const pageOrigin = await readPageOrigin( + this.connection, + this.sessions, + entry.sessionId, + ); + if (!pageOrigin || this.closed) { + return { + action: "fail", + reason: pageOrigin ? "detached" : "page_origin", + }; + } + const idempotencyKey = paymentIdempotencyKey( + request.method, + request.url, + read.body, + ); + if (this.unknownKeys.has(idempotencyKey)) { + return { action: "fail", reason: "unknown" }; + } + const relayed = await this.callRelay( + request, + read.body, + pageOrigin, + idempotencyKey, + ); + if (relayed.action === "fail") return relayed; + return this.decisionForOutcome(relayed.response, idempotencyKey); + } + + private async readHeld( + sessionId: string, + request: PausedRequest, + ): Promise { + const urlHeld = textHoldsPlaceholderNumber(request.url, this.number); + if (urlHeld && this.closed) return { kind: "held", body: "" }; + if (postDataPlan(request) === "empty") { + return urlHeld ? { kind: "held", body: "" } : { kind: "miss" }; + } + const body = await readBody(this.connection, sessionId, request); + if (body === undefined) return { kind: "unreadable" }; + if (urlHeld || textHoldsPlaceholderNumber(body, this.number)) { + return { kind: "held", body }; + } + return { kind: "miss" }; + } + + private async callRelay( + request: PausedRequest, + body: string, + pageOrigin: string, + idempotencyKey: string, + ): Promise< + | { action: "ok"; response: RelayResponse } + | { action: "fail"; reason: FailureReason } + > { + const flight = this.relayOnce(idempotencyKey, (signal) => + this.options.relay.relay( + { + injectionRef: this.options.placeholder.injectionRef, + profileId: this.options.profileId, + pageOrigin, + request: { + method: request.method, + url: request.url, + headers: forwardedHeaders(request.headers), + body, + }, + idempotencyKey, + }, + { signal }, + ), + ); + try { + const response = await failAfter(flight.promise, this.timeoutMs); + this.releaseWaiter(flight); + return { action: "ok", response }; + } catch (error) { + this.releaseWaiter(flight); + return { + action: "fail", + reason: this.closed ? "detached" : failureReason(error), + }; + } + } + + private decisionForOutcome( + response: RelayResponse, + idempotencyKey: string, + ): Decision { + if (response.outcome === "unknown") { + this.unknownKeys.add(idempotencyKey); + return { action: "fail", reason: "unknown" }; + } + if (response.outcome === "refused") { + this.logRefusalOnce(idempotencyKey, response.reason); + return { action: "refused", reason: response.reason }; + } + if (textHoldsPlaceholderNumber(response.response.body, this.number)) { + return { action: "fail", reason: "response_credential" }; + } + return { action: "relayed", response: response.response }; + } + + private logRefusalOnce(idempotencyKey: string, reason: string): void { + if (this.loggedRefusals.has(idempotencyKey)) return; + this.loggedRefusals.add(idempotencyKey); + this.logger.warn("payment relay refused the paused request", { reason }); + } + + private async apply( + entry: Inflight, + safeUrl: string, + decision: Decision, + ): Promise { + if (decision.action === "continue") { + await this.settle(entry, "continue"); + return; + } + if (decision.action === "fail") { + await this.settle(entry, "fail"); + this.emit({ type: "failed", url: safeUrl, reason: decision.reason }); + return; + } + if (decision.action === "refused") { + await this.settle(entry, "fail"); + this.emit({ type: "refused", url: safeUrl, reason: decision.reason }); + return; + } + const fulfilled = await this.settle(entry, { fulfill: decision.response }); + this.emit( + fulfilled + ? { type: "relayed", url: safeUrl, status: decision.response.status } + : { + type: "relayed_unfulfilled", + url: safeUrl, + status: decision.response.status, + }, + ); + } + + private async settle( + entry: Inflight, + action: SettleAction, + ): Promise { + if (entry.phase.status !== "open") return false; + if (action === "continue") { + const done = continueRequest( + this.connection, + entry.sessionId, + entry.requestId, + ).finally(() => { + entry.phase = { status: "done" }; + this.inflight.delete(entry.key); + }); + entry.phase = { status: "continuing", done }; + await done; + return true; + } + entry.phase = { status: "done" }; + try { + if (action === "fail") { + await failRequest(this.connection, entry.sessionId, entry.requestId); + return true; + } + await fulfillRequest( + this.connection, + entry.sessionId, + entry.requestId, + action.fulfill, + ); + return true; + } catch { + await failRequest(this.connection, entry.sessionId, entry.requestId); + return false; + } finally { + this.inflight.delete(entry.key); + } + } + + private async drainInflight(): Promise { + const entries = [...this.inflight.values()]; + await Promise.all( + entries.map(async (entry) => { + if (entry.phase.status === "continuing") { + await entry.phase.done; + return; + } + if (entry.phase.status === "open") await this.settle(entry, "fail"); + }), + ); + } + + private relayOnce( + key: string, + start: (signal: AbortSignal) => Promise, + ): Flight { + const existing = this.relayFlights.get(key); + if (existing && !existing.controller.signal.aborted) { + existing.waiters += 1; + return existing; + } + const controller = new AbortController(); + const promise = start(controller.signal).finally(() => { + const current = this.relayFlights.get(key); + if (current?.controller === controller) this.relayFlights.delete(key); + }); + void promise.catch(() => {}); + const flight: Flight = { controller, waiters: 1, promise }; + this.relayFlights.set(key, flight); + return flight; + } + + private releaseWaiter(flight: Flight): void { + flight.waiters -= 1; + if (flight.waiters > 0) return; + flight.controller.abort(); + } + + private async detach(): Promise { + if (this.closed) return; + this.closed = true; + this.buffering = false; + this.backlog = null; + for (const flight of this.relayFlights.values()) flight.controller.abort(); + await this.drainInflight(); + for (const sessionId of this.sessions.keys()) { + await this.connection + .send("Fetch.disable", {}, sessionId) + .catch(() => {}); + } + // A pause delivered while disable was in flight is still failed or + // continued here, before the socket goes away. + await this.drainInflight(); + await this.connection + .send("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }) + .catch(() => {}); + this.connection.close(); + } + + private subscribe(listener: Listener): () => void { + const missed = this.backlog; + this.backlog = null; + this.listeners.add(listener); + if (missed) { + for (const event of missed) this.deliver(listener, event); + } + return () => { + this.listeners.delete(listener); + }; + } + + private emit(event: InterceptorEvent): void { + if (this.buffering && this.backlog) { + this.backlog.push(event); + return; + } + for (const listener of this.listeners) this.deliver(listener, event); + } + + private deliver(listener: Listener, event: InterceptorEvent): void { + try { + listener(event); + } catch { + // A listener must not break interception. + } + } + + private safe(value: string): string { + return redactHeld(value, this.number); + } +} + +function inflightKey(sessionId: string, requestId: string): string { + return `${sessionId}\n${requestId}`; +} + +function failureReason(error: unknown): FailureReason { + return isRelayTimeout(error) ? "timeout" : "network"; +} + +function failAfter(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new RelayTimeoutError()); + }, ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject( + error instanceof Error ? error : new Error("Payment relay failed"), + ); + }, + ); + }); +} + +type PostDataPlan = "inline" | "entries" | "fetch" | "empty"; + +function postDataPlan(request: PausedRequest): PostDataPlan { + if (typeof request.postData === "string" && request.postData.length > 0) { + return "inline"; + } + if (request.postDataEntries && request.postDataEntries.length > 0) { + const missing = request.postDataEntries.some( + (entry) => typeof entry.bytes !== "string" || entry.bytes.length === 0, + ); + return missing ? "fetch" : "entries"; + } + if (request.hasPostData) return "fetch"; + return "empty"; +} + +async function readBody( + connection: CdpConnection, + sessionId: string, + request: PausedRequest, +): Promise { + const plan = postDataPlan(request); + if (plan === "empty") return ""; + if (plan === "inline") return request.postData ?? ""; + if (plan === "entries") { + return Buffer.concat( + (request.postDataEntries ?? []).map((entry) => + Buffer.from(entry.bytes ?? "", "base64"), + ), + ).toString("utf8"); + } + if (!request.networkId) return undefined; + try { + const result = await connection.send( + "Network.getRequestPostData", + { requestId: request.networkId }, + sessionId, + ); + const postData = readStringField(result, "postData"); + if (!postData) return undefined; + const body = + isRecord(result) && result.base64Encoded === true + ? Buffer.from(postData, "base64").toString("utf8") + : postData; + if (isMultipart(request.headers) && multipartOmitsFileBytes(body)) { + return undefined; + } + return body; + } catch { + return undefined; + } +} + +async function readPageOrigin( + connection: CdpConnection, + sessions: Map, + sessionId: string, +): Promise { + const pageSessionId = topPageSession(sessions, sessionId); + if (pageSessionId) { + const fromPage = await originFromSession( + connection, + sessions, + pageSessionId, + ); + if (fromPage) return fromPage; + } + const ancestors = await connection + .send( + "Runtime.evaluate", + { + expression: + "location.ancestorOrigins[location.ancestorOrigins.length - 1]", + returnByValue: true, + }, + sessionId, + ) + .catch(() => null); + const value = evaluatedString(ancestors); + if (value && /^https?:\/\//.test(value)) return value; + return undefined; +} + +async function originFromSession( + connection: CdpConnection, + sessions: Map, + sessionId: string, +): Promise { + const evaluated = await connection + .send( + "Runtime.evaluate", + { expression: "location.origin", returnByValue: true }, + sessionId, + ) + .catch(() => null); + const value = evaluatedString(evaluated); + if (value && /^https?:\/\//.test(value)) return value; + const url = sessions.get(sessionId)?.url; + if (!url) return undefined; + try { + const origin = new URL(url).origin; + return /^https?:\/\//.test(origin) ? origin : undefined; + } catch { + return undefined; + } +} + +function topPageSession( + sessions: Map, + sessionId: string, +): string | undefined { + let current: string | undefined = sessionId; + const visited = new Set(); + while (current && !visited.has(current)) { + visited.add(current); + const session = sessions.get(current); + if (!session) break; + if (session.type === "page") return current; + if (!session.parentSessionId) break; + current = session.parentSessionId; + } + const pages = [...sessions.values()].filter( + (session) => session.type === "page", + ); + if (pages.length === 1) return pages[0].sessionId; + return undefined; +} + +const FORWARDED_HEADERS = new Set([ + "accept", + "content-type", + "shopify-identification-signature", +]); + +function forwardedHeaders(raw: unknown): Record { + const out: Record = {}; + const take = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!FORWARDED_HEADERS.has(key) || value.length > 8192) return; + out[key] = value; + }; + if (Array.isArray(raw)) { + for (const entry of raw) { + if ( + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string" + ) { + take(entry[0], entry[1]); + continue; + } + if (isRecord(entry) && typeof entry.name === "string") { + take(entry.name, typeof entry.value === "string" ? entry.value : ""); + } + } + return out; + } + if (isRecord(raw)) { + for (const [key, value] of Object.entries(raw)) { + if (typeof value === "string") take(key, value); + } + } + return out; +} + +function parseAttachedTarget(value: unknown): + | { + sessionId: string; + waitingForDebugger: boolean; + targetInfo: { targetId: string; type: string; url: string }; + } + | undefined { + if (!isRecord(value) || typeof value.sessionId !== "string") return undefined; + if (!isRecord(value.targetInfo)) return undefined; + const info = value.targetInfo; + if (typeof info.targetId !== "string" || typeof info.type !== "string") { + return undefined; + } + return { + sessionId: value.sessionId, + waitingForDebugger: value.waitingForDebugger === true, + targetInfo: { + targetId: info.targetId, + type: info.type, + url: typeof info.url === "string" ? info.url : "", + }, + }; +} + +function parsePaused( + value: unknown, +): { requestId: string; request: PausedRequest } | undefined { + if (!isRecord(value) || typeof value.requestId !== "string") return undefined; + if (!isRecord(value.request)) return undefined; + const request = value.request; + return { + requestId: value.requestId, + request: { + url: typeof request.url === "string" ? request.url : "", + method: typeof request.method === "string" ? request.method : "GET", + headers: request.headers, + postData: + typeof request.postData === "string" ? request.postData : undefined, + hasPostData: request.hasPostData === true, + postDataEntries: parsePostDataEntries(request.postDataEntries), + ...(typeof value.networkId === "string" && value.networkId.length > 0 + ? { networkId: value.networkId } + : {}), + }, + }; +} + +function parsePostDataEntries( + value: unknown, +): { bytes?: string }[] | undefined { + if (!Array.isArray(value)) return undefined; + const entries: { bytes?: string }[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + entries.push({}); + continue; + } + entries.push(typeof entry.bytes === "string" ? { bytes: entry.bytes } : {}); + } + return entries; +} + +function parseTargetInfoChange( + value: unknown, +): { targetId: string; url: string } | undefined { + if (!isRecord(value) || !isRecord(value.targetInfo)) return undefined; + const info = value.targetInfo; + if (typeof info.targetId !== "string" || typeof info.url !== "string") { + return undefined; + } + return { targetId: info.targetId, url: info.url }; +} + +function evaluatedString(value: unknown): string | undefined { + if (!isRecord(value) || !isRecord(value.result)) return undefined; + return typeof value.result.value === "string" + ? value.result.value + : undefined; +} + +function readStringField(value: unknown, key: string): string | undefined { + if (!isRecord(value)) return undefined; + const field = value[key]; + return typeof field === "string" ? field : undefined; +} + +function headerValue(headers: unknown, name: string): string { + const wanted = name.toLowerCase(); + if (Array.isArray(headers)) { + for (const entry of headers) { + if ( + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string" && + entry[0].toLowerCase() === wanted + ) { + return entry[1]; + } + if ( + isRecord(entry) && + typeof entry.name === "string" && + entry.name.toLowerCase() === wanted && + typeof entry.value === "string" + ) { + return entry.value; + } + } + return ""; + } + if (isRecord(headers)) { + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === wanted && typeof value === "string") { + return value; + } + } + } + return ""; +} + +function isMultipart(headers: unknown): boolean { + return headerValue(headers, "content-type") + .toLowerCase() + .includes("multipart/"); +} + +// The retrieved body keeps a file part's filename header and drops its bytes. +function multipartOmitsFileBytes(body: string): boolean { + return /content-disposition:[^\r\n]*[;\s]filename\s*\*?=/i.test(body); +} + +async function armFetch( + connection: CdpConnection, + session: Session, +): Promise { + if (session.fetchEnabled) return true; + try { + await connection + .send("Network.enable", {}, session.sessionId) + .catch(() => {}); + await connection.send( + "Fetch.enable", + { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, + session.sessionId, + ); + } catch { + return false; + } + session.fetchEnabled = true; + return true; +} + +async function continueRequest( + connection: CdpConnection, + sessionId: string, + requestId: string, +): Promise { + try { + await connection.send("Fetch.continueRequest", { requestId }, sessionId); + } catch { + await failRequest(connection, sessionId, requestId); + } +} + +async function failRequest( + connection: CdpConnection, + sessionId: string, + requestId: string, +): Promise { + await connection + .send( + "Fetch.failRequest", + { requestId, errorReason: "BlockedByClient" }, + sessionId, + ) + .catch(() => {}); +} + +async function fulfillRequest( + connection: CdpConnection, + sessionId: string, + requestId: string, + response: RelayedHttpResponse, +): Promise { + const responseHeaders = Object.entries(response.headers).flatMap( + ([name, value]) => { + if (RESPONSE_HOP.has(name.toLowerCase())) return []; + return [{ name, value }]; + }, + ); + await connection.send( + "Fetch.fulfillRequest", + { + requestId, + responseCode: response.status, + responseHeaders, + body: Buffer.from(response.body, "utf8").toString("base64"), + }, + sessionId, + ); +} + +function parseTargetInfos( + value: unknown, +): { targetId: string; type: string; url: string }[] { + if (!isRecord(value) || !Array.isArray(value.targetInfos)) return []; + const infos: { targetId: string; type: string; url: string }[] = []; + for (const entry of value.targetInfos) { + if (!isRecord(entry)) continue; + if (typeof entry.targetId !== "string" || typeof entry.type !== "string") { + continue; + } + infos.push({ + targetId: entry.targetId, + type: entry.type, + url: typeof entry.url === "string" ? entry.url : "", + }); + } + return infos; +} + +function readAttachedSessionId(value: unknown): string | undefined { + if (!isRecord(value) || typeof value.sessionId !== "string") return undefined; + return value.sessionId.length > 0 ? value.sessionId : undefined; +} + +function assertAttachOptions(options: AttachOptions): void { + if (typeof options.relay?.relay !== "function") { + throw new Error("A payment relay is required"); + } + if (!options.profileId) { + throw new Error("profileId is required"); + } + const digits = options.placeholder?.number?.replace(/\D/g, "") ?? ""; + if (!options.placeholder?.injectionRef || digits.length === 0) { + throw new Error( + "placeholder.injectionRef and placeholder.number are required", + ); + } +} + +function resolveCdpUrl(target: InterceptorTarget): string { + if (typeof target === "string") { + if (!target) throw new Error("A CDP websocket URL is required"); + return target; + } + const direct = callString(target, "wsEndpoint"); + if (direct) return direct; + const browser = callObject(target, "browser"); + const fromBrowser = browser ? callString(browser, "wsEndpoint") : undefined; + if (fromBrowser) return fromBrowser; + const context = callObject(target, "context"); + const nested = context ? callObject(context, "browser") : undefined; + const fromContext = nested ? callString(nested, "wsEndpoint") : undefined; + if (fromContext) return fromContext; + throw new Error( + "Pass a CDP websocket URL, or a Playwright/Puppeteer browser or page whose browser exposes wsEndpoint()", + ); +} + +function callString(target: object, key: string): string | undefined { + const method = Reflect.get(target, key); + if (typeof method !== "function") return undefined; + const value: unknown = method.call(target); + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function callObject(target: object, key: string): object | undefined { + const method = Reflect.get(target, key); + if (typeof method !== "function") return undefined; + const value: unknown = method.call(target); + return typeof value === "object" && value !== null ? value : undefined; +} diff --git a/packages/browser-pay/src/logger.ts b/packages/browser-pay/src/logger.ts new file mode 100644 index 000000000..71b49ca46 --- /dev/null +++ b/packages/browser-pay/src/logger.ts @@ -0,0 +1,10 @@ +export interface PayLogger { + warn( + message: string, + fields?: Readonly>, + ): void; +} + +export const silentLogger: PayLogger = { + warn() {}, +}; diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts new file mode 100644 index 000000000..0f52a77f3 --- /dev/null +++ b/packages/browser-pay/src/relay.test.ts @@ -0,0 +1,276 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { RelayRequest } from "./contract"; +import { httpPaymentRelay } from "./relay"; + +const REQUEST: RelayRequest = { + injectionRef: "inj_fixture_shopify", + profileId: "shopify-card-vault", + pageOrigin: "https://shop.example", + request: { + method: "POST", + url: "https://checkout.pci.shopifyinc.com/sessions", + headers: { + accept: "application/json", + "content-type": "application/json", + "shopify-identification-signature": "fixture-signature", + }, + body: '{"credit_card":{"number":"4242424242424242"}}', + }, + idempotencyKey: "a".repeat(64), +}; + +const RELAYED = { + outcome: "relayed", + response: { + status: 200, + headers: { "content-type": "application/json" }, + body: '{"id":"west-fixture-session"}', + }, +}; + +function listen(handler: http.RequestListener): Promise { + const server = http.createServer(handler); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +function origin(server: http.Server): string { + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + +describe("httpPaymentRelay", () => { + it("accepts https and loopback http, and rejects other http base URLs", () => { + const options = { + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }; + expect(() => + httpPaymentRelay({ ...options, baseUrl: "https://api.sapiom.ai" }), + ).not.toThrow(); + expect(() => + httpPaymentRelay({ ...options, baseUrl: "http://localhost:3000" }), + ).not.toThrow(); + expect(() => + httpPaymentRelay({ ...options, baseUrl: "http://127.0.0.1:3000" }), + ).not.toThrow(); + expect(() => + httpPaymentRelay({ ...options, baseUrl: "http://[::1]:3000" }), + ).not.toThrow(); + expect(() => + httpPaymentRelay({ ...options, baseUrl: "http://example.com" }), + ).toThrow( + "Payment relay baseUrl must be https (http is only allowed on loopback)", + ); + for (const baseUrl of [ + "https://api.sapiom.ai#/payment/relay", + "https://api.sapiom.ai/#", + "https://api.sapiom.ai?", + "https://api.sapiom.ai?x=1", + ]) { + expect(() => httpPaymentRelay({ ...options, baseUrl })).toThrow( + "Payment relay baseUrl must not contain a query or fragment", + ); + } + }); + + it("POSTs David's body to the transaction path and does not retry", async () => { + const seen: { + url?: string; + headers?: http.IncomingHttpHeaders; + body?: string; + count: number; + } = { count: 0 }; + const server = await listen((req, res) => { + seen.count += 1; + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + seen.url = req.url; + seen.headers = req.headers; + seen.body = Buffer.concat(chunks).toString("utf8"); + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ reason: "not-a-refusal", echo: seen.body })); + }); + }); + try { + const relay = httpPaymentRelay({ + baseUrl: origin(server), + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay failed (HTTP 500)", + ); + expect(seen.count).toBe(1); + expect(seen.url).toBe( + "/v1/transactions/tx_fixture_shopify/payment/relay", + ); + expect(seen.headers?.["x-api-key"]).toBe("test-key"); + expect(seen.headers?.["idempotency-key"]).toBeUndefined(); + expect(JSON.parse(seen.body ?? "")).toEqual(REQUEST); + expect(JSON.parse(seen.body ?? "")).not.toHaveProperty("transactionId"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("returns a relayed response, a preserved refusal reason, and outcome unknown", async () => { + const payloads = [ + RELAYED, + { outcome: "refused", reason: "merchant_mismatch" }, + { + outcome: "refused", + reason: "future_reason", + body: REQUEST.request.body, + }, + { outcome: "unknown" }, + ]; + const server = await listen((_req, res) => { + const payload = payloads.shift(); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); + }); + try { + const relay = httpPaymentRelay({ + baseUrl: `${origin(server)}/`, + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }); + await expect(relay.relay(REQUEST)).resolves.toEqual(RELAYED); + await expect(relay.relay(REQUEST)).resolves.toEqual({ + outcome: "refused", + reason: "merchant_mismatch", + }); + await expect(relay.relay(REQUEST)).resolves.toEqual({ + outcome: "refused", + reason: "future_reason", + }); + await expect(relay.relay(REQUEST)).resolves.toEqual({ + outcome: "unknown", + }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("aborts when the caller signal fires", async () => { + let arrived: () => void = () => {}; + const requestArrived = new Promise((resolve) => { + arrived = resolve; + }); + const server = await listen((_req, res) => { + arrived(); + res.writeHead(200); + }); + const controller = new AbortController(); + try { + const relay = httpPaymentRelay({ + baseUrl: origin(server), + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }); + const pending = relay.relay(REQUEST, { signal: controller.signal }); + await requestArrived; + const expectation = expect(pending).rejects.toThrow( + "Payment relay timed out", + ); + controller.abort(); + await expectation; + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("does not read an outcome from a non-2xx body", async () => { + const payloads = [ + { status: 401, body: { outcome: "unknown" } }, + { + status: 500, + body: { outcome: "refused", reason: "already_relayed" }, + }, + ]; + const server = await listen((_req, res) => { + const payload = payloads.shift(); + res.writeHead(payload?.status ?? 500, { + "content-type": "application/json", + }); + res.end(JSON.stringify(payload?.body)); + }); + try { + const relay = httpPaymentRelay({ + baseUrl: origin(server), + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay failed (HTTP 401)", + ); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay failed (HTTP 500)", + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("refuses a redirect", async () => { + const server = await listen((_req, res) => { + res.writeHead(302, { location: "https://evil.example/steal" }); + res.end(); + }); + try { + const relay = httpPaymentRelay({ + baseUrl: origin(server), + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay refused a redirect", + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("aborts a stalled body when the caller signal fires after headers", async () => { + const controller = new AbortController(); + const relay = httpPaymentRelay({ + baseUrl: "https://relay.example", + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + fetchImpl: stalledBody, + }); + const pending = relay.relay(REQUEST, { signal: controller.signal }); + const expectation = expect(pending).rejects.toThrow( + "Payment relay timed out", + ); + controller.abort(); + await expectation; + }); +}); + +function stalledBody( + _input: Parameters[0], + init?: Parameters[1], +): Promise { + const stream = new ReadableStream({ + start(controller) { + const fail = () => { + try { + controller.error( + new DOMException("The operation was aborted.", "AbortError"), + ); + } catch { + // The stream may already be cancelled. + } + }; + if (init?.signal?.aborted) fail(); + else init?.signal?.addEventListener("abort", fail, { once: true }); + }, + }); + return Promise.resolve(new Response(stream, { status: 200 })); +} diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts new file mode 100644 index 000000000..697aa6c75 --- /dev/null +++ b/packages/browser-pay/src/relay.ts @@ -0,0 +1,164 @@ +import { + parseRelayResponse, + type RelayRequest, + type RelayResponse, +} from "./contract.js"; + +export interface RelayCallOptions { + signal?: AbortSignal; +} + +export interface PaymentRelay { + relay( + request: RelayRequest, + options?: RelayCallOptions, + ): Promise; +} + +export interface HttpRelayOptions { + baseUrl: string; + apiKey?: string; + transactionId: string; + fetchImpl?: typeof fetch; +} + +export class RelayTimeoutError extends Error { + constructor() { + super("Payment relay timed out"); + this.name = "RelayTimeoutError"; + } +} + +export function isRelayTimeout(error: unknown): boolean { + return error instanceof RelayTimeoutError || isAbortError(error); +} + +function isAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error.name === "AbortError" || error.name === "TimeoutError") + ); +} + +function assertBaseUrl(baseUrl: string): void { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error( + "Payment relay baseUrl must be https (http is only allowed on loopback)", + ); + } + if ( + baseUrl.includes("#") || + baseUrl.includes("?") || + url.hash || + url.search + ) { + throw new Error( + "Payment relay baseUrl must not contain a query or fragment", + ); + } + const host = url.hostname; + if (url.protocol === "https:") return; + if ( + url.protocol === "http:" && + (host === "localhost" || host === "127.0.0.1" || host === "[::1]") + ) { + return; + } + throw new Error( + "Payment relay baseUrl must be https (http is only allowed on loopback)", + ); +} + +function requireHttpOptions(options: HttpRelayOptions): { + baseUrl: string; + apiKey: string; + transactionId: string; + fetchImpl: typeof fetch; +} { + if (!options || typeof options.baseUrl !== "string" || !options.baseUrl) { + throw new Error("Payment relay baseUrl is required"); + } + assertBaseUrl(options.baseUrl); + if (!options.transactionId) { + throw new Error("Payment relay transactionId is required"); + } + const apiKey = options.apiKey ?? process.env.SAPIOM_API_KEY; + if (!apiKey) { + throw new Error( + "SAPIOM_API_KEY is required for the payment relay HTTP client", + ); + } + return { + baseUrl: options.baseUrl.replace(/\/$/, ""), + apiKey, + transactionId: options.transactionId, + fetchImpl: options.fetchImpl ?? fetch, + }; +} + +function transportError(error: unknown, signal?: AbortSignal): Error { + if (error instanceof RelayTimeoutError) return error; + if (signal?.aborted || isAbortError(error)) return new RelayTimeoutError(); + return new Error("Payment relay failed"); +} + +async function discardBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + +export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { + const config = requireHttpOptions(options); + return { + async relay(request, call) { + const url = `${config.baseUrl}/v1/transactions/${encodeURIComponent(config.transactionId)}/payment/relay`; + let response: Response; + try { + response = await config.fetchImpl(url, { + method: "POST", + redirect: "manual", + headers: { + accept: "application/json", + "content-type": "application/json", + "x-api-key": config.apiKey, + }, + body: JSON.stringify(request), + signal: call?.signal, + }); + } catch (error) { + throw transportError(error, call?.signal); + } + if (response.status < 200 || response.status >= 300) { + await discardBody(response); + if (response.status >= 300 && response.status < 400) { + throw new Error("Payment relay refused a redirect"); + } + throw new Error(`Payment relay failed (HTTP ${response.status})`); + } + let text: string; + try { + text = await response.text(); + } catch (error) { + throw transportError(error, call?.signal); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (parsed !== undefined) { + try { + return parseRelayResponse(parsed); + } catch { + // The thrown error must not include the response text. + } + } + throw new Error(`Payment relay failed (HTTP ${response.status})`); + }, + }; +} diff --git a/packages/browser-pay/test/stub-relay.ts b/packages/browser-pay/test/stub-relay.ts new file mode 100644 index 000000000..56980aeee --- /dev/null +++ b/packages/browser-pay/test/stub-relay.ts @@ -0,0 +1,15 @@ +import type { RelayRequest, RelayResponse } from "../src/contract"; +import type { PaymentRelay, RelayCallOptions } from "../src/relay"; + +export function stubPaymentRelay( + handler: ( + request: RelayRequest, + options?: RelayCallOptions, + ) => RelayResponse | Promise, +): PaymentRelay { + return { + relay(request, options) { + return Promise.resolve(handler(request, options)); + }, + }; +} diff --git a/packages/browser-pay/tsconfig.build.json b/packages/browser-pay/tsconfig.build.json new file mode 100644 index 000000000..8a3e9cb88 --- /dev/null +++ b/packages/browser-pay/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src" + }, + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/packages/browser-pay/tsconfig.cjs.json b/packages/browser-pay/tsconfig.cjs.json new file mode 100644 index 000000000..134d607e8 --- /dev/null +++ b/packages/browser-pay/tsconfig.cjs.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist/cjs", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/packages/browser-pay/tsconfig.esm.json b/packages/browser-pay/tsconfig.esm.json new file mode 100644 index 000000000..926f20c4a --- /dev/null +++ b/packages/browser-pay/tsconfig.esm.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./dist/esm", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/packages/browser-pay/tsconfig.examples.json b/packages/browser-pay/tsconfig.examples.json new file mode 100644 index 000000000..32b3b6b34 --- /dev/null +++ b/packages/browser-pay/tsconfig.examples.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src/**/*.ts", "examples/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/browser-pay/tsconfig.json b/packages/browser-pay/tsconfig.json new file mode 100644 index 000000000..68c310351 --- /dev/null +++ b/packages/browser-pay/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src/**/*.ts", "src/**/*.json", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be82293df..9f4ead315 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,45 @@ importers: specifier: ^5.4.2 version: 5.9.3 + packages/browser-pay: + devDependencies: + '@sapiom/tools': + specifier: workspace:* + version: link:../tools + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^20.11.30 + version: 20.19.43 + '@typescript-eslint/eslint-plugin': + specifier: ^7.3.1 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^7.3.1 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.43) + playwright: + specifier: ^1.61.0 + version: 1.61.1 + prettier: + specifier: ^3.2.5 + version: 3.8.4 + ts-jest: + specifier: ^29.1.2 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43))(typescript@5.9.3) + tsx: + specifier: ^4.20.3 + version: 4.23.0 + typescript: + specifier: ^5.4.2 + version: 5.9.3 + packages/cli: dependencies: '@sapiom/agent':