From 66fb760246af97ec999873bc223ca1b0c8a7fd2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 00:46:08 +0000 Subject: [PATCH 01/26] feat(browser-pay): pause placeholder card requests over CDP Add the browser interceptor, the T7 relay contract, and opt-in Shopify demo relays. A held placeholder is relayed or failed closed, never continued. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-interceptor.md | 5 + .github/labeler.yml | 1 + README.md | 3 + packages/browser-pay/.eslintrc.js | 20 + packages/browser-pay/CHANGELOG.md | 5 + packages/browser-pay/LICENSE | 21 + packages/browser-pay/README.md | 78 ++ packages/browser-pay/examples/attach.ts | 74 ++ .../browser-pay/examples/relays/bogus.test.ts | 110 +++ packages/browser-pay/examples/relays/bogus.ts | 55 ++ .../examples/relays/bt-proxy.test.ts | 109 +++ .../browser-pay/examples/relays/bt-proxy.ts | 93 +++ .../examples/relays/replace-card.ts | 237 ++++++ packages/browser-pay/examples/shopify-e2e.ts | 683 ++++++++++++++++++ packages/browser-pay/jest.config.js | 20 + packages/browser-pay/package.json | 75 ++ packages/browser-pay/src/cdp.ts | 273 +++++++ packages/browser-pay/src/contract.test.ts | 19 + packages/browser-pay/src/contract.ts | 105 +++ packages/browser-pay/src/detect.test.ts | 189 +++++ packages/browser-pay/src/detect.ts | 376 ++++++++++ packages/browser-pay/src/fixtures.ts | 24 + .../browser-pay/src/fixtures/refusals.json | 14 + .../src/fixtures/shopify-card-session.json | 28 + packages/browser-pay/src/index.ts | 44 ++ .../src/interceptor.integration.test.ts | 367 ++++++++++ packages/browser-pay/src/interceptor.ts | 653 +++++++++++++++++ packages/browser-pay/src/redact.ts | 19 + packages/browser-pay/src/relay.test.ts | 170 +++++ packages/browser-pay/src/relay.ts | 103 +++ packages/browser-pay/tsconfig.build.json | 12 + packages/browser-pay/tsconfig.cjs.json | 14 + packages/browser-pay/tsconfig.esm.json | 14 + packages/browser-pay/tsconfig.examples.json | 10 + packages/browser-pay/tsconfig.json | 10 + pnpm-lock.yaml | 39 + 36 files changed, 4072 insertions(+) create mode 100644 .changeset/browser-pay-interceptor.md create mode 100644 packages/browser-pay/.eslintrc.js create mode 100644 packages/browser-pay/CHANGELOG.md create mode 100644 packages/browser-pay/LICENSE create mode 100644 packages/browser-pay/README.md create mode 100644 packages/browser-pay/examples/attach.ts create mode 100644 packages/browser-pay/examples/relays/bogus.test.ts create mode 100644 packages/browser-pay/examples/relays/bogus.ts create mode 100644 packages/browser-pay/examples/relays/bt-proxy.test.ts create mode 100644 packages/browser-pay/examples/relays/bt-proxy.ts create mode 100644 packages/browser-pay/examples/relays/replace-card.ts create mode 100644 packages/browser-pay/examples/shopify-e2e.ts create mode 100644 packages/browser-pay/jest.config.js create mode 100644 packages/browser-pay/package.json create mode 100644 packages/browser-pay/src/cdp.ts create mode 100644 packages/browser-pay/src/contract.test.ts create mode 100644 packages/browser-pay/src/contract.ts create mode 100644 packages/browser-pay/src/detect.test.ts create mode 100644 packages/browser-pay/src/detect.ts create mode 100644 packages/browser-pay/src/fixtures.ts create mode 100644 packages/browser-pay/src/fixtures/refusals.json create mode 100644 packages/browser-pay/src/fixtures/shopify-card-session.json create mode 100644 packages/browser-pay/src/index.ts create mode 100644 packages/browser-pay/src/interceptor.integration.test.ts create mode 100644 packages/browser-pay/src/interceptor.ts create mode 100644 packages/browser-pay/src/redact.ts create mode 100644 packages/browser-pay/src/relay.test.ts create mode 100644 packages/browser-pay/src/relay.ts create mode 100644 packages/browser-pay/tsconfig.build.json create mode 100644 packages/browser-pay/tsconfig.cjs.json create mode 100644 packages/browser-pay/tsconfig.esm.json create mode 100644 packages/browser-pay/tsconfig.examples.json create mode 100644 packages/browser-pay/tsconfig.json diff --git a/.changeset/browser-pay-interceptor.md b/.changeset/browser-pay-interceptor.md new file mode 100644 index 000000000..f5d1804b7 --- /dev/null +++ b/.changeset/browser-pay-interceptor.md @@ -0,0 +1,5 @@ +--- +"@sapiom/browser-pay": minor +--- + +Add `@sapiom/browser-pay`, the browser-side payment interceptor. It attaches over CDP, pauses a request whose body carries the held placeholder card, and relays that request without ever continuing a placeholder to the payment provider. 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..6f2b5178e --- /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. 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..ea920392f --- /dev/null +++ b/packages/browser-pay/README.md @@ -0,0 +1,78 @@ +# @sapiom/browser-pay + +Browser-side payment interceptor for a card checkout in the agent's own Chromium. + +The agent types a placeholder card into the checkout. This module attaches over CDP, pauses the request whose body contains that placeholder, and sends it to the relay. The relay's response fulfills the paused request. The browser never sees a real card value. + +M0 pauses every request and inspects the body. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. + +## Attach + +`attachInterceptor` opens its own CDP connection. Pass a websocket URL, or a Playwright/Puppeteer browser or page that exposes one. The same function is what a later hosted-attach mode would call. + +An agent platform that already uses Sapiom for the browser creates a session, then attaches. Captcha solving, when the caller wants it, is the browser capability's `captchaSolver` flag. This package does not solve captchas. + +```typescript +import { randomUUID } from "node:crypto"; +import { createClient } from "@sapiom/tools"; +import { attachInterceptor, httpPaymentRelay } from "@sapiom/browser-pay"; + +const sapiom = createClient({ apiKey: process.env.SAPIOM_API_KEY }); +const session = await sapiom.browserAutomation.sessions.createManaged({ + idempotencyKey: randomUUID(), + recording: false, + captchaSolver: true, +}); +const handle = await attachInterceptor(session.cdpUrl, { + relay: httpPaymentRelay({ + baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", + apiKey: process.env.SAPIOM_API_KEY, + }), + transactionId: process.env.TRANSACTION_ID!, + placeholder: { + number: process.env.PLACEHOLDER_NUMBER!, + expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), + expYear: Number(process.env.PLACEHOLDER_EXP_YEAR), + cvc: process.env.PLACEHOLDER_CVC!, + injectionRef: process.env.INJECTION_REF!, + }, +}); +handle.on((event) => { + // paused, relayed, refused, failed. Card numbers are masked if one appears in a URL. +}); +``` + +A platform that already has a CDP URL skips session create: + +```typescript +await attachInterceptor(process.env.CDP_URL!, { relay, transactionId, placeholder }); +``` + +`examples/attach.ts` is that script. The HTTP relay calls `POST /v1/transactions/:id/payment/relay` with the `x-api-key` header. That route may not be deployed yet. Swap a demo relay in without changing the interceptor: both implement `PaymentRelay`. + +`merchantOrigin` on the relay request is the top-level page origin (scheme + host), not the card iframe's host. + +## Relay contract + +Types and fixtures live in this package (`RelayRequest`, `RelayResponse`, `shopifyCardSessionRoundTrip`, `relayRefusalFixtures`). The Shopify fixture posts to `https://checkout.pci.shopifyinc.com/sessions`. A refusal is `{ ok: false, reason }` and does not echo the request. Reasons: `wrong_key`, `unknown_placeholder`, `closed_placeholder`, `expired_placeholder`, `off_profile_destination`, `replay`, `merchant_origin`, `changed_total`, `cancelled`. + +Placeholder detection matches the backend's placeholder-detection rules (separators, 13–19 digits, Luhn, then expiry and CVC when those fields are present). It is duplicated here so a browser bundle does not import the backend. It should move to a shared package. + +## Shopify dev-store script + +`examples/shopify-e2e.ts` is opt-in and is not part of `pnpm test`. It launches headless Chromium with a CDP port (or `BROWSER=sapiom` to create a Sapiom browser session), attaches the interceptor, and drives the dev store through a second Playwright connection. The store is `https://1a2uh3-r7.myshopify.com`, variant `67652208394372`, Bogus Gateway test mode. + +Demo relays live under `examples/relays/` and are not published: + +- `RELAY=bogus` substitutes the Bogus Gateway success card (`1`, `12/2030`, `111`) and forwards. +- `RELAY=bt` sends the paused request through Basis Theory's ephemeral proxy. Set `BT_PRIVATE_KEY` and `BT_TOKEN_ID`. +- `RELAY=http` uses `httpPaymentRelay` (`SAPIOM_BASE_URL` + `SAPIOM_API_KEY`). + +```bash +cd packages/browser-pay +RELAY=bogus npx tsx examples/shopify-e2e.ts +RELAY=bt BT_PRIVATE_KEY=… BT_TOKEN_ID=… npx tsx examples/shopify-e2e.ts +MODE=refuse RELAY=bogus npx tsx examples/shopify-e2e.ts +``` + +`MODE=refuse` fails the card request closed so the placeholder is not forwarded and no order is confirmed. The script clicks pay once. If Shopify shows a captcha, it stops and says so. diff --git a/packages/browser-pay/examples/attach.ts b/packages/browser-pay/examples/attach.ts new file mode 100644 index 000000000..a77a9b574 --- /dev/null +++ b/packages/browser-pay/examples/attach.ts @@ -0,0 +1,74 @@ +/** + * 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=… \ + * PLACEHOLDER_NUMBER=… PLACEHOLDER_EXP_MONTH=… PLACEHOLDER_EXP_YEAR=… PLACEHOLDER_CVC=… \ + * npx tsx examples/attach.ts + */ +import { randomUUID } from "node:crypto"; +import { createClient } from "@sapiom/tools"; +import { + attachInterceptor, + httpPaymentRelay, + maskCardNumber, +} from "../src/index.js"; + +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; + if (!transactionId || !injectionRef || !number) { + throw new Error( + "TRANSACTION_ID, INJECTION_REF, and PLACEHOLDER_NUMBER are required", + ); + } + + const sapiom = createClient({ apiKey }); + const session = await sapiom.browserAutomation.sessions.createManaged({ + idempotencyKey: randomUUID(), + recording: false, + captchaSolver: true, + }); + const handle = await attachInterceptor(session.cdpUrl, { + relay: httpPaymentRelay({ + baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", + apiKey, + }), + transactionId, + placeholder: { + number, + expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), + expYear: Number(process.env.PLACEHOLDER_EXP_YEAR), + cvc: process.env.PLACEHOLDER_CVC ?? "", + injectionRef, + }, + }); + console.log(`interceptor attached; placeholder ${maskCardNumber(number)}`); + handle.on((event) => { + if (event.type === "relayed") console.log(`relayed ${event.status}`); + else if (event.type === "refused") console.log(`refused ${event.reason}`); + else if (event.type === "failed") console.log(`failed ${event.reason}`); + }); + + const shutdown = async () => { + await handle.detach(); + await sapiom.browserAutomation.sessions + .closeManaged(session.sessionId) + .catch(() => {}); + await sapiom.shutdown().catch(() => {}); + }; + process.on("SIGINT", () => { + void shutdown().finally(() => process.exit(0)); + }); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "attach failed"); + process.exit(1); +}); diff --git a/packages/browser-pay/examples/relays/bogus.test.ts b/packages/browser-pay/examples/relays/bogus.test.ts new file mode 100644 index 000000000..9fe8f3f7f --- /dev/null +++ b/packages/browser-pay/examples/relays/bogus.test.ts @@ -0,0 +1,110 @@ +import type { HeldPlaceholder, RelayRequest } from "../../src/index.js"; +import { bogusShopifyRelay } from "./bogus.js"; + +const PLACEHOLDER: HeldPlaceholder = { + number: "4242424242424242", + expMonth: 11, + expYear: 2029, + cvc: "321", + injectionRef: "inj_demo", +}; + +const BODY = JSON.stringify({ + credit_card: { + number: "4242-4242-4242-4242", + name: "Test Buyer", + month: 11, + year: 2029, + verification_value: "321", + }, + payment_session_scope: "scope", +}); + +function request(body = BODY): RelayRequest { + return { + transactionId: "tx_demo", + injectionRef: "inj_demo", + merchantOrigin: "https://1a2uh3-r7.myshopify.com", + url: "https://checkout.pci.shopifyinc.com/sessions?x=1", + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "999", + host: "checkout.pci.shopifyinc.com", + "shopify-identification-signature": "sig", + }, + body, + }; +} + +describe("bogusShopifyRelay", () => { + it("substitutes the Bogus Gateway success card and forwards once", async () => { + const seen: { url?: string; init?: RequestInit } = {}; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + seen.url = String(url); + seen.init = init; + return new Response(JSON.stringify({ id: "sess_bogus" }), { + status: 201, + headers: { + "content-type": "application/json", + "content-length": "20", + "set-cookie": "a=b", + }, + }); + }) as typeof fetch; + const relay = bogusShopifyRelay({ placeholder: PLACEHOLDER, fetchImpl }); + const response = await relay.relay(request()); + expect(seen.url).toBe("https://checkout.pci.shopifyinc.com/sessions?x=1"); + expect(seen.init?.method).toBe("POST"); + const headers = seen.init?.headers as Record; + expect(headers["shopify-identification-signature"]).toBe("sig"); + expect(headers["content-length"]).toBeUndefined(); + expect(headers.host).toBeUndefined(); + const forwarded = JSON.parse(String(seen.init?.body)); + expect(forwarded).toEqual({ + credit_card: { + number: "1", + name: "Test Buyer", + month: 12, + year: 2030, + verification_value: "111", + }, + payment_session_scope: "scope", + }); + expect(String(seen.init?.body)).not.toContain(PLACEHOLDER.number); + expect(String(seen.init?.body)).not.toContain(PLACEHOLDER.cvc); + expect(response).toEqual({ + ok: true, + status: 201, + headers: { + "content-type": "application/json", + "set-cookie": "a=b", + }, + body: '{"id":"sess_bogus"}', + }); + }); + + it("does not forward when the placeholder cannot be replaced", async () => { + const fetchImpl = jest.fn() as unknown as typeof fetch; + const relay = bogusShopifyRelay({ placeholder: PLACEHOLDER, fetchImpl }); + await expect(relay.relay(request('{"event":"ping"}'))).rejects.toThrow( + "Placeholder card fields were not replaced", + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("refuses a redirect instead of following it", async () => { + const fetchImpl = (async () => + new Response(null, { + status: 302, + headers: { location: "https://evil.example" }, + })) as typeof fetch; + const relay = bogusShopifyRelay({ placeholder: PLACEHOLDER, fetchImpl }); + await expect(relay.relay(request())).rejects.toThrow( + "Payment relay refused a redirect", + ); + }); +}); diff --git a/packages/browser-pay/examples/relays/bogus.ts b/packages/browser-pay/examples/relays/bogus.ts new file mode 100644 index 000000000..4b5f9a1e3 --- /dev/null +++ b/packages/browser-pay/examples/relays/bogus.ts @@ -0,0 +1,55 @@ +import type { HeldPlaceholder, PaymentRelay } from "../../src/index.js"; +import { + forwardRequestHeaders, + providerResponseHeaders, + replacePlaceholderCard, +} from "./replace-card.js"; + +/** + * Demo relay for the Shopify Bogus Gateway. Not part of the published SDK. + * Substitutes the gateway's success test card and forwards the paused request. + * Test data only: number "1", expiry 12/2030, cvc 111. + */ +export const BOGUS_CARD = { + number: "1", + expMonth: 12, + expYear: 2030, + cvc: "111", +} as const; + +export function bogusShopifyRelay(options: { + placeholder: HeldPlaceholder; + fetchImpl?: typeof fetch; +}): PaymentRelay { + const fetchImpl = options.fetchImpl ?? fetch; + return { + async relay(request) { + const body = replacePlaceholderCard( + request.body, + options.placeholder.number, + { + number: BOGUS_CARD.number, + expMonth: BOGUS_CARD.expMonth, + expYear: BOGUS_CARD.expYear, + cvc: BOGUS_CARD.cvc, + }, + ); + const response = await fetchImpl(request.url, { + method: request.method, + redirect: "manual", + headers: forwardRequestHeaders(request.headers), + body, + signal: AbortSignal.timeout(30_000), + }); + if (response.status >= 300 && response.status < 400) { + throw new Error("Payment relay refused a redirect"); + } + return { + ok: true, + status: response.status, + headers: providerResponseHeaders(response.headers), + body: await response.text(), + }; + }, + }; +} diff --git a/packages/browser-pay/examples/relays/bt-proxy.test.ts b/packages/browser-pay/examples/relays/bt-proxy.test.ts new file mode 100644 index 000000000..cb0f27d6f --- /dev/null +++ b/packages/browser-pay/examples/relays/bt-proxy.test.ts @@ -0,0 +1,109 @@ +import type { HeldPlaceholder, RelayRequest } from "../../src/index.js"; +import { btProxyRelay } from "./bt-proxy.js"; + +const PLACEHOLDER: HeldPlaceholder = { + number: "4242424242424242", + expMonth: 11, + expYear: 2029, + cvc: "321", + injectionRef: "inj_demo", +}; + +const API_KEY = "bt_test_key_should_not_leak"; +const TOKEN_ID = "tok_test_123"; + +const REQUEST: RelayRequest = { + transactionId: "tx_demo", + injectionRef: "inj_demo", + merchantOrigin: "https://shop.example", + url: "https://checkout.pci.shopifyinc.com/sessions", + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "40", + "shopify-identification-signature": "sig", + }, + body: JSON.stringify({ + credit_card: { + number: "4242424242424242", + month: 11, + year: 2029, + verification_value: "321", + }, + }), +}; + +describe("btProxyRelay", () => { + it("posts token expressions to the Basis Theory proxy", async () => { + const seen: { url?: string; init?: RequestInit } = {}; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + seen.url = String(url); + seen.init = init; + return new Response('{"id":"sess_bt"}', { + status: 200, + headers: { + "content-type": "application/json", + "bt-proxy-destination-status": "201", + "bt-request-id": "req_secret", + "content-encoding": "gzip", + }, + }); + }) as typeof fetch; + const relay = btProxyRelay({ + placeholder: PLACEHOLDER, + apiKey: API_KEY, + tokenId: TOKEN_ID, + fetchImpl, + }); + const response = await relay.relay(REQUEST); + expect(seen.url).toBe("https://api.basistheory.com/proxy"); + const headers = seen.init?.headers as Record; + expect(headers["BT-API-KEY"]).toBe(API_KEY); + expect(headers["BT-PROXY-URL"]).toBe( + "https://checkout.pci.shopifyinc.com/sessions", + ); + expect(headers["shopify-identification-signature"]).toBe("sig"); + expect(headers["content-length"]).toBeUndefined(); + const body = String(seen.init?.body); + expect(body).not.toContain(PLACEHOLDER.number); + expect(body).not.toContain(PLACEHOLDER.cvc); + const card = JSON.parse(body).credit_card; + expect(card.number).toBe( + "{{ token: tok_test_123 | json: '$.data.number' }}", + ); + expect(card.month).toBe( + "{{ token: tok_test_123 | json: '$.data.expiration_month' }}", + ); + expect(card.year).toBe( + "{{ token: tok_test_123 | json: '$.data.expiration_year' }}", + ); + expect(card.verification_value).toBe( + "{{ token: tok_test_123 | json: '$.data.cvc' }}", + ); + expect(response).toEqual({ + ok: true, + status: 201, + headers: { "content-type": "application/json" }, + body: '{"id":"sess_bt"}', + }); + if (!response.ok) throw new Error("expected a fulfilled relay response"); + expect(JSON.stringify(response.headers)).not.toContain("bt-"); + }); + + it("fails closed on a proxy error and does not include the key", async () => { + const fetchImpl = (async () => + new Response("nope", { status: 401 })) as typeof fetch; + const relay = btProxyRelay({ + placeholder: PLACEHOLDER, + apiKey: API_KEY, + tokenId: TOKEN_ID, + fetchImpl, + }); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay failed (HTTP 401)", + ); + }); +}); diff --git a/packages/browser-pay/examples/relays/bt-proxy.ts b/packages/browser-pay/examples/relays/bt-proxy.ts new file mode 100644 index 000000000..4199ed008 --- /dev/null +++ b/packages/browser-pay/examples/relays/bt-proxy.ts @@ -0,0 +1,93 @@ +import type { HeldPlaceholder, PaymentRelay } from "../../src/index.js"; +import { + forwardRequestHeaders, + providerResponseHeaders, + replacePlaceholderCard, +} from "./replace-card.js"; + +/** + * Demo stand-in for W3 B6. Not part of the published SDK. + * Sends the paused Shopify card-session request through Basis Theory's + * ephemeral proxy. The placeholder number, expiry, and CVC are replaced with + * token expressions; Basis Theory inserts the vaulted card. Reads + * BT_PRIVATE_KEY and BT_TOKEN_ID from the environment when constructed from + * `btProxyRelayFromEnv`. Never logs the key, the token id, or a card value. + */ +export function btProxyRelay(options: { + placeholder: HeldPlaceholder; + apiKey: string; + tokenId: string; + /** Defaults to `https://api.basistheory.com`, or `BT_API_BASE` when omitted. */ + baseUrl?: string; + fetchImpl?: typeof fetch; +}): PaymentRelay { + if (!/^[A-Za-z0-9._:-]+$/.test(options.tokenId)) { + throw new Error("BT token id is not a usable token id"); + } + const fetchImpl = options.fetchImpl ?? fetch; + const base = ( + options.baseUrl ?? + process.env.BT_API_BASE ?? + "https://api.basistheory.com" + ).replace(/\/$/, ""); + const expression = (path: string) => + `{{ token: ${options.tokenId} | json: '$.data.${path}' }}`; + return { + async relay(request) { + const body = replacePlaceholderCard( + request.body, + options.placeholder.number, + { + number: expression("number"), + expMonth: expression("expiration_month"), + expYear: expression("expiration_year"), + cvc: expression("cvc"), + }, + ); + const response = await fetchImpl(`${base}/proxy`, { + method: "POST", + redirect: "manual", + headers: { + ...forwardRequestHeaders(request.headers), + "BT-API-KEY": options.apiKey, + "BT-PROXY-URL": request.url, + }, + body, + signal: AbortSignal.timeout(30_000), + }); + if (response.status >= 300 && response.status < 400) { + throw new Error("Payment relay refused a redirect"); + } + const destination = Number( + response.headers.get("bt-proxy-destination-status"), + ); + const hasDestination = + Number.isInteger(destination) && + destination >= 100 && + destination <= 599; + if ( + !hasDestination && + (response.status < 200 || response.status >= 300) + ) { + throw new Error(`Payment relay failed (HTTP ${response.status})`); + } + return { + ok: true, + status: hasDestination ? destination : response.status, + headers: providerResponseHeaders(response.headers), + body: await response.text(), + }; + }, + }; +} + +export function btProxyRelayFromEnv( + placeholder: HeldPlaceholder, +): PaymentRelay { + const apiKey = process.env.BT_PRIVATE_KEY; + const tokenId = process.env.BT_TOKEN_ID; + if (!apiKey || !tokenId) { + throw new Error("BT_PRIVATE_KEY and BT_TOKEN_ID are required for RELAY=bt"); + } + return btProxyRelay({ placeholder, apiKey, tokenId }); +} diff --git a/packages/browser-pay/examples/relays/replace-card.ts b/packages/browser-pay/examples/relays/replace-card.ts new file mode 100644 index 000000000..60942c250 --- /dev/null +++ b/packages/browser-pay/examples/relays/replace-card.ts @@ -0,0 +1,237 @@ +import { cardNumberCandidate } from "../../src/detect.js"; + +export interface CardReplacement { + number: string; + expMonth: string | number; + expYear: string | number; + cvc: string; +} + +/** + * Replace the placeholder's number, expiry, and CVC in a JSON or form body. + * Throws, without forwarding, when the placeholder number is still present. + */ +export function replacePlaceholderCard( + body: string, + placeholderNumber: string, + next: CardReplacement, +): string { + const trimmed = body.trim(); + let replaced = ""; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + const result = replaceJson(JSON.parse(body), placeholderNumber, next); + if (!result.replaced) { + throw new Error("Placeholder card fields were not replaced"); + } + replaced = JSON.stringify(result.value); + } else if (trimmed.includes("=")) { + replaced = replaceForm(body, placeholderNumber, next); + } else { + throw new Error("Placeholder card fields were not replaced"); + } + if (numberStillPresent(replaced, placeholderNumber)) { + throw new Error("Placeholder card fields were not replaced"); + } + return replaced; +} + +function replaceJson( + value: unknown, + placeholderNumber: string, + next: CardReplacement, +): { value: unknown; replaced: boolean } { + if (Array.isArray(value)) { + let replaced = false; + const items = value.map((item) => { + const child = replaceJson(item, placeholderNumber, next); + replaced = replaced || child.replaced; + return child.value; + }); + return { value: items, replaced }; + } + if (!value || typeof value !== "object") return { value, replaced: false }; + const obj = value as Record; + if (objectHasNumber(obj, placeholderNumber)) { + return { + value: replaceObject(obj, placeholderNumber, next), + replaced: true, + }; + } + let replaced = false; + const out: Record = {}; + for (const [key, child] of Object.entries(obj)) { + const nextChild = replaceJson(child, placeholderNumber, next); + replaced = replaced || nextChild.replaced; + out[key] = nextChild.value; + } + return { value: out, replaced }; +} + +function objectHasNumber( + obj: Record, + placeholderNumber: string, +): boolean { + return Object.values(obj).some( + (value) => + typeof value === "string" && + cardNumberCandidate(value) === placeholderNumber, + ); +} + +function replaceObject( + obj: Record, + placeholderNumber: string, + next: CardReplacement, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if ( + typeof value === "string" && + cardNumberCandidate(value) === placeholderNumber + ) { + out[key] = next.number; + continue; + } + const kind = fieldKind(key); + if (kind === "month") { + out[key] = like(value, next.expMonth); + continue; + } + if (kind === "year") { + out[key] = like(value, next.expYear); + continue; + } + if (kind === "cvc") { + out[key] = next.cvc; + continue; + } + if (kind === "expiry") { + out[key] = formatExpiry(next); + continue; + } + out[key] = value; + } + return out; +} + +function replaceForm( + body: string, + placeholderNumber: string, + next: CardReplacement, +): string { + const params = new URLSearchParams(body); + let found = false; + for (const [key, value] of params) { + if (cardNumberCandidate(value) === placeholderNumber) { + params.set(key, next.number); + found = true; + } + } + if (!found) throw new Error("Placeholder card fields were not replaced"); + for (const [key, value] of [...params.entries()]) { + const kind = fieldKind(key.split("[").pop()?.replace("]", "") ?? key); + if (kind === "month") params.set(key, String(like(value, next.expMonth))); + if (kind === "year") params.set(key, String(like(value, next.expYear))); + if (kind === "cvc") params.set(key, next.cvc); + if (kind === "expiry") params.set(key, formatExpiry(next)); + } + return params.toString(); +} + +function like(original: unknown, value: string | number): string | number { + if (typeof value === "string" && !/^\d+$/.test(value)) return value; + if (typeof original === "number") return Number(value); + return String(value); +} + +function formatExpiry(next: CardReplacement): string { + if (typeof next.expMonth === "string" && !/^\d+$/.test(next.expMonth)) { + return `${next.expMonth}/${next.expYear}`; + } + const month = String(next.expMonth).padStart(2, "0"); + const year = String(next.expYear); + const shortYear = year.length === 4 ? year.slice(-2) : year; + return `${month}/${shortYear}`; +} + +function numberStillPresent(body: string, number: string): boolean { + const pattern = new RegExp(number.split("").join("[\\s-]*")); + return pattern.test(body); +} + +function fieldKind(key: string): "month" | "year" | "expiry" | "cvc" | "other" { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + if ( + normalized.includes("startmonth") || + normalized.includes("startyear") || + normalized.includes("issuen") + ) { + return "other"; + } + if ( + normalized.includes("cvc") || + normalized.includes("cvv") || + normalized.includes("verificationvalue") || + normalized.includes("securitycode") + ) { + return "cvc"; + } + if (normalized === "year" || normalized.endsWith("year")) return "year"; + if (normalized === "month" || normalized.endsWith("month")) return "month"; + if ( + normalized === "expiry" || + normalized === "expiration" || + normalized === "exp" + ) { + return "expiry"; + } + return "other"; +} + +export const HOP_BY_HOP = new Set([ + "host", + "content-length", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authorization", +]); + +export function forwardRequestHeaders( + headers: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (HOP_BY_HOP.has(key.toLowerCase())) continue; + out[key] = value; + } + return out; +} + +export function providerResponseHeaders( + headers: Headers, +): Record { + const drop = new Set([ + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + "keep-alive", + "te", + "trailer", + "upgrade", + ]); + const out: Record = {}; + headers.forEach((value, key) => { + if (drop.has(key) || key.startsWith("bt-") || key === "set-cookie") return; + out[key] = value; + }); + const setCookies = + typeof headers.getSetCookie === "function" ? headers.getSetCookie() : []; + if (setCookies.length > 0) out["set-cookie"] = setCookies.join("\n"); + return out; +} diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts new file mode 100644 index 000000000..702bb4013 --- /dev/null +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -0,0 +1,683 @@ +/** + * Opt-in Shopify dev-store checkout. Not part of CI. + * + * RELAY=bogus npx tsx examples/shopify-e2e.ts + * RELAY=bt BT_PRIVATE_KEY=… BT_TOKEN_ID=… npx tsx examples/shopify-e2e.ts + * RELAY=http SAPIOM_BASE_URL=… SAPIOM_API_KEY=… TRANSACTION_ID=… \ + * INJECTION_REF=… PLACEHOLDER_NUMBER=… PLACEHOLDER_EXP_MONTH=… \ + * PLACEHOLDER_EXP_YEAR=… PLACEHOLDER_CVC=… npx tsx examples/shopify-e2e.ts + * MODE=refuse RELAY=bogus npx tsx examples/shopify-e2e.ts + * BROWSER=sapiom SAPIOM_API_KEY=… RELAY=bogus npx tsx examples/shopify-e2e.ts + * + * One checkout per process. Pay is clicked once. A captcha is reported and not bypassed. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { createClient, type Sapiom } from "@sapiom/tools"; +import { chromium, type Frame, type Page } from "playwright"; +import { + attachInterceptor, + httpPaymentRelay, + maskCardNumber, + redact, + stubPaymentRelay, + type HeldPlaceholder, + type InterceptorEvent, + type PaymentRelay, +} from "../src/index.js"; +import { bogusShopifyRelay } from "./relays/bogus.js"; +import { btProxyRelayFromEnv } from "./relays/bt-proxy.js"; + +const CART_URL = "https://1a2uh3-r7.myshopify.com/cart/67652208394372:1"; +const SHOP_ORIGIN = "https://1a2uh3-r7.myshopify.com"; + +class CheckoutStopped extends Error { + constructor( + message: string, + readonly code: number, + ) { + super(message); + } +} + +async function main(): Promise { + const relayName = process.env.RELAY; + if (relayName !== "bogus" && relayName !== "bt" && relayName !== "http") { + throw new CheckoutStopped( + "Set RELAY=bogus, RELAY=bt, or RELAY=http. See the comment at the top of examples/shopify-e2e.ts.", + 1, + ); + } + const mode = process.env.MODE === "refuse" ? "refuse" : "pay"; + const placeholder = loadPlaceholder(relayName); + console.log( + `checkout ${mode} via RELAY=${relayName} BROWSER=${process.env.BROWSER ?? "local"} placeholder ${maskCardNumber(placeholder.number)}`, + ); + + const events: InterceptorEvent[] = []; + let relayCalls = 0; + let sessionsUrl = false; + let merchantOrigin = ""; + let relayNote = ""; + const selected = + mode === "refuse" ? refusingRelay() : selectRelay(relayName, placeholder); + const relay: PaymentRelay = { + async relay(request) { + relayCalls += 1; + sessionsUrl = request.url.includes( + "checkout.pci.shopifyinc.com/sessions", + ); + merchantOrigin = request.merchantOrigin; + const result = await selected.relay(request); + relayNote = result.ok + ? `status ${result.status} body ${redact(result.body).slice(0, 180)}` + : `refused ${result.reason}`; + return result; + }, + }; + + const browserSession = await openBrowser(); + const handle = await attachInterceptor(browserSession.cdpUrl, { + relay, + placeholder, + transactionId: + relayName === "http" ? required("TRANSACTION_ID") : "tx_local_demo", + timeoutMs: 30_000, + }); + handle.on((event) => events.push(event)); + const driver = await chromium.connectOverCDP(browserSession.cdpUrl); + let page: Page | undefined; + try { + const context = driver.contexts()[0] ?? (await driver.newContext()); + page = context.pages()[0] ?? (await context.newPage()); + await page.setViewportSize({ width: 1280, height: 900 }); + await runCheckout(page, placeholder, mode); + if (!sessionsUrl) { + throw new CheckoutStopped( + "interceptor did not relay a request to checkout.pci.shopifyinc.com/sessions", + 2, + ); + } + if (mode === "refuse") { + if (!events.some((event) => event.type === "refused")) { + throw new CheckoutStopped("refusal did not fail the paused request", 2); + } + if (events.some((event) => event.type === "relayed")) { + throw new CheckoutStopped("refusal fulfilled the paused request", 2); + } + } + if (merchantOrigin && merchantOrigin !== SHOP_ORIGIN) { + throw new CheckoutStopped(`merchant origin was ${merchantOrigin}`, 2); + } + console.log( + `relay calls ${relayCalls}; events ${events.map(summarize).join(", ") || "none"}; ${relayNote}`, + ); + } catch (error) { + if (page) console.error(await diagnostics(page)); + console.log( + `relay calls ${relayCalls}; events ${events.map(summarize).join(", ") || "none"}; ${relayNote}`, + ); + console.error( + error instanceof Error ? (error.stack ?? error.message) : error, + ); + throw error; + } finally { + await page?.close().catch(() => {}); + await handle.detach(); + await browserSession.close(); + } +} + +function selectRelay(name: string, placeholder: HeldPlaceholder): PaymentRelay { + if (name === "bogus") return bogusShopifyRelay({ placeholder }); + if (name === "bt") return btProxyRelayFromEnv(placeholder); + return httpPaymentRelay({ + baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", + apiKey: required("SAPIOM_API_KEY"), + }); +} + +function refusingRelay(): PaymentRelay { + return stubPaymentRelay(async () => ({ ok: false, reason: "cancelled" })); +} + +function loadPlaceholder(relayName: string): HeldPlaceholder { + if (relayName === "http") { + return { + number: required("PLACEHOLDER_NUMBER"), + expMonth: Number(required("PLACEHOLDER_EXP_MONTH")), + expYear: Number(required("PLACEHOLDER_EXP_YEAR")), + cvc: required("PLACEHOLDER_CVC"), + injectionRef: required("INJECTION_REF"), + }; + } + // A Luhn-valid stand-in. The bogus relay replaces it with card 1 before + // the request is forwarded. It is not sent as itself. + return { + number: "4123456789012349", + expMonth: 11, + expYear: 2029, + cvc: "321", + injectionRef: "inj_local_demo", + }; +} + +async function runCheckout( + page: Page, + placeholder: HeldPlaceholder, + mode: "pay" | "refuse", +): Promise { + console.log("open cart"); + await page.goto(CART_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForTimeout(1500); + await assertNoChallenge(page, "after cart"); + if (page.url().includes("shop.app")) { + const back = new URL(page.url()).searchParams.get("ur_back_url"); + if (!back) { + throw new CheckoutStopped( + "Shop Pay did not offer a standard checkout URL", + 2, + ); + } + console.log("leave Shop Pay for the card checkout"); + await page.goto(back, { waitUntil: "domcontentloaded", timeout: 60_000 }); + } + await assertNoChallenge(page, "on checkout"); + console.log("fill shipping"); + await fillShipping(page); + console.log("choose shipping"); + await chooseShipping(page); + console.log("fill card fields"); + await fillCard(page, placeholder); + await assertNoChallenge(page, "before pay"); + console.log("pay once"); + await clickPay(page); + if (mode === "refuse") { + await waitFor(async () => page.url(), 20_000); + await page.waitForTimeout(3000); + if (/thank[-_]you/i.test(page.url())) { + throw new CheckoutStopped("refusal still reached the thank-you page", 2); + } + const text = await page + .locator("body") + .innerText() + .catch(() => ""); + if (/order\s+#\d+/i.test(text) && /thank you/i.test(text)) { + throw new CheckoutStopped("refusal still created an order", 2); + } + console.log(`refusal held; page ${redactUrl(page.url())}`); + return; + } + await waitForConfirmation(page); + const text = await page + .locator("body") + .innerText() + .catch(() => ""); + const order = + text.match(/order\s+#\s*(\d+)/i) ?? text.match(/confirmation\s+#\s*(\d+)/i); + console.log(`confirmed ${redactUrl(page.url())}`); + console.log(order ? `order ${order[1]}` : "order id not shown on the page"); +} + +async function fillShipping(page: Page): Promise { + await fill( + page, + /email/i, + "buyer@example.com", + 'input[type="email"], input[name="email"]', + ); + const country = page + .locator('select[name="countryCode"], select[name="country"]') + .first(); + if (await country.isVisible().catch(() => false)) { + await country.selectOption({ label: "United States" }).catch(async () => { + await country.selectOption("US"); + }); + } + await fill(page, /first name/i, "Test", 'input[name="firstName"]'); + await fill(page, /last name/i, "Buyer", 'input[name="lastName"]'); + await fill(page, /^address$/i, "1 Test Street", 'input[name="address1"]'); + await dismissAddressSuggestions(page); + await fill(page, /^city$/i, "New York", 'input[name="city"]'); + await fill( + page, + /zip code|postal code/i, + "10001", + 'input[name="postalCode"]', + ); + const phone = page.getByLabel(/phone/i).first(); + if (await phone.isVisible().catch(() => false)) + await phone.fill("2015550123"); + await selectState(page); + await dismissAddressSuggestions(page); +} + +async function dismissAddressSuggestions(page: Page): Promise { + const decline = page.getByRole("button", { name: /not now/i }).first(); + if (await decline.isVisible().catch(() => false)) { + await decline.click(); + } + await page.keyboard.press("Escape").catch(() => {}); + await page.keyboard.press("Tab").catch(() => {}); +} + +async function fill( + page: Page, + label: RegExp, + value: string, + fallback: string, +): Promise { + const byLabel = page.getByLabel(label).first(); + if (await byLabel.isVisible().catch(() => false)) { + await byLabel.fill(value); + return; + } + const field = page.locator(fallback).first(); + await field.waitFor({ state: "visible", timeout: 20_000 }); + await field.fill(value); +} + +async function selectState(page: Page): Promise { + const select = page + .locator('select[name="zone"], select[name="province"]') + .first(); + if (await select.isVisible().catch(() => false)) { + await select.selectOption({ label: "New York" }).catch(async () => { + await select.selectOption("NY"); + }); + return; + } + const combo = page.getByLabel(/state|province|region/i).first(); + if (await combo.isVisible().catch(() => false)) { + await combo.click(); + await combo.fill("New York"); + await page.keyboard.press("Enter"); + } +} + +async function chooseShipping(page: Page): Promise { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const radios = page.locator('input[type="radio"]'); + const count = await radios.count(); + for (let index = 0; index < count; index += 1) { + const radio = radios.nth(index); + const blob = `${(await radio.getAttribute("name")) ?? ""} ${(await radio.getAttribute("id")) ?? ""}`; + if (!/shipping|delivery|rate/i.test(blob)) continue; + if (!(await radio.isChecked().catch(() => false))) { + await radio.check({ force: true }).catch(() => {}); + } + return; + } + await page.waitForTimeout(400); + } + console.log("no shipping-rate radio appeared"); +} + +async function fillCard( + page: Page, + placeholder: HeldPlaceholder, +): Promise { + await page + .waitForSelector( + 'iframe[src*="checkout.pci.shopifyinc.com"], iframe[name*="card-fields"], iframe[title*="Card number"]', + { timeout: 30_000 }, + ) + .catch(() => {}); + const frames = page.frames().filter((frame) => { + const blob = `${frame.name()} ${frame.url()}`; + return /checkout\.pci\.shopifyinc\.com|card-fields/i.test(blob); + }); + const roles = new Map(); + for (const frame of frames) { + const role = frameRole(`${frame.name()} ${frame.url()}`); + if (role !== "unknown" && !roles.has(role)) roles.set(role, frame); + } + const number = roles.get("number"); + if (!number) { + throw new CheckoutStopped( + `card number iframe not found (${frames.map((frame) => pathOnly(frame.url())).join(", ") || "no pci frames"})`, + 2, + ); + } + await typeFrame(number, placeholder.number); + const expiry = roles.get("expiry"); + if (expiry) { + await typeFrame( + expiry, + `${String(placeholder.expMonth).padStart(2, "0")}${String(placeholder.expYear).slice(-2)}`, + ); + } else { + const month = roles.get("month"); + const year = roles.get("year"); + if (month) + await typeFrame(month, String(placeholder.expMonth).padStart(2, "0")); + if (year) await typeFrame(year, String(placeholder.expYear).slice(-2)); + } + const cvc = roles.get("cvc"); + if (!cvc) throw new CheckoutStopped("card security code iframe not found", 2); + await typeFrame(cvc, placeholder.cvc); + const name = roles.get("name"); + if (name) await typeFrame(name, "Test Buyer"); +} + +function frameRole(blob: string): string { + const value = blob.toLowerCase(); + if (/verification|security.?code|cvv|cvc/.test(value)) return "cvc"; + if (/expir/.test(value)) return "expiry"; + if (/month/.test(value) && !/year/.test(value)) return "month"; + if (/year/.test(value)) return "year"; + if (/number/.test(value)) return "number"; + if (/name/.test(value)) return "name"; + return "unknown"; +} + +async function typeFrame(frame: Frame, text: string): Promise { + // Shopify's card iframes put a honeypot input first. It is aria-hidden and + // covered by the real field, so a click on it never lands. + const inputs = frame.locator( + 'input:not([data-honeypot-field]):not([aria-hidden="true"]), textarea:not([data-honeypot-field])', + ); + await inputs.first().waitFor({ state: "attached", timeout: 15_000 }); + const count = await inputs.count(); + let target = inputs.first(); + for (let index = 0; index < count; index += 1) { + const candidate = inputs.nth(index); + const tab = await candidate.getAttribute("tabindex"); + if (tab === "-1") continue; + target = candidate; + break; + } + await target.click({ force: true }); + await target.fill(""); + await target.pressSequentially(text, { delay: 20 }); +} + +async function clickPay(page: Page): Promise { + const named = page.locator("#checkout-pay-button").first(); + if (await named.isVisible().catch(() => false)) { + await named.click(); + return; + } + await page + .getByRole("button", { name: /pay now/i }) + .first() + .click(); +} + +async function waitForConfirmation(page: Page): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + await assertNoChallenge(page, "waiting for confirmation"); + if (/thank[-_]you/i.test(page.url())) return; + const heading = page.getByRole("heading", { name: /thank you/i }).first(); + if (await heading.isVisible().catch(() => false)) return; + const paymentError = await paymentAlert(page); + if (paymentError) throw new CheckoutStopped(redact(paymentError), 2); + await page.waitForTimeout(500); + } + throw new CheckoutStopped( + `checkout did not confirm (${redactUrl(page.url())})`, + 2, + ); +} + +async function paymentAlert(page: Page): Promise { + const alerts = await page + .locator('[role="alert"]') + .allInnerTexts() + .catch(() => [] as string[]); + for (const alert of alerts) { + const text = alert.replace(/\s+/g, " ").trim(); + if (!text || /being processed/i.test(text)) continue; + if (/card|payment|gateway|declined/i.test(text)) return text; + } + return null; +} + +async function assertNoChallenge(page: Page, where: string): Promise { + const challenge = await detectChallenge(page); + if (challenge) { + throw new CheckoutStopped( + `Shopify showed a captcha or bot challenge (${challenge}) ${where}. Not bypassing it.`, + 3, + ); + } +} + +async function detectChallenge(page: Page): Promise { + const url = page.url(); + if (/hcaptcha|recaptcha|\/checkpoint|\/challenge/i.test(url)) return "url"; + for (const frame of page.frames()) { + if ( + /hcaptcha\.com|google\.com\/recaptcha|challenges\.cloudflare/i.test( + frame.url(), + ) + ) { + return "frame"; + } + } + const text = await page + .locator("body") + .innerText({ timeout: 2_000 }) + .catch(() => ""); + if (/verify you are human|are you a human|checking your browser/i.test(text)) + return "text"; + if (/enter store password|opening soon/i.test(text)) return "store-password"; + return null; +} + +async function diagnostics(page: Page): Promise { + const frames = page.frames().map((frame) => pathOnly(frame.url())); + const buttons = await page + .locator("button") + .allInnerTexts() + .catch(() => [] as string[]); + const fields = await page + .locator("input, select, textarea") + .evaluateAll((nodes) => + nodes.map((node) => { + const field = node as { + name?: string; + id?: string; + value?: string; + getAttribute?: (name: string) => string | null; + }; + const name = + field.name || + field.id || + field.getAttribute?.("aria-label") || + "field"; + if (/number|verif|cvv|cvc|expir|security|code/i.test(name)) { + return `${name}=filled`; + } + return `${name}=${String(field.value ?? "").slice(0, 40)}`; + }), + ) + .catch(() => [] as string[]); + const alerts = await page + .locator('[role="alert"]') + .allInnerTexts() + .catch(() => [] as string[]); + const radios = await page + .locator('input[type="radio"]') + .evaluateAll((nodes) => + nodes.map((node) => { + const input = node as { name?: string; id?: string; checked?: boolean }; + return `${input.name || input.id || "radio"}${input.checked ? " checked" : ""}`; + }), + ) + .catch(() => [] as string[]); + const text = redact( + await page + .locator("body") + .innerText({ timeout: 2_000 }) + .catch(() => ""), + ) + .replace(/\s+/g, " ") + .slice(0, 700); + const challenge = await detectChallenge(page); + return [ + `url ${redactUrl(page.url())}`, + `frames ${frames.join(", ")}`, + `buttons ${buttons + .map((label) => label.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .slice(0, 12) + .join(" | ")}`, + `radios ${radios.join(", ") || "none"}`, + `fields ${fields.join(" | ")}`, + `alerts ${alerts.map((label) => redact(label).replace(/\s+/g, " ").trim()).join(" | ") || "none"}`, + `text ${text}`, + `challenge ${challenge ?? "no"}`, + ].join("\n"); +} + +function pathOnly(url: string): string { + try { + return new URL(url).pathname; + } catch { + return "frame"; + } +} + +function redactUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return "unparsed-url"; + } +} + +function summarize(event: InterceptorEvent): string { + if (event.type === "relayed") return `relayed ${event.status}`; + if (event.type === "refused") return `refused ${event.reason}`; + if (event.type === "failed") return `failed ${event.reason}`; + return "paused"; +} + +async function waitFor( + read: () => Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await read(); + await new Promise((resolve) => setTimeout(resolve, 250)); + } +} + +async function openBrowser(): Promise<{ + cdpUrl: string; + close: () => Promise; +}> { + if ((process.env.BROWSER ?? "local") === "sapiom") { + const apiKey = process.env.SAPIOM_API_KEY; + if (!apiKey) + throw new CheckoutStopped("BROWSER=sapiom requires SAPIOM_API_KEY", 1); + const sapiom: Sapiom = createClient({ apiKey }); + const session = await sapiom.browserAutomation.sessions.createManaged({ + idempotencyKey: randomUUID(), + recording: false, + captchaSolver: true, + }); + return { + cdpUrl: session.cdpUrl, + close: async () => { + await sapiom.browserAutomation.sessions + .closeManaged(session.sessionId) + .catch(() => {}); + await sapiom.shutdown().catch(() => {}); + }, + }; + } + const executable = [ + process.env.CHROME_PATH, + "/usr/local/bin/google-chrome", + "/usr/bin/google-chrome", + ].find((candidate) => candidate && existsSync(candidate)); + if (!executable) throw new CheckoutStopped("local Chrome was not found", 1); + const userDataDir = await mkdtemp( + path.join(os.tmpdir(), "browser-pay-shopify-"), + ); + const port = await new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const value = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(value)); + }); + server.on("error", reject); + }); + const chrome: ChildProcess = spawn( + executable, + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + `--remote-debugging-port=${port}`, + "--remote-allow-origins=*", + `--user-data-dir=${userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "about:blank", + ], + { stdio: "ignore" }, + ); + const cdpUrl = await waitForCdp(port, chrome); + return { + cdpUrl, + close: async () => { + chrome.kill("SIGKILL"); + await new Promise((resolve) => setTimeout(resolve, 200)); + await rm(userDataDir, { + recursive: true, + force: true, + maxRetries: 3, + }).catch(() => {}); + }, + }; +} + +async function waitForCdp(port: number, proc: ChildProcess): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (proc.exitCode !== null) + throw new CheckoutStopped("Chrome exited before CDP was ready", 1); + 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 { + // Chrome is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new CheckoutStopped("CDP endpoint did not come up", 1); +} + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new CheckoutStopped(`${name} is required`, 1); + return value; +} + +main().catch((error: unknown) => { + if (error instanceof CheckoutStopped) { + console.error(error.message); + process.exit(error.code); + } + console.error(error instanceof Error ? error.message : "checkout failed"); + process.exit(2); +}); 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..39625aa0f --- /dev/null +++ b/packages/browser-pay/package.json @@ -0,0 +1,75 @@ +{ + "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", + "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.ts b/packages/browser-pay/src/cdp.ts new file mode 100644 index 000000000..048fb2ebb --- /dev/null +++ b/packages/browser-pay/src/cdp.ts @@ -0,0 +1,273 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import https from "node:https"; +import type { Duplex } from "node:stream"; + +/** + * 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: any) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +}; + +class WebSocketClient { + private buffer = Buffer.alloc(0); + private fragments: Buffer[] = []; + private closed = false; + onmessage: (data: string) => void = () => {}; + onclose: () => void = () => {}; + + private constructor(private readonly socket: Duplex) { + socket.on("data", (chunk: Buffer) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.parse(); + }); + socket.on("close", () => { + this.closed = true; + this.onclose(); + }); + socket.on("error", () => { + this.closed = true; + }); + } + + 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.buffer = Buffer.from(head); + client.parse(); + } + 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 parse(): void { + for (;;) { + if (this.buffer.length < 2) return; + const first = this.buffer[0]; + const second = this.buffer[1]; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (this.buffer.length < 4) return; + length = this.buffer.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + if (this.buffer.length < 10) return; + length = Number(this.buffer.readBigUInt64BE(2)); + offset = 10; + } + const masked = (second & 0x80) !== 0; + const maskOffset = offset; + if (masked) offset += 4; + if (this.buffer.length < offset + length) return; + let payload = this.buffer.subarray(offset, offset + length); + if (masked) { + const mask = this.buffer.subarray(maskOffset, maskOffset + 4); + payload = Buffer.from( + payload.map((byte, index) => byte ^ mask[index % 4]), + ); + } + this.buffer = this.buffer.subarray(offset + length); + const opcode = first & 0x0f; + const fin = (first & 0x80) !== 0; + if (opcode === 0x8) { + this.close(); + return; + } + if (opcode === 0x9) { + this.frame(0xa, payload); + continue; + } + if (opcode === 0xa) continue; + if (opcode !== 0x1 && opcode !== 0x0) continue; + this.fragments.push(Buffer.from(payload)); + if (fin) { + const message = Buffer.concat(this.fragments).toString("utf8"); + this.fragments = []; + this.onmessage(message); + } + } + } + + private frame(opcode: number, data: Buffer): void { + if (this.closed) return; + 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.from(data.map((byte, index) => byte ^ mask[index % 4])); + this.socket.write(Buffer.concat([header, mask, body])); + } + + send(data: string): void { + this.frame(0x1, Buffer.from(data, "utf8")); + } + + close(): void { + if (this.closed) return; + this.closed = true; + try { + this.frame(0x8, Buffer.alloc(0)); + } catch { + // The socket may already be gone. + } + this.socket.end(); + setTimeout(() => this.socket.destroy(), 200); + } +} + +export class CdpConnection { + private nextId = 0; + private readonly pending = new Map(); + private readonly listeners = new Set<(event: CdpEvent) => void>(); + + private constructor(private readonly socket: WebSocketClient) { + socket.onmessage = (data) => { + let message: any; + try { + message = JSON.parse(data); + } catch { + return; + } + if (message.id && this.pending.has(message.id)) { + const pending = this.pending.get(message.id)!; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(new Error(`CDP ${message.error.message}`)); + } 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); + }; + socket.onclose = () => { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("CDP connection closed")); + } + this.pending.clear(); + }; + } + + 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) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { + resolve, + reject, + timer, + }); + this.socket.send( + JSON.stringify({ + id, + method, + params, + ...(sessionId ? { sessionId } : {}), + }), + ); + }); + } + + 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.test.ts b/packages/browser-pay/src/contract.test.ts new file mode 100644 index 000000000..3c7959043 --- /dev/null +++ b/packages/browser-pay/src/contract.test.ts @@ -0,0 +1,19 @@ +import { parseRelayResponse } from "./contract"; + +describe("parseRelayResponse", () => { + it("rejects a payload that is not a success or a known refusal", () => { + expect(() => parseRelayResponse(null)).toThrow( + "Payment relay returned an unreadable response", + ); + expect(() => + parseRelayResponse({ + ok: false, + reason: "made_up", + body: "4242424242424242", + }), + ).toThrow("Payment relay returned an unreadable response"); + expect(() => + parseRelayResponse({ ok: true, status: 200, body: 1, headers: {} }), + ).toThrow("Payment relay returned an unreadable response"); + }); +}); diff --git a/packages/browser-pay/src/contract.ts b/packages/browser-pay/src/contract.ts new file mode 100644 index 000000000..6176ba255 --- /dev/null +++ b/packages/browser-pay/src/contract.ts @@ -0,0 +1,105 @@ +/** + * T7, the interceptor-to-relay contract (W7 U0). + * + * The paused request body may contain the placeholder the agent typed. No field + * on the response is a credential: a success body is the payment provider's + * opaque response, and a refusal names a reason without echoing the request. + * Real card values never travel back to the browser. + */ + +export const RELAY_REFUSAL_REASONS = [ + "wrong_key", + "unknown_placeholder", + "closed_placeholder", + "expired_placeholder", + "off_profile_destination", + "replay", + "merchant_origin", + "changed_total", + "cancelled", +] as const; + +export type RelayRefusalReason = (typeof RELAY_REFUSAL_REASONS)[number]; + +/** + * What the interceptor sends to the relay. + * `merchantOrigin` is the top-level page origin (scheme + host), the payTo + * value bound at authorization. It is never the card iframe's processor host. + */ +export interface RelayRequest { + transactionId: string; + injectionRef: string; + merchantOrigin: string; + url: string; + method: string; + headers: Record; + body: string; +} + +/** Provider response to fulfill the paused browser request with. */ +export interface RelaySuccess { + ok: true; + /** Provider HTTP status. */ + status: number; + headers: Record; + /** Provider response body. Must not contain a real card value. */ + body: string; +} + +/** Verification failed. Nothing was forwarded. The request is not echoed. */ +export interface RelayRefusal { + ok: false; + reason: RelayRefusalReason; +} + +export type RelayResponse = RelaySuccess | RelayRefusal; + +export function isRelayRefusalReason( + value: string, +): value is RelayRefusalReason { + return (RELAY_REFUSAL_REASONS as readonly string[]).includes(value); +} + +function isHeaderRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value as Record).every( + (entry) => typeof entry === "string", + ); +} + +/** + * Accept a success payload or a typed refusal. Anything else throws, and the + * message never includes the payload (it may contain a card or a request echo). + */ +export function parseRelayResponse(value: unknown): RelayResponse { + if (!value || typeof value !== "object") { + throw new Error("Payment relay returned an unreadable response"); + } + const body = value as Record; + if (body.ok === true) { + if ( + typeof body.status !== "number" || + !Number.isInteger(body.status) || + body.status < 100 || + body.status > 599 || + typeof body.body !== "string" || + !isHeaderRecord(body.headers) + ) { + throw new Error("Payment relay returned an unreadable response"); + } + return { + ok: true, + status: body.status, + headers: body.headers, + body: body.body, + }; + } + if ( + typeof body.reason === "string" && + isRelayRefusalReason(body.reason) && + body.ok !== true + ) { + return { ok: false, reason: body.reason }; + } + throw new Error("Payment relay returned an unreadable response"); +} diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts new file mode 100644 index 000000000..7ed9ce5df --- /dev/null +++ b/packages/browser-pay/src/detect.test.ts @@ -0,0 +1,189 @@ +import { + cardNumberCandidate, + matchHeldPlaceholder, + matchHeldPlaceholderInBody, + type HeldPlaceholder, +} from "./detect"; +import { maskCardNumber, redact } from "./redact"; + +const HELD: HeldPlaceholder = { + number: "4242424242424242", + expMonth: 12, + expYear: 2028, + cvc: "123", + injectionRef: "inj_test", +}; + +const json = (card: Record) => + JSON.stringify({ credit_card: card }); + +describe("cardNumberCandidate", () => { + it("accepts a Luhn-valid number and strips separators", () => { + expect(cardNumberCandidate("4242424242424242")).toBe("4242424242424242"); + expect(cardNumberCandidate("4242-4242-4242-4242")).toBe("4242424242424242"); + expect(cardNumberCandidate("4242 4242 4242 4242")).toBe("4242424242424242"); + }); + + it("rejects a Luhn failure, a short run, and an unrelated valid number's check", () => { + expect(cardNumberCandidate("4242424242424243")).toBeNull(); + expect(cardNumberCandidate("424242424242")).toBeNull(); + expect(cardNumberCandidate("not a card")).toBeNull(); + }); +}); + +describe("matchHeldPlaceholder", () => { + it("matches the held number when expiry and cvc agree, including a two-digit year", () => { + expect( + matchHeldPlaceholder( + { number: "4242424242424242", expMonth: 12, expYear: 28, cvc: "123" }, + [HELD], + ), + ).toEqual({ kind: "match", injectionRef: "inj_test" }); + }); + + it("returns not_held for a different Luhn-valid number", () => { + expect( + matchHeldPlaceholder({ number: "4111111111111111" }, [HELD]), + ).toEqual({ kind: "not_held" }); + }); + + it("returns not_a_card_number when the value fails Luhn", () => { + expect( + matchHeldPlaceholder({ number: "4242424242424243" }, [HELD]), + ).toEqual({ kind: "not_a_card_number" }); + }); +}); + +describe("matchHeldPlaceholderInBody", () => { + it("matches a JSON body", () => { + const body = json({ + number: "4242424242424242", + month: 12, + year: 2028, + verification_value: "123", + name: "Fixture Buyer", + }); + expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + kind: "match", + injectionRef: "inj_test", + }); + }); + + it("matches a JSON number written with separators", () => { + const body = json({ + number: "4242-4242-4242-4242", + month: "12", + year: "28", + verification_value: "123", + }); + expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + kind: "match", + injectionRef: "inj_test", + }); + }); + + it("matches a form body", () => { + const body = + "credit_card[number]=4242+4242+4242+4242&credit_card[month]=12&credit_card[year]=2028&credit_card[verification_value]=123"; + expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + kind: "match", + injectionRef: "inj_test", + }); + }); + + it("matches on the number alone when expiry and cvc are absent", () => { + expect( + matchHeldPlaceholderInBody(json({ number: "4242 4242 4242 4242" }), HELD), + ).toEqual({ kind: "match", injectionRef: "inj_test" }); + }); + + it("does not treat a Luhn failure as the placeholder", () => { + expect( + matchHeldPlaceholderInBody( + json({ + number: "4242424242424243", + month: 12, + year: 2028, + verification_value: "123", + }), + HELD, + ), + ).toEqual({ kind: "not_held" }); + }); + + it("mismatches a held number with the wrong cvc", () => { + expect( + matchHeldPlaceholderInBody( + json({ + number: "4242424242424242", + month: 12, + year: 2028, + verification_value: "999", + }), + HELD, + ), + ).toEqual({ kind: "mismatch", injectionRef: "inj_test", mismatch: "cvc" }); + }); + + it("mismatches a held number with the wrong expiry", () => { + expect( + matchHeldPlaceholderInBody( + json({ + number: "4242424242424242", + month: 1, + year: 2028, + verification_value: "123", + }), + HELD, + ), + ).toEqual({ + kind: "mismatch", + injectionRef: "inj_test", + mismatch: "expiry", + }); + expect( + matchHeldPlaceholderInBody( + json({ + number: "4242424242424242", + month: 12, + year: 2030, + verification_value: "123", + }), + HELD, + ), + ).toEqual({ + kind: "mismatch", + injectionRef: "inj_test", + mismatch: "expiry", + }); + }); + + it("returns not_held for an unrelated card number", () => { + expect( + matchHeldPlaceholderInBody( + json({ + number: "4111111111111111", + month: 12, + year: 2028, + verification_value: "123", + }), + HELD, + ), + ).toEqual({ kind: "not_held" }); + }); + + it("returns not_held for an empty body and for digits that are not a card", () => { + expect(matchHeldPlaceholderInBody("", HELD)).toEqual({ kind: "not_held" }); + expect(matchHeldPlaceholderInBody('{"order":12345}', HELD)).toEqual({ + kind: "not_held", + }); + }); +}); + +describe("redact", () => { + it("masks a card number to last4 and leaves short numbers", () => { + expect(maskCardNumber("4242424242424242")).toBe("••••4242"); + expect(redact("pan 4242-4242-4242-4242 end")).toBe("pan ••••4242 end"); + expect(redact("order 1001")).toBe("order 1001"); + }); +}); diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts new file mode 100644 index 000000000..bc092d589 --- /dev/null +++ b/packages/browser-pay/src/detect.ts @@ -0,0 +1,376 @@ +/** + * Recognizing the one placeholder this interceptor was given, inside a paused + * request body. + * + * This is a copy of the backend's placeholder-detection logic (card-number + * candidate, Luhn, expiry/cvc comparison). sapiom-js cannot import that module. + * It should move to a shared package. Do not drift the comparison rules. + * + * A typed number might be a real card. This function only decides whether the + * body contains the placeholder already held; it never sends a number anywhere. + */ + +/** A placeholder as the API hands it to the agent. */ +export interface HeldPlaceholder { + number: string; + expMonth: number; + expYear: number; + cvc: string; + injectionRef: string; +} + +/** Card fields found in a request body. Expiry and CVC only when present. */ +export interface TypedCard { + number: string; + expMonth?: number; + /** Two or four digits. */ + expYear?: number; + cvc?: string; +} + +export type HeldPlaceholderMatch = + | { kind: "not_a_card_number" } + | { kind: "not_held" } + | { kind: "mismatch"; injectionRef: string; mismatch: "expiry" | "cvc" } + | { kind: "match"; injectionRef: string }; + +export type BodyPlaceholderMatch = Exclude< + HeldPlaceholderMatch, + { kind: "not_a_card_number" } +>; + +/** + * A value as a card number worth comparing: separators removed, 13 to 19 + * digits, valid Luhn check digit. Null when it cannot be a card number. + */ +export function cardNumberCandidate(raw: string): string | null { + const number = raw.replace(/[\s-]/g, ""); + if (!/^\d{13,19}$/.test(number)) return null; + return luhnCheckDigit(number.slice(0, -1)) === number.slice(-1) + ? number + : null; +} + +/** Compares one typed card with the placeholders the caller holds. */ +export function matchHeldPlaceholder( + typed: TypedCard, + held: readonly HeldPlaceholder[], +): HeldPlaceholderMatch { + const number = cardNumberCandidate(typed.number); + if (!number) return { kind: "not_a_card_number" }; + const placeholder = held.find((candidate) => candidate.number === number); + if (!placeholder) return { kind: "not_held" }; + const { injectionRef } = placeholder; + if (typed.expMonth !== undefined && typed.expMonth !== placeholder.expMonth) { + return { kind: "mismatch", injectionRef, mismatch: "expiry" }; + } + if ( + typed.expYear !== undefined && + typed.expYear % 100 !== placeholder.expYear % 100 + ) { + return { kind: "mismatch", injectionRef, mismatch: "expiry" }; + } + if (typed.cvc !== undefined && typed.cvc !== placeholder.cvc) { + return { kind: "mismatch", injectionRef, mismatch: "cvc" }; + } + return { kind: "match", injectionRef }; +} + +export function luhnCheckDigit(body: string): string { + let sum = 0; + for (let i = 0; i < body.length; i++) { + let digit = Number(body[body.length - 1 - i]); + if (i % 2 === 0) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + } + return String((10 - (sum % 10)) % 10); +} + +/** + * Whether `body` contains the single held placeholder. + * `not_held` means the placeholder number is not in the body (other card + * numbers included). `mismatch` means the number is present but the expiry or + * CVC beside it disagrees. `match` carries only the injection ref. + */ +export function matchHeldPlaceholderInBody( + body: string, + held: HeldPlaceholder, +): BodyPlaceholderMatch { + const cards = extractTypedCards(body); + let mismatch: Extract | null = + null; + for (const card of cards) { + const result = matchHeldPlaceholder(card, [held]); + if (result.kind === "match") { + return { kind: "match", injectionRef: result.injectionRef }; + } + if (result.kind === "mismatch" && !mismatch) mismatch = result; + } + if (mismatch) return mismatch; + return { kind: "not_held" }; +} + +/** Card numbers in a body, with expiry and CVC taken from the same object or form group. */ +export function extractTypedCards(body: string): TypedCard[] { + const structured = extractStructured(body); + const covered = new Set( + structured.map((card) => cardNumberCandidate(card.number) ?? card.number), + ); + const extras: TypedCard[] = []; + for (const number of scanRawNumbers(body)) { + if (!covered.has(number)) extras.push({ number }); + } + return [...structured, ...extras]; +} + +function extractStructured(body: string): TypedCard[] { + const trimmed = body.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const cards: TypedCard[] = []; + walk(JSON.parse(trimmed), undefined, undefined, cards); + return cards; + } catch { + // Not JSON. Fall through to form encoding when it looks like a form. + } + } + if (trimmed.includes("=")) return extractForm(trimmed); + return []; +} + +function walk( + value: unknown, + container: Record | undefined, + parent: Record | undefined, + out: TypedCard[], +): void { + if (typeof value === "string") { + const whole = cardNumberCandidate(value); + const numbers = whole ? [whole] : scanRawNumbers(value); + for (const number of numbers) { + out.push(cardFromContext(number, container, parent)); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) walk(item, undefined, undefined, out); + return; + } + if (value && typeof value === "object") { + const obj = value as Record; + for (const child of Object.values(obj)) walk(child, obj, container, out); + } +} + +function cardFromContext( + number: string, + container: Record | undefined, + parent: Record | undefined, +): TypedCard { + const own = readCardFields(container); + const inherited = readCardFields(parent); + return { + number, + ...fill(inherited, own), + }; +} + +function fill( + fallback: Partial, + primary: Partial, +): Partial { + return { + ...(primary.expMonth !== undefined + ? { expMonth: primary.expMonth } + : fallback.expMonth !== undefined + ? { expMonth: fallback.expMonth } + : {}), + ...(primary.expYear !== undefined + ? { expYear: primary.expYear } + : fallback.expYear !== undefined + ? { expYear: fallback.expYear } + : {}), + ...(primary.cvc !== undefined + ? { cvc: primary.cvc } + : fallback.cvc !== undefined + ? { cvc: fallback.cvc } + : {}), + }; +} + +function extractForm(body: string): TypedCard[] { + let params: URLSearchParams; + try { + params = new URLSearchParams(body); + } catch { + return []; + } + const groups = new Map>(); + for (const [key, value] of params) { + const { prefix, field } = splitFormKey(key); + const group = groups.get(prefix) ?? {}; + group[field] = value; + groups.set(prefix, group); + } + const cards: TypedCard[] = []; + for (const group of groups.values()) { + for (const value of Object.values(group)) { + const number = cardNumberCandidate(value); + if (!number) continue; + cards.push({ number, ...readCardFields(group) }); + } + } + return cards; +} + +function splitFormKey(key: string): { prefix: string; field: string } { + const bracket = key.match(/^(.*)\[([^\]]+)\]$/); + if (bracket) return { prefix: bracket[1], field: bracket[2] }; + const dot = key.match(/^(.*)\.([^.]+)$/); + if (dot) return { prefix: dot[1], field: dot[2] }; + return { prefix: "", field: key }; +} + +type FieldKind = "month" | "year" | "expiry" | "cvc" | "other"; + +function fieldKind(key: string): FieldKind { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + if ( + normalized.includes("startmonth") || + normalized.includes("startyear") || + normalized.includes("issuen") + ) { + return "other"; + } + if ( + normalized.includes("cvc") || + normalized.includes("cvv") || + normalized.includes("csc") || + normalized.includes("securitycode") || + normalized.includes("verificationvalue") || + normalized.includes("cardverification") + ) { + return "cvc"; + } + if ( + normalized.includes("expyear") || + normalized.includes("expirationyear") || + normalized.includes("expiryyear") || + normalized === "year" || + normalized.endsWith("year") + ) { + return "year"; + } + if ( + normalized.includes("expmonth") || + normalized.includes("expirationmonth") || + normalized.includes("expirymonth") || + normalized === "month" || + normalized.endsWith("month") + ) { + return "month"; + } + if ( + normalized === "exp" || + normalized === "expiry" || + normalized === "expiration" || + normalized.includes("expdate") || + normalized === "ccexp" + ) { + return "expiry"; + } + return "other"; +} + +function readCardFields( + obj: Record | undefined, +): Partial { + if (!obj) return {}; + const fields: Partial = {}; + for (const [key, value] of Object.entries(obj)) { + const kind = fieldKind(key); + if (kind === "month" && fields.expMonth === undefined) { + const month = readPresentMonth(value); + if (month !== undefined) fields.expMonth = month; + } else if (kind === "year" && fields.expYear === undefined) { + const year = readPresentYear(value); + if (year !== undefined) fields.expYear = year; + } else if (kind === "cvc" && fields.cvc === undefined) { + const cvc = readPresentCvc(value); + if (cvc !== undefined) fields.cvc = cvc; + } else if (kind === "expiry" && fields.expMonth === undefined) { + const expiry = readExpiry(value); + if (expiry) { + fields.expMonth = expiry.expMonth; + if (fields.expYear === undefined) fields.expYear = expiry.expYear; + } else if (value !== null && value !== undefined && value !== "") { + fields.expMonth = Number.NaN; + } + } + } + return fields; +} + +function readPresentMonth(value: unknown): number | undefined { + if (value === null || value === undefined || value === "") return undefined; + const month = readMonth(value); + return month === undefined ? Number.NaN : month; +} + +function readPresentYear(value: unknown): number | undefined { + if (value === null || value === undefined || value === "") return undefined; + const year = readYear(value); + return year === undefined ? Number.NaN : year; +} + +function readPresentCvc(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "string" && value.trim() !== "") return value.trim(); + return undefined; +} + +function readMonth(value: unknown): number | undefined { + const raw = + typeof value === "number" + ? String(value) + : typeof value === "string" + ? value.trim() + : ""; + if (!/^\d{1,2}$/.test(raw)) return undefined; + const month = Number(raw); + if (month < 1 || month > 12) return undefined; + return month; +} + +function readYear(value: unknown): number | undefined { + const raw = + typeof value === "number" + ? String(value) + : typeof value === "string" + ? value.trim() + : ""; + if (!/^\d{2}$|^\d{4}$/.test(raw)) return undefined; + return Number(raw); +} + +function readExpiry( + value: unknown, +): { expMonth: number; expYear: number } | undefined { + if (typeof value !== "string") return undefined; + const match = value.trim().match(/^(0?[1-9]|1[0-2])\s*[/-]\s*(\d{2}|\d{4})$/); + if (!match) return undefined; + return { expMonth: Number(match[1]), expYear: Number(match[2]) }; +} + +function scanRawNumbers(body: string): string[] { + const found = new Set(); + for (const match of body.matchAll(/\d(?:[\s-]*\d){12,18}/g)) { + const candidate = cardNumberCandidate(match[0]); + if (candidate) found.add(candidate); + } + return [...found]; +} diff --git a/packages/browser-pay/src/fixtures.ts b/packages/browser-pay/src/fixtures.ts new file mode 100644 index 000000000..ff5fab420 --- /dev/null +++ b/packages/browser-pay/src/fixtures.ts @@ -0,0 +1,24 @@ +import shopifyCardSessionJson from "./fixtures/shopify-card-session.json"; +import refusalJson from "./fixtures/refusals.json"; +import type { + RelayRefusal, + RelayRefusalReason, + RelayRequest, + RelaySuccess, +} from "./contract.js"; + +/** + * One Shopify card-session round trip. The number in the request body is a + * placeholder stand-in (a published test PAN), not a credential. The response + * is only the provider session id. + */ +export const shopifyCardSessionRoundTrip = shopifyCardSessionJson as { + request: RelayRequest; + response: RelaySuccess; +}; + +/** One refusal per reason W3's relay can return. None of them echo the request. */ +export const relayRefusalFixtures = refusalJson as Record< + RelayRefusalReason, + RelayRefusal +>; diff --git a/packages/browser-pay/src/fixtures/refusals.json b/packages/browser-pay/src/fixtures/refusals.json new file mode 100644 index 000000000..43c2ae523 --- /dev/null +++ b/packages/browser-pay/src/fixtures/refusals.json @@ -0,0 +1,14 @@ +{ + "wrong_key": { "ok": false, "reason": "wrong_key" }, + "unknown_placeholder": { "ok": false, "reason": "unknown_placeholder" }, + "closed_placeholder": { "ok": false, "reason": "closed_placeholder" }, + "expired_placeholder": { "ok": false, "reason": "expired_placeholder" }, + "off_profile_destination": { + "ok": false, + "reason": "off_profile_destination" + }, + "replay": { "ok": false, "reason": "replay" }, + "merchant_origin": { "ok": false, "reason": "merchant_origin" }, + "changed_total": { "ok": false, "reason": "changed_total" }, + "cancelled": { "ok": false, "reason": "cancelled" } +} diff --git a/packages/browser-pay/src/fixtures/shopify-card-session.json b/packages/browser-pay/src/fixtures/shopify-card-session.json new file mode 100644 index 000000000..f385b446e --- /dev/null +++ b/packages/browser-pay/src/fixtures/shopify-card-session.json @@ -0,0 +1,28 @@ +{ + "request": { + "transactionId": "tx_fixture_shopify", + "injectionRef": "inj_fixture_shopify", + "merchantOrigin": "https://shop.example", + "url": "https://checkout.pci.shopifyinc.com/sessions", + "method": "POST", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "origin": "https://checkout.pci.shopifyinc.com", + "referer": "https://checkout.pci.shopifyinc.com/", + "user-agent": "Mozilla/5.0 (fixture)", + "accept-language": "en-US,en;q=0.9", + "shopify-identification-signature": "fixture-signature" + }, + "body": "{\"credit_card\":{\"number\":\"4242424242424242\",\"month\":12,\"year\":2028,\"verification_value\":\"123\",\"name\":\"Fixture Buyer\"},\"payment_session_scope\":\"fixture_scope\"}" + }, + "response": { + "ok": true, + "status": 200, + "headers": { + "content-type": "application/json", + "access-control-allow-origin": "https://checkout.pci.shopifyinc.com" + }, + "body": "{\"id\":\"west-fixture-session\"}" + } +} diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts new file mode 100644 index 000000000..7e96cc2a3 --- /dev/null +++ b/packages/browser-pay/src/index.ts @@ -0,0 +1,44 @@ +export { attachInterceptor, resolveCdpUrl } from "./interceptor.js"; +export type { + AttachOptions, + InterceptorEvent, + InterceptorHandle, + InterceptorTarget, +} from "./interceptor.js"; + +export { httpPaymentRelay, stubPaymentRelay } from "./relay.js"; +export type { HttpRelayOptions, PaymentRelay } from "./relay.js"; + +export { + RELAY_REFUSAL_REASONS, + isRelayRefusalReason, + parseRelayResponse, +} from "./contract.js"; +export type { + RelayRefusal, + RelayRefusalReason, + RelayRequest, + RelayResponse, + RelaySuccess, +} from "./contract.js"; + +export { + cardNumberCandidate, + extractTypedCards, + luhnCheckDigit, + matchHeldPlaceholder, + matchHeldPlaceholderInBody, +} from "./detect.js"; +export type { + BodyPlaceholderMatch, + HeldPlaceholder, + HeldPlaceholderMatch, + TypedCard, +} from "./detect.js"; + +export { + relayRefusalFixtures, + shopifyCardSessionRoundTrip, +} from "./fixtures.js"; + +export { maskCardNumber, redact } from "./redact.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..8713db91c --- /dev/null +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -0,0 +1,367 @@ +import { spawn, type ChildProcess } from "node:child_process"; +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 { stubPaymentRelay, type PaymentRelay } from "./relay"; +import type { HeldPlaceholder, RelayRequest } from "./index"; + +const PLACEHOLDER: HeldPlaceholder = { + number: "4242424242424242", + expMonth: 12, + expYear: 2028, + cvc: "123", + 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: PLACEHOLDER.number, + month: PLACEHOLDER.expMonth, + year: PLACEHOLDER.expYear, + verification_value: "999", + }, +}); +const CARD = JSON.stringify({ + credit_card: { + number: PLACEHOLDER.number, + month: PLACEHOLDER.expMonth, + year: PLACEHOLDER.expYear, + verification_value: PLACEHOLDER.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, +): Promise<{ server: http.Server; port: number }> { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/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, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resolve({ server, port }); + }); + }); +} + +function chromeExecutable(): string | undefined { + const candidates = [ + process.env.CHROME_PATH, + "/usr/local/bin/google-chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ]; + return candidates.find((candidate) => candidate && existsSync(candidate)); +} + +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 describeWithChrome = chromeExecutable() ? describe : describe.skip; + +describeWithChrome("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 () => { + const executable = chromeExecutable(); + if (!executable) { + throw new Error( + "google-chrome or chromium is required for the interceptor integration test", + ); + } + userDataDir = await mkdtemp(path.join(os.tmpdir(), "browser-pay-")); + const port = await freePort(); + chrome = spawn( + executable, + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + `--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); + merchant = await startServer(hits, ""); + }, 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 }, + ) { + const events: InterceptorEvent[] = []; + const calls: RelayRequest[] = []; + const wrapped: PaymentRelay = { + relay: async (request) => { + calls.push(request); + return relay.relay(request); + }, + }; + const handle = await attachInterceptor(cdpUrl, { + relay: wrapped, + placeholder: PLACEHOLDER, + transactionId: "tx_integration", + timeoutMs: 5_000, + }); + 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://127.0.0.1:${merchant.port}`; + await page.goto(`${merchantOrigin}/`, { waitUntil: "domcontentloaded" }); + await page.setContent( + `merchant`, + { 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"); + const frame = page + .frames() + .find((candidate) => candidate.url().includes(String(card.port))); + if (!frame) throw new Error("cross-origin card frame was not attached"); + 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 () => ({ + ok: true, + 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(1); + expect(calls[0]).toMatchObject({ + transactionId: "tx_integration", + injectionRef: "inj_integration", + merchantOrigin, + method: "POST", + url: `http://127.0.0.1:${card.port}/sessions`, + body: CARD, + }); + expect(calls[0].merchantOrigin).not.toContain(String(card.port)); + expect(header(calls[0].headers, "content-type")).toBe("application/json"); + + expect(result).toEqual({ + ping: { status: 200, text: JSON.stringify({ ok: true, via: "server" }) }, + unrelated: { + status: 200, + text: JSON.stringify({ ok: true, via: "server" }), + }, + mismatch: { error: "TypeError" }, + card: { status: 201, text: '{"id":"sess_test"}' }, + }); + expect(events.map((event) => event.type)).toEqual([ + "failed", + "paused", + "relayed", + ]); + expect(events[2]).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 () => ({ ok: false, reason: "cancelled" })), + { 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].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: "cancelled", + }); + 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); +}); + +function header( + headers: Record, + name: string, +): string | undefined { + const key = Object.keys(headers).find( + (candidate) => candidate.toLowerCase() === name, + ); + return key ? headers[key] : undefined; +} diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts new file mode 100644 index 000000000..4392eeb91 --- /dev/null +++ b/packages/browser-pay/src/interceptor.ts @@ -0,0 +1,653 @@ +import { CdpConnection, type CdpEvent } from "./cdp.js"; +import { matchHeldPlaceholderInBody, type HeldPlaceholder } from "./detect.js"; +import type { PaymentRelay, RelayResponse } from "./relay.js"; +import { redact } from "./redact.js"; + +export interface AttachOptions { + relay: PaymentRelay; + /** The one placeholder the agent was given for this checkout. */ + placeholder: HeldPlaceholder; + transactionId: string; + /** + * How long to wait for the relay before failing the paused request. + * The relay is not retried. + */ + timeoutMs?: number; +} + +export type InterceptorEvent = + | { type: "paused"; url: string; method: string } + | { type: "relayed"; url: string; status: number } + | { type: "refused"; url: string; reason: string } + | { type: "failed"; url: string; reason: string }; + +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" }, + { type: "worker" }, + { type: "shared_worker" }, + { type: "service_worker" }, +]; + +const RELEVANT_TYPES = new Set(TARGET_FILTER.map((entry) => entry.type)); + +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; +} + +/** + * Attach the payment interceptor to a browser. + * + * Every request is paused. Bodies that do not contain the held placeholder are + * continued immediately. A match is sent to the relay and the paused request + * is fulfilled with the provider response. A mismatch, refusal, timeout, or + * network error fails the paused request. The placeholder is never continued: + * it is a random number in a real BIN range. + */ +export async function attachInterceptor( + target: InterceptorTarget, + options: AttachOptions, +): Promise { + const cdpUrl = resolveCdpUrl(target); + const connection = await CdpConnection.connect(cdpUrl); + const sessions = new Map(); + const seen = new Set(); + const listeners = new Set<(event: InterceptorEvent) => void>(); + let closed = false; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + const emit = (event: InterceptorEvent) => { + for (const listener of listeners) { + try { + listener(event); + } catch { + // A listener must not break interception. + } + } + }; + + const enableSession = async ( + sessionId: string, + waitingForDebugger: boolean, + ) => { + if (seen.has(sessionId)) { + if (waitingForDebugger) { + await connection + .send("Runtime.runIfWaitingForDebugger", {}, sessionId) + .catch(() => {}); + } + return; + } + seen.add(sessionId); + // Fetch has to be on before the target runs, or the card iframe can post + // before we are listening. waitForDebuggerOnStart holds it until then. + // Network has to be enabled before Network.getCookies will return the + // jar the card session needs. + await connection.send("Network.enable", {}, sessionId).catch(() => {}); + await connection + .send( + "Fetch.enable", + { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, + sessionId, + ) + .catch(() => {}); + const session = sessions.get(sessionId); + if (session && RELEVANT_TYPES.has(session.type)) { + await connection + .send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: TARGET_FILTER, + }, + sessionId, + ) + .catch(() => {}); + } + if (waitingForDebugger) { + await connection + .send("Runtime.runIfWaitingForDebugger", {}, sessionId) + .catch(() => {}); + } + }; + + const onAttached = (event: CdpEvent) => { + const params = event.params as { + sessionId?: string; + waitingForDebugger?: boolean; + targetInfo?: { targetId?: string; type?: string; url?: string }; + }; + const sessionId = params.sessionId; + const info = params.targetInfo; + if (!sessionId || !info?.type || !info.targetId) return; + sessions.set(sessionId, { + sessionId, + targetId: info.targetId, + type: info.type, + url: info.url ?? "", + ...(event.sessionId ? { parentSessionId: event.sessionId } : {}), + }); + void enableSession(sessionId, params.waitingForDebugger === true); + }; + + const onPaused = (event: CdpEvent) => { + const sessionId = event.sessionId; + const params = event.params as { + requestId?: string; + resourceType?: string; + request?: { + url?: string; + method?: string; + headers?: unknown; + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; + }; + }; + if (!sessionId || !params.requestId || !params.request) return; + void handlePaused( + connection, + sessions, + options, + timeoutMs, + emit, + () => closed, + sessionId, + params.requestId, + params.resourceType ?? "", + params.request, + ); + }; + + connection.onEvent((event) => { + if (closed) return; + if (event.method === "Target.attachedToTarget") onAttached(event); + else if (event.method === "Target.detachedFromTarget") { + const sessionId = event.params.sessionId as string | undefined; + if (sessionId) sessions.delete(sessionId); + } else if (event.method === "Target.targetInfoChanged") { + const info = event.params.targetInfo as + | { targetId?: string; url?: string } + | undefined; + if (!info?.targetId || !info.url) return; + for (const session of sessions.values()) { + if (session.targetId === info.targetId) session.url = info.url; + } + } else if (event.method === "Fetch.requestPaused") onPaused(event); + }); + + await connection.send("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: TARGET_FILTER, + }); + await connection + .send("Target.setDiscoverTargets", { discover: true }) + .catch(() => {}); + + const existing = await connection + .send<{ + targetInfos?: { targetId: string; type: string }[]; + }>("Target.getTargets") + .catch(() => ({ targetInfos: [] })); + for (const info of existing.targetInfos ?? []) { + if (!RELEVANT_TYPES.has(info.type)) continue; + await connection + .send("Target.attachToTarget", { + targetId: info.targetId, + flatten: true, + }) + .catch(() => {}); + } + + return { + on(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async detach() { + if (closed) return; + closed = true; + for (const sessionId of sessions.keys()) { + await connection.send("Fetch.disable", {}, sessionId).catch(() => {}); + } + await connection + .send("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }) + .catch(() => {}); + connection.close(); + }, + }; +} + +async function handlePaused( + connection: CdpConnection, + sessions: Map, + options: AttachOptions, + timeoutMs: number, + emit: (event: InterceptorEvent) => void, + isClosed: () => boolean, + sessionId: string, + requestId: string, + resourceType: string, + request: { + url?: string; + method?: string; + headers?: unknown; + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; + }, +): Promise { + const url = request.url ?? ""; + const method = request.method ?? "GET"; + const safeUrl = redact(url); + try { + let body = ""; + try { + body = await readBody(connection, sessionId, requestId, request); + } catch { + // A body we cannot read might still be the placeholder. Fail it. + if (request.hasPostData) { + await failRequest(connection, sessionId, requestId); + emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); + return; + } + } + const match = matchHeldPlaceholderInBody(body, options.placeholder); + if (match.kind === "not_held") { + await continueRequest(connection, sessionId, requestId); + return; + } + if (match.kind === "mismatch") { + // The placeholder number is here with someone else's expiry or CVC. + // Continuing it would still put that number on the network. + await failRequest(connection, sessionId, requestId); + emit({ + type: "failed", + url: safeUrl, + reason: "placeholder_mismatch", + }); + return; + } + if (isClosed()) { + await failRequest(connection, sessionId, requestId); + return; + } + // A later injection profile (W7 U3) can reject a matched destination + // here, before the relay call, and must fail the request rather than + // continue it. M0 has no URL filter. + emit({ type: "paused", url: safeUrl, method }); + const merchantOrigin = await readMerchantOrigin( + connection, + sessions, + sessionId, + ); + if (!merchantOrigin) { + await failRequest(connection, sessionId, requestId); + emit({ type: "failed", url: safeUrl, reason: "merchant_origin" }); + return; + } + const headers = await completeHeaders( + connection, + sessions, + sessionId, + url, + resourceType, + request.headers, + ); + let response: RelayResponse; + try { + response = await withTimeout( + options.relay.relay({ + transactionId: options.transactionId, + injectionRef: match.injectionRef, + merchantOrigin, + url, + method, + headers, + body, + }), + timeoutMs, + ); + } catch (error) { + await failRequest(connection, sessionId, requestId); + emit({ + type: "failed", + url: safeUrl, + reason: failureReason(error), + }); + return; + } + if (!response.ok) { + await failRequest(connection, sessionId, requestId); + emit({ type: "refused", url: safeUrl, reason: response.reason }); + return; + } + await fulfillRequest(connection, sessionId, requestId, response); + emit({ type: "relayed", url: safeUrl, status: response.status }); + } catch { + await failRequest(connection, sessionId, requestId); + emit({ type: "failed", url: safeUrl, reason: "network" }); + } +} + +async function readBody( + connection: CdpConnection, + sessionId: string, + requestId: string, + request: { + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; + }, +): Promise { + if (typeof request.postData === "string" && request.postData.length > 0) { + return request.postData; + } + if (request.postDataEntries && request.postDataEntries.length > 0) { + return request.postDataEntries + .map((entry) => Buffer.from(entry.bytes ?? "", "base64").toString("utf8")) + .join(""); + } + if (!request.hasPostData) return ""; + const result = await connection.send<{ postData?: string }>( + "Fetch.getRequestPostData", + { requestId }, + sessionId, + ); + return result.postData ?? ""; +} + +async function readMerchantOrigin( + connection: CdpConnection, + sessions: Map, + sessionId: string, +): Promise { + const pageSessionId = topPageSession(sessions, sessionId); + if (!pageSessionId) return undefined; + const evaluated = await connection + .send<{ + result?: { value?: unknown }; + }>("Runtime.evaluate", { expression: "location.origin", returnByValue: true }, pageSessionId) + .catch(() => null); + const value = evaluated?.result?.value; + if (typeof value === "string" && /^https?:\/\//.test(value)) return value; + const url = sessions.get(pageSessionId)?.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 seen = new Set(); + while (current && !seen.has(current)) { + seen.add(current); + const session = sessions.get(current); + if (!session) return undefined; + if (session.type === "page") return current; + if (!session.parentSessionId) return undefined; + current = session.parentSessionId; + } + return undefined; +} + +/** + * At the Fetch Request stage Chrome has not yet added the headers its network + * stack appends (Cookie, Sec-Fetch-*, Accept-Language). Shopify's card session + * needs those. Never override a header the request already has. + */ +async function completeHeaders( + connection: CdpConnection, + sessions: Map, + sessionId: string, + requestUrl: string, + resourceType: string, + rawHeaders: unknown, +): Promise> { + const headers = headerRecord(rawHeaders); + const has = (name: string) => + Object.keys(headers).some((key) => key.toLowerCase() === name); + const frameUrl = sessions.get(sessionId)?.url || requestUrl; + try { + const target = new URL(requestUrl); + const frame = new URL(frameUrl); + if (!has("sec-fetch-site")) { + const site = (value: URL) => + value.hostname.split(".").slice(-2).join("."); + headers["Sec-Fetch-Site"] = + target.origin === frame.origin + ? "same-origin" + : site(target) === site(frame) && target.protocol === frame.protocol + ? "same-site" + : "cross-site"; + } + if (!has("sec-fetch-dest") && /^(Fetch|XHR)$/.test(resourceType)) { + headers["Sec-Fetch-Dest"] = "empty"; + } + if (!has("accept-language")) { + const languages = await connection + .send<{ + result?: { value?: unknown }; + }>("Runtime.evaluate", { expression: "navigator.languages", returnByValue: true }, sessionId) + .catch(() => null); + const list = languages?.result?.value; + if ( + Array.isArray(list) && + list.every((entry) => typeof entry === "string") + ) { + headers["Accept-Language"] = list + .map((language, index) => + index === 0 + ? language + : `${language};q=${Math.max(0.1, 1 - index / 10).toFixed(1)}`, + ) + .join(","); + } + } + if (!has("cookie")) { + const cookies = await connection + .send<{ + cookies?: { name: string; value: string }[]; + }>("Network.getCookies", { urls: [requestUrl] }, sessionId) + .catch(() => null); + if (cookies?.cookies?.length) { + headers["Cookie"] = cookies.cookies + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "); + } + } + } catch { + // Keep the headers Chrome already gave us. + } + return headers; +} + +function headerRecord(headers: unknown): Record { + if (!headers) return {}; + if (Array.isArray(headers)) { + const out: Record = {}; + for (const entry of headers) { + if (entry && typeof entry.name === "string") { + out[entry.name] = String(entry.value ?? ""); + } + } + return out; + } + if (typeof headers === "object") { + const out: Record = {}; + for (const [key, value] of Object.entries( + headers as Record, + )) { + if (typeof value === "string") out[key] = value; + } + return out; + } + return {}; +} + +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: Extract, +): Promise { + const responseHeaders = Object.entries(response.headers).flatMap( + ([name, value]) => { + if (RESPONSE_HOP.has(name.toLowerCase())) return []; + if (name.toLowerCase() === "set-cookie") { + return value + .split("\n") + .filter((part) => part.length > 0) + .map((part) => ({ name, value: part })); + } + return [{ name, value }]; + }, + ); + await connection.send( + "Fetch.fulfillRequest", + { + requestId, + responseCode: response.status, + responseHeaders, + body: Buffer.from(response.body, "utf8").toString("base64"), + }, + sessionId, + ); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timer: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error("Payment relay timed out"); + error.name = "TimeoutError"; + reject(error); + }, timeoutMs); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer!); + } +} + +function failureReason(error: unknown): string { + if ( + error instanceof Error && + (error.name === "TimeoutError" || + error.message === "Payment relay timed out") + ) { + return "timeout"; + } + return "network"; +} + +export function resolveCdpUrl(target: InterceptorTarget): string { + if (typeof target === "string") { + if (!target) throw new Error("A CDP websocket URL is required"); + return target; + } + if ( + target && + typeof (target as { wsEndpoint?: unknown }).wsEndpoint === "function" + ) { + const url = (target as { wsEndpoint(): string }).wsEndpoint(); + if (url) return url; + } + const browserFromBrowser = ( + target as { browser?: () => { wsEndpoint(): string } | null } + ).browser; + if (typeof browserFromBrowser === "function") { + const url = browserFromBrowser.call(target)?.wsEndpoint(); + if (url) return url; + } + const context = ( + target as { + context?: () => { browser(): { wsEndpoint(): string } | null } | null; + } + ).context; + if (typeof context === "function") { + const url = context.call(target)?.browser()?.wsEndpoint(); + if (url) return url; + } + throw new Error( + "Pass a CDP websocket URL, or a Playwright/Puppeteer browser or page whose browser exposes wsEndpoint()", + ); +} diff --git a/packages/browser-pay/src/redact.ts b/packages/browser-pay/src/redact.ts new file mode 100644 index 000000000..97525f85d --- /dev/null +++ b/packages/browser-pay/src/redact.ts @@ -0,0 +1,19 @@ +/** Mask a card number to its last four digits. Shorter values collapse. */ +export function maskCardNumber(number: string): string { + const digits = number.replace(/\D/g, ""); + if (digits.length < 4) return "••••"; + return `••••${digits.slice(-4)}`; +} + +/** + * Replace 13–19 digit runs (spaces and dashes allowed) with a last-4 mask. + * Used on URLs and error text so a number typed into a query string cannot + * leave the process through an event. + */ +export function redact(value: string): string { + return value.replace(/\d(?:[\s-]*\d){12,18}/g, (run) => { + const digits = run.replace(/\D/g, ""); + if (digits.length < 13 || digits.length > 19) return run; + return maskCardNumber(digits); + }); +} diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts new file mode 100644 index 000000000..539013e75 --- /dev/null +++ b/packages/browser-pay/src/relay.test.ts @@ -0,0 +1,170 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { + RELAY_REFUSAL_REASONS, + parseRelayResponse, + type RelayRequest, +} from "./contract"; +import { relayRefusalFixtures, shopifyCardSessionRoundTrip } from "./fixtures"; +import { matchHeldPlaceholderInBody } from "./detect"; +import { httpPaymentRelay, stubPaymentRelay } from "./relay"; + +const REQUEST: RelayRequest = shopifyCardSessionRoundTrip.request; + +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("T7 fixtures", () => { + it("round-trips a Shopify card session and matches the placeholder", () => { + const { request, response } = shopifyCardSessionRoundTrip; + expect(request.url).toBe("https://checkout.pci.shopifyinc.com/sessions"); + expect(request.merchantOrigin).toBe("https://shop.example"); + expect(Object.keys(response).sort()).toEqual([ + "body", + "headers", + "ok", + "status", + ]); + expect(response).toEqual({ + ok: true, + status: 200, + headers: { + "content-type": "application/json", + "access-control-allow-origin": "https://checkout.pci.shopifyinc.com", + }, + body: '{"id":"west-fixture-session"}', + }); + expect( + matchHeldPlaceholderInBody(request.body, { + number: "4242424242424242", + expMonth: 12, + expYear: 2028, + cvc: "123", + injectionRef: request.injectionRef, + }), + ).toEqual({ kind: "match", injectionRef: "inj_fixture_shopify" }); + expect(response.body).not.toMatch(/\d(?:[\s-]*\d){12,18}/); + }); + + it("has one refusal per reason and does not echo a request", () => { + expect(Object.keys(relayRefusalFixtures).sort()).toEqual( + [...RELAY_REFUSAL_REASONS].sort(), + ); + for (const reason of RELAY_REFUSAL_REASONS) { + expect(relayRefusalFixtures[reason]).toEqual({ ok: false, reason }); + expect(parseRelayResponse(relayRefusalFixtures[reason])).toEqual({ + ok: false, + reason, + }); + } + }); +}); + +describe("httpPaymentRelay", () => { + it("POSTs the T7 body to David's route with the API key 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", + timeoutMs: 1000, + }); + 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(JSON.parse(seen.body ?? "")).toEqual(REQUEST); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("returns a provider response and a typed refusal", async () => { + const payloads = [ + shopifyCardSessionRoundTrip.response, + relayRefusalFixtures.cancelled, + ]; + const server = await listen((_req, res) => { + const payload = payloads.shift(); + res.writeHead(payload && "ok" in payload && payload.ok ? 200 : 403, { + "content-type": "application/json", + }); + res.end(JSON.stringify(payload)); + }); + try { + const relay = httpPaymentRelay({ + baseUrl: `${origin(server)}/`, + apiKey: "test-key", + }); + await expect(relay.relay(REQUEST)).resolves.toEqual( + shopifyCardSessionRoundTrip.response, + ); + await expect(relay.relay(REQUEST)).resolves.toEqual({ + ok: false, + reason: "cancelled", + }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("times out without including a response body", async () => { + const server = await listen(() => { + // Never respond. + }); + try { + const relay = httpPaymentRelay({ + baseUrl: origin(server), + apiKey: "test-key", + timeoutMs: 50, + }); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay timed out", + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); +}); + +describe("stubPaymentRelay", () => { + it("returns the handler's response", async () => { + const relay = stubPaymentRelay(async () => relayRefusalFixtures.replay); + await expect(relay.relay(REQUEST)).resolves.toEqual({ + ok: false, + reason: "replay", + }); + }); +}); diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts new file mode 100644 index 000000000..6749ed8f4 --- /dev/null +++ b/packages/browser-pay/src/relay.ts @@ -0,0 +1,103 @@ +import { + parseRelayResponse, + type RelayRequest, + type RelayResponse, +} from "./contract.js"; + +export type { RelayRequest, RelayResponse }; + +/** Pluggable relay. The HTTP client and the in-memory stub both implement this. */ +export interface PaymentRelay { + relay(request: RelayRequest): Promise; +} + +export interface HttpRelayOptions { + /** API origin, for example `https://api.sapiom.ai`. The `/v1/...` path is added here. */ + baseUrl: string; + /** Defaults to `SAPIOM_API_KEY`. Sent as `x-api-key`, same as the rest of the SDK. */ + apiKey?: string; + /** One attempt. A timeout fails the paused request; it is never retried. */ + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +const DEFAULT_TIMEOUT_MS = 20_000; + +/** + * David's route: `POST /v1/transactions/:id/payment/relay`. + * The route may not be deployed yet. One attempt, no retry. + */ +export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { + 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", + ); + } + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const fetchImpl = options.fetchImpl ?? fetch; + return { + async relay(request) { + const url = `${baseUrl}/v1/transactions/${encodeURIComponent(request.transactionId)}/payment/relay`; + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + redirect: "manual", + headers: { + accept: "application/json", + "content-type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(request), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + if (isTimeout(error)) throw new Error("Payment relay timed out"); + throw new Error("Payment relay failed"); + } + // A redirect would be the provider (or an attacker) pointing the real + // card somewhere else. Fail closed without reading a Location header. + if (response.status >= 300 && response.status < 400) { + throw new Error("Payment relay refused a redirect"); + } + const text = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (parsed !== undefined) { + try { + return parseRelayResponse(parsed); + } catch { + // Fall through to a status-only error. Never include `text`. + } + } + throw new Error(`Payment relay failed (HTTP ${response.status})`); + }, + }; +} + +/** In-memory relay for tests and local demos. */ +export function stubPaymentRelay( + handler: (request: RelayRequest) => RelayResponse | Promise, +): PaymentRelay { + return { + relay(request) { + return Promise.resolve(handler(request)); + }, + }; +} + +function isTimeout(error: unknown): boolean { + // Jest runs this file in a vm, so a DOMException from Node's fetch is not + // an `instanceof Error` here. Read the fields instead. + if (!error || typeof error !== "object") return false; + const name = "name" in error ? String(error.name) : ""; + const message = "message" in error ? String(error.message) : ""; + if (name === "TimeoutError" || name === "AbortError") return true; + return /timeout/i.test(message); +} diff --git a/packages/browser-pay/tsconfig.build.json b/packages/browser-pay/tsconfig.build.json new file mode 100644 index 000000000..6fb75ac16 --- /dev/null +++ b/packages/browser-pay/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true + }, + "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..2af2a9e3b --- /dev/null +++ b/packages/browser-pay/tsconfig.cjs.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist/cjs", + "composite": true + }, + "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..cabfe5714 --- /dev/null +++ b/packages/browser-pay/tsconfig.esm.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./dist/esm", + "composite": true + }, + "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..e201dd2d1 --- /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"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/browser-pay/tsconfig.json b/packages/browser-pay/tsconfig.json new file mode 100644 index 000000000..a4352dcf6 --- /dev/null +++ b/packages/browser-pay/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node", "jest"] + }, + "include": ["src/**/*.ts", "src/**/*.json"], + "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': From bc59eed0a981938ba1454182118b16c8ee69c774 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 01:16:28 +0000 Subject: [PATCH 02/26] fix(browser-pay): relay the Shopify Payments test card The dev store rejects the Bogus Gateway card. Demo relays now substitute Shopify Payments' published test card. RELAY=bt vaults that card in Basis Theory and forwards token expressions through the ephemeral proxy. A management key creates a private application for the run and deletes it. Co-authored-by: Rimmely Ewan --- packages/browser-pay/README.md | 16 +- .../browser-pay/examples/relays/bogus.test.ts | 15 +- packages/browser-pay/examples/relays/bogus.ts | 14 +- .../examples/relays/bt-proxy.test.ts | 153 ++++++++- .../browser-pay/examples/relays/bt-proxy.ts | 233 ++++++++++++- .../relays/shopify-payments-test-card.ts | 10 + packages/browser-pay/examples/shopify-e2e.ts | 315 ++++++++++++++---- 7 files changed, 661 insertions(+), 95 deletions(-) create mode 100644 packages/browser-pay/examples/relays/shopify-payments-test-card.ts diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index ea920392f..9f0eb317e 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -45,7 +45,11 @@ handle.on((event) => { A platform that already has a CDP URL skips session create: ```typescript -await attachInterceptor(process.env.CDP_URL!, { relay, transactionId, placeholder }); +await attachInterceptor(process.env.CDP_URL!, { + relay, + transactionId, + placeholder, +}); ``` `examples/attach.ts` is that script. The HTTP relay calls `POST /v1/transactions/:id/payment/relay` with the `x-api-key` header. That route may not be deployed yet. Swap a demo relay in without changing the interceptor: both implement `PaymentRelay`. @@ -60,19 +64,19 @@ Placeholder detection matches the backend's placeholder-detection rules (separat ## Shopify dev-store script -`examples/shopify-e2e.ts` is opt-in and is not part of `pnpm test`. It launches headless Chromium with a CDP port (or `BROWSER=sapiom` to create a Sapiom browser session), attaches the interceptor, and drives the dev store through a second Playwright connection. The store is `https://1a2uh3-r7.myshopify.com`, variant `67652208394372`, Bogus Gateway test mode. +`examples/shopify-e2e.ts` is opt-in and is not part of `pnpm test`. It launches headless Chromium with a CDP port, or `BROWSER=sapiom` to create a Sapiom browser session (`captchaSolver: true`) and attach to the returned CDP URL. A second Playwright connection drives the dev store. The store is `https://1a2uh3-r7.myshopify.com`, variant `67652208394372`, Shopify Payments test mode. Demo relays live under `examples/relays/` and are not published: -- `RELAY=bogus` substitutes the Bogus Gateway success card (`1`, `12/2030`, `111`) and forwards. -- `RELAY=bt` sends the paused request through Basis Theory's ephemeral proxy. Set `BT_PRIVATE_KEY` and `BT_TOKEN_ID`. +- `RELAY=bogus` substitutes Shopify Payments' published test card (Visa ending 4242, expiry 12/2030, any CVC) and forwards. +- `RELAY=bt` vaults that test card in Basis Theory and sends the paused request through the ephemeral proxy with token expressions. `BT_PRIVATE_KEY` may be a private application key or a test-tenant management key. A management key cannot create tokens, so the script creates a private application for the run and deletes it afterwards. The card token is deleted too. `BT_TOKEN_ID` is not required. - `RELAY=http` uses `httpPaymentRelay` (`SAPIOM_BASE_URL` + `SAPIOM_API_KEY`). ```bash cd packages/browser-pay RELAY=bogus npx tsx examples/shopify-e2e.ts -RELAY=bt BT_PRIVATE_KEY=… BT_TOKEN_ID=… npx tsx examples/shopify-e2e.ts +BROWSER=sapiom RELAY=bt SAPIOM_API_KEY=… BT_PRIVATE_KEY=… npx tsx examples/shopify-e2e.ts MODE=refuse RELAY=bogus npx tsx examples/shopify-e2e.ts ``` -`MODE=refuse` fails the card request closed so the placeholder is not forwarded and no order is confirmed. The script clicks pay once. If Shopify shows a captcha, it stops and says so. +`MODE=refuse` fails the card request closed so the placeholder is not forwarded and no order is confirmed. The script clicks pay once. With `BROWSER=sapiom` it waits for the session's captcha solver. It does not bypass a captcha itself, and it stops if one is still showing. diff --git a/packages/browser-pay/examples/relays/bogus.test.ts b/packages/browser-pay/examples/relays/bogus.test.ts index 9fe8f3f7f..9ad4b9d07 100644 --- a/packages/browser-pay/examples/relays/bogus.test.ts +++ b/packages/browser-pay/examples/relays/bogus.test.ts @@ -1,8 +1,9 @@ import type { HeldPlaceholder, RelayRequest } from "../../src/index.js"; import { bogusShopifyRelay } from "./bogus.js"; +import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; const PLACEHOLDER: HeldPlaceholder = { - number: "4242424242424242", + number: "4123456789012349", expMonth: 11, expYear: 2029, cvc: "321", @@ -11,7 +12,7 @@ const PLACEHOLDER: HeldPlaceholder = { const BODY = JSON.stringify({ credit_card: { - number: "4242-4242-4242-4242", + number: "4123-4567-8901-2349", name: "Test Buyer", month: 11, year: 2029, @@ -38,7 +39,7 @@ function request(body = BODY): RelayRequest { } describe("bogusShopifyRelay", () => { - it("substitutes the Bogus Gateway success card and forwards once", async () => { + it("substitutes the Shopify Payments test card and forwards once", async () => { const seen: { url?: string; init?: RequestInit } = {}; const fetchImpl = (async ( url: string | URL | Request, @@ -66,11 +67,11 @@ describe("bogusShopifyRelay", () => { const forwarded = JSON.parse(String(seen.init?.body)); expect(forwarded).toEqual({ credit_card: { - number: "1", + number: SHOPIFY_PAYMENTS_TEST_CARD.number, name: "Test Buyer", - month: 12, - year: 2030, - verification_value: "111", + month: SHOPIFY_PAYMENTS_TEST_CARD.expMonth, + year: SHOPIFY_PAYMENTS_TEST_CARD.expYear, + verification_value: SHOPIFY_PAYMENTS_TEST_CARD.cvc, }, payment_session_scope: "scope", }); diff --git a/packages/browser-pay/examples/relays/bogus.ts b/packages/browser-pay/examples/relays/bogus.ts index 4b5f9a1e3..65031c87f 100644 --- a/packages/browser-pay/examples/relays/bogus.ts +++ b/packages/browser-pay/examples/relays/bogus.ts @@ -1,4 +1,5 @@ import type { HeldPlaceholder, PaymentRelay } from "../../src/index.js"; +import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; import { forwardRequestHeaders, providerResponseHeaders, @@ -6,16 +7,11 @@ import { } from "./replace-card.js"; /** - * Demo relay for the Shopify Bogus Gateway. Not part of the published SDK. - * Substitutes the gateway's success test card and forwards the paused request. - * Test data only: number "1", expiry 12/2030, cvc 111. + * Demo relay that forwards the paused request with Shopify Payments' published + * test card in place of the placeholder. Not part of the published SDK. + * The dev store rejects the old Bogus Gateway success value ("1"). */ -export const BOGUS_CARD = { - number: "1", - expMonth: 12, - expYear: 2030, - cvc: "111", -} as const; +export const BOGUS_CARD = SHOPIFY_PAYMENTS_TEST_CARD; export function bogusShopifyRelay(options: { placeholder: HeldPlaceholder; diff --git a/packages/browser-pay/examples/relays/bt-proxy.test.ts b/packages/browser-pay/examples/relays/bt-proxy.test.ts index cb0f27d6f..ff9919084 100644 --- a/packages/browser-pay/examples/relays/bt-proxy.test.ts +++ b/packages/browser-pay/examples/relays/bt-proxy.test.ts @@ -1,5 +1,10 @@ import type { HeldPlaceholder, RelayRequest } from "../../src/index.js"; -import { btProxyRelay } from "./bt-proxy.js"; +import { + btAccessFromKey, + btProxyRelay, + createShopifyPaymentsTestToken, +} from "./bt-proxy.js"; +import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; const PLACEHOLDER: HeldPlaceholder = { number: "4242424242424242", @@ -107,3 +112,149 @@ describe("btProxyRelay", () => { ); }); }); + +describe("createShopifyPaymentsTestToken", () => { + it("vaults the Shopify Payments test card and deletes the token afterwards", async () => { + const calls: { url: string; init?: RequestInit }[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + calls.push({ url: String(url), init }); + if (init?.method === "DELETE") return new Response(null, { status: 204 }); + return new Response( + JSON.stringify({ + id: "tok_created_1", + data: { number: SHOPIFY_PAYMENTS_TEST_CARD.number }, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + const created = await createShopifyPaymentsTestToken({ + apiKey: API_KEY, + fetchImpl, + }); + expect(created.tokenId).toBe("tok_created_1"); + const create = calls[0]; + expect(create?.url).toBe("https://api.basistheory.com/tokens"); + expect(create?.init?.method).toBe("POST"); + const headers = create?.init?.headers as Record; + expect(headers["BT-API-KEY"]).toBe(API_KEY); + expect(JSON.parse(String(create?.init?.body))).toEqual({ + type: "card", + data: { + number: SHOPIFY_PAYMENTS_TEST_CARD.number, + expiration_month: SHOPIFY_PAYMENTS_TEST_CARD.expMonth, + expiration_year: SHOPIFY_PAYMENTS_TEST_CARD.expYear, + cvc: SHOPIFY_PAYMENTS_TEST_CARD.cvc, + }, + }); + await created.release(); + expect(calls[1]?.url).toBe( + "https://api.basistheory.com/tokens/tok_created_1", + ); + expect(calls[1]?.init?.method).toBe("DELETE"); + }); + + it("does not echo the card number when token create fails", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + title: "bad", + echo: SHOPIFY_PAYMENTS_TEST_CARD.number, + }), + { status: 400 }, + )) as typeof fetch; + await expect( + createShopifyPaymentsTestToken({ apiKey: API_KEY, fetchImpl }), + ).rejects.toThrow("Basis Theory token create failed (HTTP 400: bad)"); + try { + await createShopifyPaymentsTestToken({ apiKey: API_KEY, fetchImpl }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).not.toContain(SHOPIFY_PAYMENTS_TEST_CARD.number); + expect(message).not.toContain(API_KEY); + } + }); +}); + +describe("btAccessFromKey", () => { + it("uses a private key without creating an application", async () => { + const fetchImpl = jest.fn(async () => + Response.json({ type: "private" }), + ) as unknown as typeof fetch; + const access = await btAccessFromKey({ apiKey: API_KEY, fetchImpl }); + expect(access.createdApplication).toBe(false); + expect(access.apiKey).toBe(API_KEY); + await access.release(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("creates a private application from a management key and deletes it", async () => { + const calls: { url: string; init?: RequestInit }[] = []; + const createdKey = "key_test_private_created"; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + calls.push({ url: String(url), init }); + if (!init || init.method === "GET" || init.method === undefined) { + return Response.json({ type: "management" }); + } + if (init.method === "DELETE") return new Response(null, { status: 204 }); + return Response.json({ + id: "app_demo_1", + keys: [{ key: createdKey }], + }); + }) as typeof fetch; + const access = await btAccessFromKey({ + apiKey: "key_test_management", + fetchImpl, + }); + expect(access.createdApplication).toBe(true); + expect(access.apiKey).toBe(createdKey); + const create = calls[1]; + expect(create?.url).toBe("https://api.basistheory.com/applications"); + expect(JSON.parse(String(create?.init?.body))).toEqual({ + name: "browser-pay demo", + type: "private", + permissions: [ + "token:create", + "token:delete", + "token:use", + "proxy:invoke", + ], + }); + await access.release(); + expect(calls[2]?.url).toBe( + "https://api.basistheory.com/applications/app_demo_1", + ); + expect(calls[2]?.init?.method).toBe("DELETE"); + const deleteHeaders = calls[2]?.init?.headers as Record; + expect(deleteHeaders["BT-API-KEY"]).toBe("key_test_management"); + }); + + it("does not include the created key when application create fails", async () => { + const fetchImpl = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { + if (!init || init.method === undefined || init.method === "GET") { + return Response.json({ type: "management" }); + } + return Response.json( + { + title: "missing_permission", + detail: "Missing permission: application:create", + key: "key_test_should_not_leak", + }, + { status: 403 }, + ); + }) as typeof fetch; + await expect( + btAccessFromKey({ apiKey: "key_test_management", fetchImpl }), + ).rejects.toThrow( + "Basis Theory application create failed (HTTP 403: missing_permission: Missing permission: application:create)", + ); + }); +}); diff --git a/packages/browser-pay/examples/relays/bt-proxy.ts b/packages/browser-pay/examples/relays/bt-proxy.ts index 4199ed008..8c10caa85 100644 --- a/packages/browser-pay/examples/relays/bt-proxy.ts +++ b/packages/browser-pay/examples/relays/bt-proxy.ts @@ -1,4 +1,5 @@ import type { HeldPlaceholder, PaymentRelay } from "../../src/index.js"; +import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; import { forwardRequestHeaders, providerResponseHeaders, @@ -13,6 +14,232 @@ import { * BT_PRIVATE_KEY and BT_TOKEN_ID from the environment when constructed from * `btProxyRelayFromEnv`. Never logs the key, the token id, or a card value. */ + +export interface CreatedCardToken { + tokenId: string; + /** Deletes the token. A failed delete is logged as a status code only. */ + release: () => Promise; +} + +export interface BtAccess { + apiKey: string; + /** True when this run created a private application from a management key. */ + createdApplication: boolean; + /** No-op for a private key. Deletes an application created for a management key. */ + release: () => Promise; +} + +const PRIVATE_APP_PERMISSIONS = [ + "token:create", + "token:delete", + "token:use", + "proxy:invoke", +] as const; + +async function safeFailure(response: Response, action: string): Promise { + let extra = ""; + try { + const body = (await response.json()) as { + title?: unknown; + detail?: unknown; + }; + const title = typeof body.title === "string" ? body.title : ""; + const detail = typeof body.detail === "string" ? body.detail : ""; + const combined = [title, detail].filter(Boolean).join(": "); + if ( + combined && + combined.length <= 180 && + !/key_|[0-9a-f]{8}-[0-9a-f]{4}/i.test(combined) && + !/\d(?:\s*\d){11,}/.test(combined) + ) { + extra = `: ${combined}`; + } + } catch { + // The status is enough when the body is not JSON. + } + return new Error( + `Basis Theory ${action} failed (HTTP ${response.status}${extra})`, + ); +} + +function btBaseUrl(baseUrl?: string): string { + return ( + baseUrl ?? + process.env.BT_API_BASE ?? + "https://api.basistheory.com" + ).replace(/\/$/, ""); +} + +/** + * Vault Shopify Payments' published test card in the Basis Theory test tenant. + * The PAN is sent only on this create call. The relay then uses token expressions. + */ +export async function createShopifyPaymentsTestToken(options: { + apiKey: string; + baseUrl?: string; + fetchImpl?: typeof fetch; +}): Promise { + if (!options.apiKey) { + throw new Error("BT_PRIVATE_KEY is required to create a card token"); + } + const fetchImpl = options.fetchImpl ?? fetch; + const base = btBaseUrl(options.baseUrl); + const card = SHOPIFY_PAYMENTS_TEST_CARD; + const response = await fetchImpl(`${base}/tokens`, { + method: "POST", + redirect: "manual", + headers: { + "BT-API-KEY": options.apiKey, + "content-type": "application/json", + }, + body: JSON.stringify({ + type: "card", + data: { + number: card.number, + expiration_month: card.expMonth, + expiration_year: card.expYear, + cvc: card.cvc, + }, + }), + signal: AbortSignal.timeout(30_000), + }); + if (response.status >= 300 && response.status < 400) { + throw new Error("Basis Theory token create refused a redirect"); + } + if (!response.ok) throw await safeFailure(response, "token create"); + const payload = (await response.json()) as { id?: unknown }; + const tokenId = payload.id; + if (typeof tokenId !== "string" || !/^[A-Za-z0-9._:-]+$/.test(tokenId)) { + throw new Error("Basis Theory token create returned no token id"); + } + return { + tokenId, + release: async () => { + try { + const deleted = await fetchImpl( + `${base}/tokens/${encodeURIComponent(tokenId)}`, + { + method: "DELETE", + redirect: "manual", + headers: { "BT-API-KEY": options.apiKey }, + signal: AbortSignal.timeout(30_000), + }, + ); + if (!deleted.ok && deleted.status !== 404) { + console.error( + `Basis Theory token delete failed (HTTP ${deleted.status})`, + ); + } + await deleted.arrayBuffer().catch(() => undefined); + } catch { + console.error("Basis Theory token delete failed"); + } + }, + }; +} + +/** + * A private application key that can create a card token and invoke the proxy. + * `BT_PRIVATE_KEY` in this environment may be a management key, which cannot + * do either. A management key creates a private application for this run. + * The application is deleted by `release`. The returned key is never logged. + */ +export async function btAccessFromKey(options: { + apiKey: string; + baseUrl?: string; + fetchImpl?: typeof fetch; +}): Promise { + if (!options.apiKey) { + throw new Error("BT_PRIVATE_KEY is required"); + } + const fetchImpl = options.fetchImpl ?? fetch; + const base = btBaseUrl(options.baseUrl); + const current = await fetchImpl(`${base}/applications/key`, { + method: "GET", + redirect: "manual", + headers: { "BT-API-KEY": options.apiKey, accept: "application/json" }, + signal: AbortSignal.timeout(30_000), + }); + if (current.status >= 300 && current.status < 400) { + throw new Error("Basis Theory application lookup refused a redirect"); + } + if (!current.ok) { + await current.arrayBuffer().catch(() => undefined); + return { + apiKey: options.apiKey, + createdApplication: false, + release: async () => {}, + }; + } + const app = (await current.json()) as { type?: unknown }; + if (app.type !== "management") { + return { + apiKey: options.apiKey, + createdApplication: false, + release: async () => {}, + }; + } + const created = await fetchImpl(`${base}/applications`, { + method: "POST", + redirect: "manual", + headers: { + "BT-API-KEY": options.apiKey, + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "browser-pay demo", + type: "private", + permissions: [...PRIVATE_APP_PERMISSIONS], + }), + signal: AbortSignal.timeout(30_000), + }); + if (created.status >= 300 && created.status < 400) { + throw new Error("Basis Theory application create refused a redirect"); + } + if (!created.ok) throw await safeFailure(created, "application create"); + const body = (await created.json()) as { + id?: unknown; + keys?: Array<{ key?: unknown }>; + }; + const applicationId = body.id; + const privateKey = body.keys?.find( + (item) => typeof item.key === "string", + )?.key; + if ( + typeof applicationId !== "string" || + !/^[A-Za-z0-9._:-]+$/.test(applicationId) || + typeof privateKey !== "string" || + privateKey.length < 8 + ) { + throw new Error("Basis Theory application create returned no key"); + } + return { + apiKey: privateKey, + createdApplication: true, + release: async () => { + try { + const deleted = await fetchImpl( + `${base}/applications/${encodeURIComponent(applicationId)}`, + { + method: "DELETE", + redirect: "manual", + headers: { "BT-API-KEY": options.apiKey }, + signal: AbortSignal.timeout(30_000), + }, + ); + if (!deleted.ok && deleted.status !== 404) { + console.error( + `Basis Theory application delete failed (HTTP ${deleted.status})`, + ); + } + await deleted.arrayBuffer().catch(() => undefined); + } catch { + console.error("Basis Theory application delete failed"); + } + }, + }; +} + export function btProxyRelay(options: { placeholder: HeldPlaceholder; apiKey: string; @@ -25,11 +252,7 @@ export function btProxyRelay(options: { throw new Error("BT token id is not a usable token id"); } const fetchImpl = options.fetchImpl ?? fetch; - const base = ( - options.baseUrl ?? - process.env.BT_API_BASE ?? - "https://api.basistheory.com" - ).replace(/\/$/, ""); + const base = btBaseUrl(options.baseUrl); const expression = (path: string) => `{{ token: ${options.tokenId} | json: '$.data.${path}' }}`; return { diff --git a/packages/browser-pay/examples/relays/shopify-payments-test-card.ts b/packages/browser-pay/examples/relays/shopify-payments-test-card.ts new file mode 100644 index 000000000..afb2b6fce --- /dev/null +++ b/packages/browser-pay/examples/relays/shopify-payments-test-card.ts @@ -0,0 +1,10 @@ +/** + * Shopify Payments test-mode Visa. Shopify publishes this number for test + * checkouts. It is not a live card. Logs must still mask it to the last four. + */ +export const SHOPIFY_PAYMENTS_TEST_CARD = { + number: "4242424242424242", + expMonth: 12, + expYear: 2030, + cvc: "123", +} as const; diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 702bb4013..90bd44cd8 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -2,14 +2,16 @@ * Opt-in Shopify dev-store checkout. Not part of CI. * * RELAY=bogus npx tsx examples/shopify-e2e.ts - * RELAY=bt BT_PRIVATE_KEY=… BT_TOKEN_ID=… npx tsx examples/shopify-e2e.ts + * BROWSER=sapiom RELAY=bt SAPIOM_API_KEY=… BT_PRIVATE_KEY=… \ + * npx tsx examples/shopify-e2e.ts * RELAY=http SAPIOM_BASE_URL=… SAPIOM_API_KEY=… TRANSACTION_ID=… \ * INJECTION_REF=… PLACEHOLDER_NUMBER=… PLACEHOLDER_EXP_MONTH=… \ * PLACEHOLDER_EXP_YEAR=… PLACEHOLDER_CVC=… npx tsx examples/shopify-e2e.ts * MODE=refuse RELAY=bogus npx tsx examples/shopify-e2e.ts - * BROWSER=sapiom SAPIOM_API_KEY=… RELAY=bogus npx tsx examples/shopify-e2e.ts * - * One checkout per process. Pay is clicked once. A captcha is reported and not bypassed. + * One checkout per process. Pay is clicked once. RELAY=bt creates a Basis Theory + * card token at setup and deletes it afterwards. BROWSER=sapiom asks the Sapiom + * browser session to solve captchas. This script does not bypass them itself. */ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; @@ -19,7 +21,7 @@ import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { createClient, type Sapiom } from "@sapiom/tools"; -import { chromium, type Frame, type Page } from "playwright"; +import { chromium, type Browser, type Frame, type Page } from "playwright"; import { attachInterceptor, httpPaymentRelay, @@ -28,10 +30,16 @@ import { stubPaymentRelay, type HeldPlaceholder, type InterceptorEvent, + type InterceptorHandle, type PaymentRelay, } from "../src/index.js"; import { bogusShopifyRelay } from "./relays/bogus.js"; -import { btProxyRelayFromEnv } from "./relays/bt-proxy.js"; +import { + btAccessFromKey, + btProxyRelay, + createShopifyPaymentsTestToken, +} from "./relays/bt-proxy.js"; +import { SHOPIFY_PAYMENTS_TEST_CARD } from "./relays/shopify-payments-test-card.js"; const CART_URL = "https://1a2uh3-r7.myshopify.com/cart/67652208394372:1"; const SHOP_ORIGIN = "https://1a2uh3-r7.myshopify.com"; @@ -56,7 +64,7 @@ async function main(): Promise { const mode = process.env.MODE === "refuse" ? "refuse" : "pay"; const placeholder = loadPlaceholder(relayName); console.log( - `checkout ${mode} via RELAY=${relayName} BROWSER=${process.env.BROWSER ?? "local"} placeholder ${maskCardNumber(placeholder.number)}`, + `checkout ${mode} via RELAY=${relayName} BROWSER=${process.env.BROWSER ?? "local"} placeholder ${maskCardNumber(placeholder.number)} test card ${maskCardNumber(SHOPIFY_PAYMENTS_TEST_CARD.number)}`, ); const events: InterceptorEvent[] = []; @@ -64,35 +72,69 @@ async function main(): Promise { let sessionsUrl = false; let merchantOrigin = ""; let relayNote = ""; - const selected = - mode === "refuse" ? refusingRelay() : selectRelay(relayName, placeholder); - const relay: PaymentRelay = { - async relay(request) { - relayCalls += 1; - sessionsUrl = request.url.includes( - "checkout.pci.shopifyinc.com/sessions", - ); - merchantOrigin = request.merchantOrigin; - const result = await selected.relay(request); - relayNote = result.ok - ? `status ${result.status} body ${redact(result.body).slice(0, 180)}` - : `refused ${result.reason}`; - return result; - }, - }; - - const browserSession = await openBrowser(); - const handle = await attachInterceptor(browserSession.cdpUrl, { - relay, - placeholder, - transactionId: - relayName === "http" ? required("TRANSACTION_ID") : "tx_local_demo", - timeoutMs: 30_000, - }); - handle.on((event) => events.push(event)); - const driver = await chromium.connectOverCDP(browserSession.cdpUrl); + let releaseToken: (() => Promise) | undefined; + let releaseAccess: (() => Promise) | undefined; + let browserSession: + | { cdpUrl: string; close: () => Promise } + | undefined; + let handle: InterceptorHandle | undefined; + let driver: Browser | undefined; let page: Page | undefined; try { + let btTokenId: string | undefined; + let btApiKey: string | undefined; + if (relayName === "bt" && mode === "pay") { + const access = await btAccessFromKey({ + apiKey: required("BT_PRIVATE_KEY"), + }); + releaseAccess = access.release; + btApiKey = access.apiKey; + console.log( + access.createdApplication + ? "Basis Theory management key; created a private application for this run" + : "using the Basis Theory private key from the environment", + ); + const created = await createShopifyPaymentsTestToken({ + apiKey: access.apiKey, + }); + releaseToken = created.release; + btTokenId = created.tokenId; + console.log( + "Basis Theory card token created for the Shopify Payments test card", + ); + } + const selected = + mode === "refuse" + ? refusingRelay() + : selectRelay(relayName, placeholder, btApiKey, btTokenId); + const relay: PaymentRelay = { + async relay(request) { + relayCalls += 1; + sessionsUrl = request.url.includes( + "checkout.pci.shopifyinc.com/sessions", + ); + merchantOrigin = request.merchantOrigin; + const result = await selected.relay(request); + const note = result.ok + ? `status ${result.status} body ${redact(result.body).slice(0, 180)}` + : `refused ${result.reason}`; + relayNote = btTokenId + ? note.split(btTokenId).join("tok_redacted") + : note; + return result; + }, + }; + + browserSession = await openBrowser(); + handle = await attachInterceptor(browserSession.cdpUrl, { + relay, + placeholder, + transactionId: + relayName === "http" ? required("TRANSACTION_ID") : "tx_local_demo", + timeoutMs: 30_000, + }); + handle.on((event) => events.push(event)); + driver = await chromium.connectOverCDP(browserSession.cdpUrl); const context = driver.contexts()[0] ?? (await driver.newContext()); page = context.pages()[0] ?? (await context.newPage()); await page.setViewportSize({ width: 1280, height: 900 }); @@ -118,24 +160,60 @@ async function main(): Promise { `relay calls ${relayCalls}; events ${events.map(summarize).join(", ") || "none"}; ${relayNote}`, ); } catch (error) { - if (page) console.error(await diagnostics(page)); + if (page) console.error(scrub(await diagnostics(page))); console.log( `relay calls ${relayCalls}; events ${events.map(summarize).join(", ") || "none"}; ${relayNote}`, ); console.error( - error instanceof Error ? (error.stack ?? error.message) : error, + scrub( + error instanceof Error ? (error.stack ?? error.message) : String(error), + ), ); throw error; } finally { - await page?.close().catch(() => {}); - await handle.detach(); - await browserSession.close(); + await releaseToken?.().catch(() => {}); + await releaseAccess?.().catch(() => {}); + await settle(page?.close()); + await settle(handle?.detach()); + await settle(driver?.close()); + await settle(browserSession?.close()); } } -function selectRelay(name: string, placeholder: HeldPlaceholder): PaymentRelay { +function settle(op: Promise | undefined): Promise { + if (!op) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, 15_000); + op.then( + () => { + clearTimeout(timer); + resolve(); + }, + () => { + clearTimeout(timer); + resolve(); + }, + ); + }); +} + +function selectRelay( + name: string, + placeholder: HeldPlaceholder, + btApiKey?: string, + btTokenId?: string, +): PaymentRelay { if (name === "bogus") return bogusShopifyRelay({ placeholder }); - if (name === "bt") return btProxyRelayFromEnv(placeholder); + if (name === "bt") { + if (!btApiKey || !btTokenId) { + throw new CheckoutStopped("Basis Theory card token was not created", 1); + } + return btProxyRelay({ + placeholder, + apiKey: btApiKey, + tokenId: btTokenId, + }); + } return httpPaymentRelay({ baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", apiKey: required("SAPIOM_API_KEY"), @@ -156,8 +234,8 @@ function loadPlaceholder(relayName: string): HeldPlaceholder { injectionRef: required("INJECTION_REF"), }; } - // A Luhn-valid stand-in. The bogus relay replaces it with card 1 before - // the request is forwarded. It is not sent as itself. + // A Luhn-valid stand-in. The relay replaces it before the request is + // forwarded. It is not sent as itself. return { number: "4123456789012349", expMonth: 11, @@ -399,19 +477,71 @@ async function typeFrame(frame: Frame, text: string): Promise { } async function clickPay(page: Page): Promise { + await acceptAddressSuggestion(page); + await dismissCheckoutModal(page); const named = page.locator("#checkout-pay-button").first(); - if (await named.isVisible().catch(() => false)) { - await named.click(); + const target = (await named.isVisible().catch(() => false)) + ? named + : page.getByRole("button", { name: /pay now/i }).first(); + await target.click({ timeout: 15_000 }); +} + +async function acceptAddressSuggestion(page: Page): Promise { + const prompt = page.getByText(/did you mean/i).first(); + if (!(await prompt.isVisible().catch(() => false))) return; + const buttons = page.locator("button"); + const count = await buttons.count(); + for (let index = 0; index < count; index += 1) { + const label = ( + await buttons + .nth(index) + .innerText() + .catch(() => "") + ) + .replace(/\s+/g, " ") + .trim(); + if (!/^\d+\s+\S+/.test(label) || label.length > 80) continue; + if (/pay now|order summary/i.test(label)) continue; + console.log("accept address suggestion"); + await buttons.nth(index).click(); + await page.waitForTimeout(800); return; } - await page - .getByRole("button", { name: /pay now/i }) - .first() - .click(); +} + +async function dismissCheckoutModal(page: Page): Promise { + const keep = page + .getByRole("button", { + name: /use entered address|keep address|continue with entered/i, + }) + .first(); + if (await keep.isVisible().catch(() => false)) { + console.log("dismiss address suggestion"); + await keep.click(); + await page.waitForTimeout(400); + } + if (await detectChallenge(page)) return; + await page.keyboard.press("Escape").catch(() => {}); + await page.evaluate(() => { + const doc = globalThis as unknown as { + document: { + querySelectorAll: (selector: string) => Iterable<{ + innerText?: string; + style: { setProperty: (name: string, value: string) => void }; + }>; + }; + }; + for (const el of doc.document.querySelectorAll('[data-type="modal"]')) { + if (/captcha|verify you are human/i.test(el.innerText ?? "")) continue; + el.style.setProperty("pointer-events", "none"); + } + }); } async function waitForConfirmation(page: Page): Promise { - const deadline = Date.now() + 60_000; + const deadline = + Date.now() + + ((process.env.BROWSER ?? "local") === "sapiom" ? 180_000 : 60_000); while (Date.now() < deadline) { await assertNoChallenge(page, "waiting for confirmation"); if (/thank[-_]you/i.test(page.url())) return; @@ -441,34 +571,56 @@ async function paymentAlert(page: Page): Promise { } async function assertNoChallenge(page: Page, where: string): Promise { - const challenge = await detectChallenge(page); - if (challenge) { - throw new CheckoutStopped( - `Shopify showed a captcha or bot challenge (${challenge}) ${where}. Not bypassing it.`, - 3, + if (!(await detectChallenge(page))) return; + if ((process.env.BROWSER ?? "local") === "sapiom") { + console.log( + `captcha visible ${where}; waiting for the browser session's solver`, ); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (/thank[-_]you/i.test(page.url())) return; + if (!(await detectChallenge(page))) { + console.log(`captcha cleared ${where}`); + return; + } + await page.waitForTimeout(1000); + } + if (/thank[-_]you/i.test(page.url())) return; } + const challenge = (await detectChallenge(page)) ?? "visible"; + throw new CheckoutStopped( + `Shopify showed a captcha or bot challenge (${challenge}) ${where}. Not bypassing it.`, + 3, + ); } async function detectChallenge(page: Page): Promise { const url = page.url(); - if (/hcaptcha|recaptcha|\/checkpoint|\/challenge/i.test(url)) return "url"; + if (/hcaptcha\.com|recaptcha|\/checkpoint/i.test(url)) return "url"; + const text = await page + .locator("body") + .innerText({ timeout: 2_000 }) + .catch(() => ""); + if ( + /verify you are human|are you a human|checking your browser|solve the captcha/i.test( + text, + ) + ) { + return "text"; + } + if (/enter store password|opening soon/i.test(text)) return "store-password"; for (const frame of page.frames()) { if ( - /hcaptcha\.com|google\.com\/recaptcha|challenges\.cloudflare/i.test( + !/hcaptcha\.com|google\.com\/recaptcha|challenges\.cloudflare/i.test( frame.url(), ) ) { - return "frame"; + continue; } + const element = await frame.frameElement().catch(() => null); + const box = element ? await element.boundingBox().catch(() => null) : null; + if (box && box.width >= 120 && box.height >= 40) return "frame"; } - const text = await page - .locator("body") - .innerText({ timeout: 2_000 }) - .catch(() => ""); - if (/verify you are human|are you a human|checking your browser/i.test(text)) - return "text"; - if (/enter store password|opening soon/i.test(text)) return "store-password"; return null; } @@ -587,11 +739,34 @@ async function openBrowser(): Promise<{ recording: false, captchaSolver: true, }); + const fields = session as { + sessionId?: string; + session_id?: string; + cdpUrl?: string; + cdp_url?: string; + }; + const sessionId = fields.sessionId || fields.session_id; + const cdpUrl = fields.cdpUrl || fields.cdp_url; + if (!sessionId || !cdpUrl || !/^wss?:\/\//.test(cdpUrl)) { + if (sessionId) { + await sapiom.browserAutomation.sessions + .closeManaged(sessionId) + .catch(() => {}); + } + await sapiom.shutdown().catch(() => {}); + throw new CheckoutStopped( + `Sapiom browser session did not include a CDP websocket (fields: ${Object.keys(session).sort().join(",")})`, + 1, + ); + } + console.log( + "Sapiom browser session created (captchaSolver requested; CDP websocket present)", + ); return { - cdpUrl: session.cdpUrl, + cdpUrl, close: async () => { await sapiom.browserAutomation.sessions - .closeManaged(session.sessionId) + .closeManaged(sessionId) .catch(() => {}); await sapiom.shutdown().catch(() => {}); }, @@ -673,11 +848,17 @@ function required(name: string): string { return value; } +function scrub(value: string): string { + return redact(value).replace(/wss?:\/\/\S+/gi, "wss://redacted"); +} + main().catch((error: unknown) => { if (error instanceof CheckoutStopped) { console.error(error.message); process.exit(error.code); } - console.error(error instanceof Error ? error.message : "checkout failed"); + console.error( + scrub(error instanceof Error ? error.message : "checkout failed"), + ); process.exit(2); }); From d4c2871899524adaf870bbdda019ca4df58b1f27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 01:24:21 +0000 Subject: [PATCH 03/26] fix(browser-pay): check a real address before the single pay click Use 350 5th Ave so Shopify does not offer an address correction. Confirm the card iframes before pay, and wait up to three minutes for the session captcha solver. A captcha after pay is waited out; pay is not clicked again. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 305 ++++++++++++++----- 1 file changed, 223 insertions(+), 82 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 90bd44cd8..381c081aa 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -43,6 +43,21 @@ import { SHOPIFY_PAYMENTS_TEST_CARD } from "./relays/shopify-payments-test-card. const CART_URL = "https://1a2uh3-r7.myshopify.com/cart/67652208394372:1"; const SHOP_ORIGIN = "https://1a2uh3-r7.myshopify.com"; +const SOLVER_WAIT_MS = 180_000; +const ADDRESS = { + line: "350 5th Ave", + city: "New York", + state: "New York", + zip: "10118", + phone: "2127363100", +} as const; + +let startedAt = Date.now(); + +function mark(message: string): void { + const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); + console.log(`t+${seconds}s ${message}`); +} class CheckoutStopped extends Error { constructor( @@ -62,6 +77,8 @@ async function main(): Promise { ); } const mode = process.env.MODE === "refuse" ? "refuse" : "pay"; + startedAt = Date.now(); + sessionsObserved = false; const placeholder = loadPlaceholder(relayName); console.log( `checkout ${mode} via RELAY=${relayName} BROWSER=${process.env.BROWSER ?? "local"} placeholder ${maskCardNumber(placeholder.number)} test card ${maskCardNumber(SHOPIFY_PAYMENTS_TEST_CARD.number)}`, @@ -110,11 +127,23 @@ async function main(): Promise { const relay: PaymentRelay = { async relay(request) { relayCalls += 1; - sessionsUrl = request.url.includes( + const isSessions = request.url.includes( "checkout.pci.shopifyinc.com/sessions", ); + if (isSessions && !sessionsUrl) { + sessionsObserved = true; + mark("pci sessions paused"); + } + sessionsUrl = sessionsUrl || isSessions; merchantOrigin = request.merchantOrigin; const result = await selected.relay(request); + if (request.url.includes("checkout.pci.shopifyinc.com/sessions")) { + mark( + result.ok + ? `pci sessions relayed HTTP ${result.status}` + : `pci sessions refused ${result.reason}`, + ); + } const note = result.ok ? `status ${result.status} body ${redact(result.body).slice(0, 180)}` : `refused ${result.reason}`; @@ -125,7 +154,9 @@ async function main(): Promise { }, }; + mark("opening browser session"); browserSession = await openBrowser(); + mark("browser session ready"); handle = await attachInterceptor(browserSession.cdpUrl, { relay, placeholder, @@ -272,7 +303,10 @@ async function runCheckout( await chooseShipping(page); console.log("fill card fields"); await fillCard(page, placeholder); + mark("card typed"); await assertNoChallenge(page, "before pay"); + await assertReadyToPay(page, placeholder); + mark("pre-pay checks passed"); console.log("pay once"); await clickPay(page); if (mode === "refuse") { @@ -319,20 +353,21 @@ async function fillShipping(page: Page): Promise { } await fill(page, /first name/i, "Test", 'input[name="firstName"]'); await fill(page, /last name/i, "Buyer", 'input[name="lastName"]'); - await fill(page, /^address$/i, "1 Test Street", 'input[name="address1"]'); + await fill(page, /^address$/i, ADDRESS.line, 'input[name="address1"]'); await dismissAddressSuggestions(page); - await fill(page, /^city$/i, "New York", 'input[name="city"]'); + await fill(page, /^city$/i, ADDRESS.city, 'input[name="city"]'); await fill( page, /zip code|postal code/i, - "10001", + ADDRESS.zip, 'input[name="postalCode"]', ); const phone = page.getByLabel(/phone/i).first(); if (await phone.isVisible().catch(() => false)) - await phone.fill("2015550123"); + await phone.fill(ADDRESS.phone); await selectState(page); await dismissAddressSuggestions(page); + await page.waitForTimeout(2500); } async function dismissAddressSuggestions(page: Page): Promise { @@ -397,25 +432,32 @@ async function chooseShipping(page: Page): Promise { console.log("no shipping-rate radio appeared"); } -async function fillCard( - page: Page, - placeholder: HeldPlaceholder, -): Promise { +async function cardFrames(page: Page): Promise> { await page .waitForSelector( 'iframe[src*="checkout.pci.shopifyinc.com"], iframe[name*="card-fields"], iframe[title*="Card number"]', { timeout: 30_000 }, ) .catch(() => {}); + const roles = new Map(); + for (const frame of page.frames()) { + const blob = `${frame.name()} ${frame.url()}`; + if (!/checkout\.pci\.shopifyinc\.com|card-fields/i.test(blob)) continue; + const role = frameRole(blob); + if (role !== "unknown" && !roles.has(role)) roles.set(role, frame); + } + return roles; +} + +async function fillCard( + page: Page, + placeholder: HeldPlaceholder, +): Promise { + const roles = await cardFrames(page); const frames = page.frames().filter((frame) => { const blob = `${frame.name()} ${frame.url()}`; return /checkout\.pci\.shopifyinc\.com|card-fields/i.test(blob); }); - const roles = new Map(); - for (const frame of frames) { - const role = frameRole(`${frame.name()} ${frame.url()}`); - if (role !== "unknown" && !roles.has(role)) roles.set(role, frame); - } const number = roles.get("number"); if (!number) { throw new CheckoutStopped( @@ -476,87 +518,186 @@ async function typeFrame(frame: Frame, text: string): Promise { await target.pressSequentially(text, { delay: 20 }); } -async function clickPay(page: Page): Promise { - await acceptAddressSuggestion(page); - await dismissCheckoutModal(page); - const named = page.locator("#checkout-pay-button").first(); - const target = (await named.isVisible().catch(() => false)) - ? named - : page.getByRole("button", { name: /pay now/i }).first(); - await target.click({ timeout: 15_000 }); +async function assertReadyToPay( + page: Page, + placeholder: HeldPlaceholder, +): Promise { + const suggestion = page + .getByText(/did you mean|suggested address|address suggestion/i) + .first(); + if (await suggestion.isVisible().catch(() => false)) { + throw new CheckoutStopped("address suggestion still shown before pay", 2); + } + const invalid = await page + .locator('input[aria-invalid="true"], select[aria-invalid="true"]') + .count(); + if (invalid > 0) { + throw new CheckoutStopped(`${invalid} invalid fields before pay`, 2); + } + const alerts = await page + .locator('[role="alert"]') + .allInnerTexts() + .catch(() => [] as string[]); + const fieldError = alerts + .map((alert) => alert.replace(/\s+/g, " ").trim()) + .find((alert) => + /address|phone|email|city|zip|postal|state|required|invalid|enter a/i.test( + alert, + ), + ); + if (fieldError) { + throw new CheckoutStopped( + `field error before pay: ${redact(fieldError).slice(0, 180)}`, + 2, + ); + } + const anyChecked = await page + .locator('input[type="radio"]:checked') + .evaluateAll((nodes) => + nodes.some((node) => + /shipping|delivery|rate/i.test( + `${(node as { name?: string }).name ?? ""} ${(node as { id?: string }).id ?? ""}`, + ), + ), + ) + .catch(() => false); + const radios = await page.locator('input[type="radio"]').count(); + if (radios > 0 && !anyChecked) { + throw new CheckoutStopped("no shipping rate selected before pay", 2); + } + await assertCardHoldsPlaceholder(page, placeholder); + console.log( + `card iframes hold placeholder ${maskCardNumber(placeholder.number)}`, + ); } -async function acceptAddressSuggestion(page: Page): Promise { - const prompt = page.getByText(/did you mean/i).first(); - if (!(await prompt.isVisible().catch(() => false))) return; - const buttons = page.locator("button"); - const count = await buttons.count(); - for (let index = 0; index < count; index += 1) { - const label = ( - await buttons - .nth(index) - .innerText() - .catch(() => "") - ) - .replace(/\s+/g, " ") - .trim(); - if (!/^\d+\s+\S+/.test(label) || label.length > 80) continue; - if (/pay now|order summary/i.test(label)) continue; - console.log("accept address suggestion"); - await buttons.nth(index).click(); - await page.waitForTimeout(800); - return; +async function assertCardHoldsPlaceholder( + page: Page, + placeholder: HeldPlaceholder, +): Promise { + const roles = await cardFrames(page); + const number = digits(await cardFieldValue(requiredFrame(roles, "number"))); + const expiryFrame = roles.get("expiry"); + const expiry = expiryFrame ? digits(await cardFieldValue(expiryFrame)) : ""; + const cvc = digits(await cardFieldValue(requiredFrame(roles, "cvc"))); + const numberOk = + number === placeholder.number || + (number.length >= 4 && number.endsWith(placeholder.number.slice(-4))); + const expiryOk = + !expiryFrame || + (expiry.includes(String(placeholder.expMonth).padStart(2, "0")) && + expiry.includes(String(placeholder.expYear).slice(-2))); + const cvcOk = cvc === placeholder.cvc; + if (!numberOk || !expiryOk || !cvcOk) { + throw new CheckoutStopped( + `card iframes do not hold the placeholder (number last4 ${number.slice(-4) || "empty"}, expiry ${expiry ? "present" : "empty"}, cvc ${cvc ? "present" : "empty"})`, + 2, + ); } } -async function dismissCheckoutModal(page: Page): Promise { - const keep = page - .getByRole("button", { - name: /use entered address|keep address|continue with entered/i, - }) - .first(); - if (await keep.isVisible().catch(() => false)) { - console.log("dismiss address suggestion"); - await keep.click(); - await page.waitForTimeout(400); +function requiredFrame(roles: Map, role: string): Frame { + const frame = roles.get(role); + if (!frame) throw new CheckoutStopped(`card ${role} iframe not found`, 2); + return frame; +} + +async function cardFieldValue(frame: Frame): Promise { + const inputs = frame.locator( + 'input:not([data-honeypot-field]):not([aria-hidden="true"])', + ); + const count = await inputs.count(); + for (let index = 0; index < count; index += 1) { + const candidate = inputs.nth(index); + if ((await candidate.getAttribute("tabindex")) === "-1") continue; + return candidate.inputValue(); } - if (await detectChallenge(page)) return; - await page.keyboard.press("Escape").catch(() => {}); - await page.evaluate(() => { - const doc = globalThis as unknown as { - document: { - querySelectorAll: (selector: string) => Iterable<{ - innerText?: string; - style: { setProperty: (name: string, value: string) => void }; - }>; - }; - }; - for (const el of doc.document.querySelectorAll('[data-type="modal"]')) { - if (/captcha|verify you are human/i.test(el.innerText ?? "")) continue; - el.style.setProperty("pointer-events", "none"); - } - }); + return inputs.first().inputValue(); +} + +function digits(value: string): string { + return value.replace(/\D/g, ""); +} + +async function clickPay(page: Page): Promise { + const named = page.locator("#checkout-pay-button").first(); + const target = (await named.isVisible().catch(() => false)) + ? named + : page.getByRole("button", { name: /pay now/i }).first(); + mark("clicking pay once"); + await target.click({ timeout: 15_000 }); + mark("pay click returned"); } async function waitForConfirmation(page: Page): Promise { - const deadline = - Date.now() + - ((process.env.BROWSER ?? "local") === "sapiom" ? 180_000 : 60_000); + const deadline = Date.now() + (solverEnabled() ? 240_000 : 60_000); + let captchaAt = 0; + let captchaCleared = false; + let sawSessions = false; while (Date.now() < deadline) { - await assertNoChallenge(page, "waiting for confirmation"); - if (/thank[-_]you/i.test(page.url())) return; + const challenge = await detectChallenge(page); + if (challenge) { + if (!captchaAt) { + captchaAt = Date.now(); + mark(`captcha visible after pay (${challenge})`); + } + if (Date.now() - captchaAt > SOLVER_WAIT_MS) { + const waited = ((Date.now() - captchaAt) / 1000).toFixed(1); + throw new CheckoutStopped( + `captcha still shown ${waited}s after pay. Not bypassing it.`, + 3, + ); + } + await page.waitForTimeout(1000); + continue; + } + if (captchaAt && !captchaCleared) { + captchaCleared = true; + mark( + `captcha cleared ${((Date.now() - captchaAt) / 1000).toFixed(1)}s after it appeared`, + ); + } + if (sessionsSeen() && !sawSessions) { + sawSessions = true; + mark("pci sessions observed while waiting for confirmation"); + } + if (/thank[-_]you/i.test(page.url())) { + mark("thank-you page"); + return; + } const heading = page.getByRole("heading", { name: /thank you/i }).first(); - if (await heading.isVisible().catch(() => false)) return; + if (await heading.isVisible().catch(() => false)) { + mark("thank-you heading"); + return; + } const paymentError = await paymentAlert(page); if (paymentError) throw new CheckoutStopped(redact(paymentError), 2); await page.waitForTimeout(500); } + if (captchaAt && !captchaCleared) { + throw new CheckoutStopped( + "captcha still shown after pay. Not bypassing it.", + 3, + ); + } throw new CheckoutStopped( - `checkout did not confirm (${redactUrl(page.url())})`, + sessionsSeen() + ? `sessions relayed but checkout did not confirm (${redactUrl(page.url())})` + : `pci sessions request was not relayed (${redactUrl(page.url())})`, 2, ); } +function solverEnabled(): boolean { + return (process.env.BROWSER ?? "local") === "sapiom"; +} + +let sessionsObserved = false; + +function sessionsSeen(): boolean { + return sessionsObserved; +} + async function paymentAlert(page: Page): Promise { const alerts = await page .locator('[role="alert"]') @@ -572,15 +713,15 @@ async function paymentAlert(page: Page): Promise { async function assertNoChallenge(page: Page, where: string): Promise { if (!(await detectChallenge(page))) return; - if ((process.env.BROWSER ?? "local") === "sapiom") { - console.log( - `captcha visible ${where}; waiting for the browser session's solver`, - ); - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { + if (solverEnabled()) { + const seen = Date.now(); + mark(`captcha visible ${where}; waiting up to 180s for the solver`); + while (Date.now() - seen < SOLVER_WAIT_MS) { if (/thank[-_]you/i.test(page.url())) return; if (!(await detectChallenge(page))) { - console.log(`captcha cleared ${where}`); + mark( + `captcha cleared ${where} after ${((Date.now() - seen) / 1000).toFixed(1)}s`, + ); return; } await page.waitForTimeout(1000); From 21c9cea58cefab5e891732b257f59ab5987700f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 02:50:52 +0000 Subject: [PATCH 04/26] fix(browser-pay): enable Anchor's solver and stop pausing it Captcha solving stays off unless the session proxy is active. The dev-store session now requests proxy, stealth, and a headful browser. The interceptor no longer Fetch-pauses captcha frames or workers, which held Shopify's hCaptcha past the solver's timeout. Co-authored-by: Rimmely Ewan --- packages/browser-pay/README.md | 9 +- packages/browser-pay/examples/attach.ts | 3 + packages/browser-pay/examples/shopify-e2e.ts | 10 +- packages/browser-pay/src/interceptor.test.ts | 31 +++ packages/browser-pay/src/interceptor.ts | 195 ++++++++++++++++--- 5 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 packages/browser-pay/src/interceptor.test.ts diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 9f0eb317e..d029f19b7 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -4,13 +4,13 @@ Browser-side payment interceptor for a card checkout in the agent's own Chromium The agent types a placeholder card into the checkout. This module attaches over CDP, pauses the request whose body contains that placeholder, and sends it to the relay. The relay's response fulfills the paused request. The browser never sees a real card value. -M0 pauses every request and inspects the body. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. +M0 pauses page and iframe requests and inspects the body. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. ## Attach `attachInterceptor` opens its own CDP connection. Pass a websocket URL, or a Playwright/Puppeteer browser or page that exposes one. The same function is what a later hosted-attach mode would call. -An agent platform that already uses Sapiom for the browser creates a session, then attaches. Captcha solving, when the caller wants it, is the browser capability's `captchaSolver` flag. This package does not solve captchas. +An agent platform that already uses Sapiom for the browser creates a session, then attaches. Captcha solving is the browser capability's `captchaSolver` flag. The provider turns that solver off unless `proxy` is also set; `stealth` and `headless: false` match the headful residential session the solver expects. This package does not solve captchas. ```typescript import { randomUUID } from "node:crypto"; @@ -22,6 +22,9 @@ const session = await sapiom.browserAutomation.sessions.createManaged({ idempotencyKey: randomUUID(), recording: false, captchaSolver: true, + proxy: true, + stealth: true, + headless: false, }); const handle = await attachInterceptor(session.cdpUrl, { relay: httpPaymentRelay({ @@ -64,7 +67,7 @@ Placeholder detection matches the backend's placeholder-detection rules (separat ## Shopify dev-store script -`examples/shopify-e2e.ts` is opt-in and is not part of `pnpm test`. It launches headless Chromium with a CDP port, or `BROWSER=sapiom` to create a Sapiom browser session (`captchaSolver: true`) and attach to the returned CDP URL. A second Playwright connection drives the dev store. The store is `https://1a2uh3-r7.myshopify.com`, variant `67652208394372`, Shopify Payments test mode. +`examples/shopify-e2e.ts` is opt-in and is not part of `pnpm test`. It launches headless Chromium with a CDP port, or `BROWSER=sapiom` to create a Sapiom browser session (`captchaSolver`, `proxy`, `stealth`, and `headless: false`) and attach to the returned CDP URL. A second Playwright connection drives the dev store. The store is `https://1a2uh3-r7.myshopify.com`, variant `67652208394372`, Shopify Payments test mode. Demo relays live under `examples/relays/` and are not published: diff --git a/packages/browser-pay/examples/attach.ts b/packages/browser-pay/examples/attach.ts index a77a9b574..e7aa25458 100644 --- a/packages/browser-pay/examples/attach.ts +++ b/packages/browser-pay/examples/attach.ts @@ -34,6 +34,9 @@ async function main(): Promise { idempotencyKey: randomUUID(), recording: false, captchaSolver: true, + proxy: true, + stealth: true, + headless: false, }); const handle = await attachInterceptor(session.cdpUrl, { relay: httpPaymentRelay({ diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 381c081aa..5cf1c7703 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -11,7 +11,8 @@ * * One checkout per process. Pay is clicked once. RELAY=bt creates a Basis Theory * card token at setup and deletes it afterwards. BROWSER=sapiom asks the Sapiom - * browser session to solve captchas. This script does not bypass them itself. + * browser session to solve captchas, with the proxy and stealth that session + * requires. This script does not bypass them itself. */ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; @@ -875,10 +876,15 @@ async function openBrowser(): Promise<{ if (!apiKey) throw new CheckoutStopped("BROWSER=sapiom requires SAPIOM_API_KEY", 1); const sapiom: Sapiom = createClient({ apiKey }); + // Anchor disables captcha solving unless the session proxy is active. + // extra stealth uses the same proxy. headless false keeps the browser headful. const session = await sapiom.browserAutomation.sessions.createManaged({ idempotencyKey: randomUUID(), recording: false, captchaSolver: true, + proxy: true, + stealth: true, + headless: false, }); const fields = session as { sessionId?: string; @@ -901,7 +907,7 @@ async function openBrowser(): Promise<{ ); } console.log( - "Sapiom browser session created (captchaSolver requested; CDP websocket present)", + "Sapiom browser session created (captchaSolver, proxy, and stealth requested; CDP websocket present)", ); return { cdpUrl, diff --git a/packages/browser-pay/src/interceptor.test.ts b/packages/browser-pay/src/interceptor.test.ts new file mode 100644 index 000000000..8a6c9a069 --- /dev/null +++ b/packages/browser-pay/src/interceptor.test.ts @@ -0,0 +1,31 @@ +import { isSolverTraffic } from "./interceptor"; + +describe("isSolverTraffic", () => { + it("matches captcha widgets and the browser solver, not the card host", () => { + expect( + isSolverTraffic( + "https://hcaptcha.com/captcha/v1/abc/static/hcaptcha.html", + ), + ).toBe(true); + expect( + isSolverTraffic("https://newassets.hcaptcha.com/captcha/v1/abc/api.js"), + ).toBe(true); + expect(isSolverTraffic("https://api.hcaptcha.com/getcaptcha")).toBe(true); + expect( + isSolverTraffic("https://www.google.com/recaptcha/api2/anchor"), + ).toBe(true); + expect( + isSolverTraffic("https://challenges.cloudflare.com/cdn-cgi/challenge"), + ).toBe(true); + expect(isSolverTraffic("https://api.anchorbrowser.io/v1/sessions")).toBe( + true, + ); + expect( + isSolverTraffic("https://checkout.pci.shopifyinc.com/sessions"), + ).toBe(false); + expect(isSolverTraffic("https://1a2uh3-r7.myshopify.com/checkouts")).toBe( + false, + ); + expect(isSolverTraffic("not a url")).toBe(false); + }); +}); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 4392eeb91..df3a05389 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -39,16 +39,37 @@ export type InterceptorTarget = const DEFAULT_TIMEOUT_MS = 20_000; -const TARGET_FILTER = [ - { type: "page" }, - { type: "iframe" }, - { type: "worker" }, - { type: "shared_worker" }, - { type: "service_worker" }, -]; +const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; const RELEVANT_TYPES = new Set(TARGET_FILTER.map((entry) => entry.type)); +/** + * Captcha widgets and the session solver. Their requests are not the card + * post. Holding them on Fetch or the debugger stalls the solver past its + * own timeout. A POST is still scanned so a placeholder is never continued. + */ +export 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/"); +} + const RESPONSE_HOP = new Set([ "connection", "keep-alive", @@ -66,16 +87,32 @@ interface Session { type: string; url: string; parentSessionId?: string; + fetchEnabled?: boolean; +} + +/** Workers and extension pages belong to the captcha solver. Leave them alone. */ +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); } /** * Attach the payment interceptor to a browser. * - * Every request is paused. Bodies that do not contain the held placeholder are - * continued immediately. A match is sent to the relay and the paused request - * is fulfilled with the provider response. A mismatch, refusal, timeout, or - * network error fails the paused request. The placeholder is never continued: - * it is a random number in a real BIN range. + * Page and iframe requests are paused. Captcha widgets, extension pages, and + * workers are not: pausing them stalls the session's captcha solver. A paused + * body that does not contain the held placeholder is continued. A match is + * sent to the relay and the paused request is fulfilled with the provider + * response. A mismatch, refusal, timeout, or network error fails the paused + * request. The placeholder is never continued: it is a random number in a + * real BIN range. */ export async function attachInterceptor( target: InterceptorTarget, @@ -112,19 +149,18 @@ export async function attachInterceptor( return; } seen.add(sessionId); + const session = sessions.get(sessionId); + // A captcha frame or the solver's own target must run without Fetch. + // Pausing its requests keeps the widget up past the solver's timeout. + if (session && isHandsOffTarget(session)) { + await releaseTarget(connection, sessionId, waitingForDebugger); + return; + } // Fetch has to be on before the target runs, or the card iframe can post // before we are listening. waitForDebuggerOnStart holds it until then. // Network has to be enabled before Network.getCookies will return the // jar the card session needs. - await connection.send("Network.enable", {}, sessionId).catch(() => {}); - await connection - .send( - "Fetch.enable", - { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, - sessionId, - ) - .catch(() => {}); - const session = sessions.get(sessionId); + await armFetch(connection, session); if (session && RELEVANT_TYPES.has(session.type)) { await connection .send( @@ -139,11 +175,7 @@ export async function attachInterceptor( ) .catch(() => {}); } - if (waitingForDebugger) { - await connection - .send("Runtime.runIfWaitingForDebugger", {}, sessionId) - .catch(() => {}); - } + await releaseTarget(connection, sessionId, waitingForDebugger); }; const onAttached = (event: CdpEvent) => { @@ -180,6 +212,20 @@ export async function attachInterceptor( }; }; if (!sessionId || !params.requestId || !params.request) return; + const url = params.request.url ?? ""; + const session = sessions.get(sessionId); + if (isSolverTraffic(url) || (session ? isHandsOffTarget(session) : false)) { + void continueSolverRequest( + connection, + options, + emit, + sessionId, + params.requestId, + url, + params.request, + ); + return; + } void handlePaused( connection, sessions, @@ -206,7 +252,16 @@ export async function attachInterceptor( | undefined; if (!info?.targetId || !info.url) return; for (const session of sessions.values()) { - if (session.targetId === info.targetId) session.url = info.url; + if (session.targetId !== info.targetId) continue; + session.url = info.url; + if (!isHandsOffTarget(session)) continue; + session.fetchEnabled = false; + void connection + .send("Fetch.disable", {}, session.sessionId) + .catch(() => {}); + void connection + .send("Runtime.runIfWaitingForDebugger", {}, session.sessionId) + .catch(() => {}); } } else if (event.method === "Fetch.requestPaused") onPaused(event); }); @@ -408,7 +463,11 @@ async function readMerchantOrigin( const evaluated = await connection .send<{ result?: { value?: unknown }; - }>("Runtime.evaluate", { expression: "location.origin", returnByValue: true }, pageSessionId) + }>( + "Runtime.evaluate", + { expression: "location.origin", returnByValue: true }, + pageSessionId, + ) .catch(() => null); const value = evaluated?.result?.value; if (typeof value === "string" && /^https?:\/\//.test(value)) return value; @@ -476,7 +535,11 @@ async function completeHeaders( const languages = await connection .send<{ result?: { value?: unknown }; - }>("Runtime.evaluate", { expression: "navigator.languages", returnByValue: true }, sessionId) + }>( + "Runtime.evaluate", + { expression: "navigator.languages", returnByValue: true }, + sessionId, + ) .catch(() => null); const list = languages?.result?.value; if ( @@ -533,6 +596,80 @@ function headerRecord(headers: unknown): Record { return {}; } +function requestHasBody(request: { + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; +}): boolean { + if (request.hasPostData) return true; + if (typeof request.postData === "string" && request.postData.length > 0) { + return true; + } + return Boolean(request.postDataEntries && request.postDataEntries.length > 0); +} + +async function armFetch( + connection: CdpConnection, + session: Session | undefined, +): Promise { + if (!session || session.fetchEnabled || isHandsOffTarget(session)) return; + await connection + .send("Network.enable", {}, session.sessionId) + .catch(() => {}); + await connection + .send( + "Fetch.enable", + { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, + session.sessionId, + ) + .catch(() => {}); + session.fetchEnabled = true; +} + +async function releaseTarget( + connection: CdpConnection, + sessionId: string, + waitingForDebugger: boolean, +): Promise { + if (!waitingForDebugger) return; + await connection + .send("Runtime.runIfWaitingForDebugger", {}, sessionId) + .catch(() => {}); +} + +async function continueSolverRequest( + connection: CdpConnection, + options: AttachOptions, + emit: (event: InterceptorEvent) => void, + sessionId: string, + requestId: string, + url: string, + request: { + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; + }, +): Promise { + if (requestHasBody(request)) { + try { + const body = await readBody(connection, sessionId, requestId, request); + const match = matchHeldPlaceholderInBody(body, options.placeholder); + if (match.kind !== "not_held") { + await failRequest(connection, sessionId, requestId); + emit({ + type: "failed", + url: redact(url), + reason: "placeholder_mismatch", + }); + return; + } + } catch { + // An unreadable captcha body is not the card post. Continue it. + } + } + await continueRequest(connection, sessionId, requestId); +} + async function continueRequest( connection: CdpConnection, sessionId: string, From 426ba980d95703ef5bc6a5fcd84f4afc7446d7de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 02:52:40 +0000 Subject: [PATCH 05/26] fix(browser-pay): let the solver finish before retrying a card field Shopify's hCaptcha can open while the card iframes are being filled and push the security-code field outside the viewport. Wait for the session solver, then type that field. Pay is still clicked once. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 5cf1c7703..5b48c6264 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -454,6 +454,9 @@ async function fillCard( page: Page, placeholder: HeldPlaceholder, ): Promise { + if (await detectChallenge(page)) { + await assertNoChallenge(page, "before card entry"); + } const roles = await cardFrames(page); const frames = page.frames().filter((frame) => { const blob = `${frame.name()} ${frame.url()}`; @@ -514,7 +517,16 @@ async function typeFrame(frame: Frame, text: string): Promise { target = candidate; break; } - await target.click({ force: true }); + await target.scrollIntoViewIfNeeded().catch(() => {}); + try { + await target.click({ force: true, timeout: 5_000 }); + } catch (error) { + const page = frame.page(); + if (!(await detectChallenge(page))) throw error; + await assertNoChallenge(page, "during card entry"); + await target.scrollIntoViewIfNeeded().catch(() => {}); + await target.click({ force: true, timeout: 5_000 }); + } await target.fill(""); await target.pressSequentially(text, { delay: 20 }); } From 74c55febf28a0a6358803ebfb5c65a43924340f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 02:54:13 +0000 Subject: [PATCH 06/26] fix(browser-pay): continue unrelated GETs and keep one session per target A second CDP session per page paused every request twice, and each pause waited on a remote round trip. GET and HEAD with no body now continue as soon as the URL is checked. setAutoAttach is the only attach path. Co-authored-by: Rimmely Ewan --- packages/browser-pay/README.md | 2 +- packages/browser-pay/src/interceptor.test.ts | 55 +++++++++++- packages/browser-pay/src/interceptor.ts | 94 +++++++++++++------- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index d029f19b7..5bd2ae8a8 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -4,7 +4,7 @@ Browser-side payment interceptor for a card checkout in the agent's own Chromium The agent types a placeholder card into the checkout. This module attaches over CDP, pauses the request whose body contains that placeholder, and sends it to the relay. The relay's response fulfills the paused request. The browser never sees a real card value. -M0 pauses page and iframe requests and inspects the body. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. +M0 pauses page and iframe requests and inspects the body. GET and HEAD requests with no body are continued as soon as the URL is checked, so captcha and other subresources are not held for a remote round trip. A URL that contains the held number is failed. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. ## Attach diff --git a/packages/browser-pay/src/interceptor.test.ts b/packages/browser-pay/src/interceptor.test.ts index 8a6c9a069..48f9603b5 100644 --- a/packages/browser-pay/src/interceptor.test.ts +++ b/packages/browser-pay/src/interceptor.test.ts @@ -1,4 +1,4 @@ -import { isSolverTraffic } from "./interceptor"; +import { canContinueWithoutReading, isSolverTraffic } from "./interceptor"; describe("isSolverTraffic", () => { it("matches captcha widgets and the browser solver, not the card host", () => { @@ -29,3 +29,56 @@ describe("isSolverTraffic", () => { expect(isSolverTraffic("not a url")).toBe(false); }); }); + +describe("canContinueWithoutReading", () => { + const number = "4123456789012349"; + + it("continues a GET or HEAD whose URL does not hold the placeholder", () => { + expect( + canContinueWithoutReading( + "GET", + "https://newassets.hcaptcha.com/captcha/v1/api.js", + false, + number, + ), + ).toBe(true); + expect( + canContinueWithoutReading( + "HEAD", + "https://example.com/style.css", + false, + number, + ), + ).toBe(true); + }); + + it("does not skip a body, a query holding the placeholder, or a POST", () => { + expect( + canContinueWithoutReading("GET", "https://example.com/a", true, number), + ).toBe(false); + expect( + canContinueWithoutReading( + "GET", + `https://example.com/pay?card=${number}`, + false, + number, + ), + ).toBe(false); + expect( + canContinueWithoutReading( + "GET", + "https://example.com/pay?card=4123-4567-8901-2349", + false, + number, + ), + ).toBe(false); + expect( + canContinueWithoutReading( + "POST", + "https://example.com/sessions", + false, + number, + ), + ).toBe(false); + }); +}); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index df3a05389..784b87a0e 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -41,7 +41,28 @@ const DEFAULT_TIMEOUT_MS = 20_000; const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; -const RELEVANT_TYPES = new Set(TARGET_FILTER.map((entry) => entry.type)); +/** GET/HEAD with no body and no held number in the URL. Safe to continue immediately. */ +export 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 !urlDigits(url).includes(placeholderNumber); +} + +function urlDigits(url: string): string { + let decoded = url; + try { + decoded = decodeURIComponent(url); + } catch { + // Keep the raw URL when it is not valid percent-encoding. + } + return decoded.replace(/\D/g, ""); +} /** * Captcha widgets and the session solver. Their requests are not the card @@ -161,20 +182,6 @@ export async function attachInterceptor( // Network has to be enabled before Network.getCookies will return the // jar the card session needs. await armFetch(connection, session); - if (session && RELEVANT_TYPES.has(session.type)) { - await connection - .send( - "Target.setAutoAttach", - { - autoAttach: true, - waitForDebuggerOnStart: true, - flatten: true, - filter: TARGET_FILTER, - }, - sessionId, - ) - .catch(() => {}); - } await releaseTarget(connection, sessionId, waitingForDebugger); }; @@ -187,6 +194,23 @@ export async function attachInterceptor( const sessionId = params.sessionId; const info = params.targetInfo; if (!sessionId || !info?.type || !info.targetId) return; + const duplicate = [...sessions.values()].some( + (session) => session.targetId === info.targetId, + ); + if (duplicate) { + // setAutoAttach already owns this target. A second session would pause + // every request twice. + void releaseTarget( + connection, + sessionId, + params.waitingForDebugger === true, + ).then(() => + connection + .send("Target.detachFromTarget", { sessionId }) + .catch(() => {}), + ); + return; + } sessions.set(sessionId, { sessionId, targetId: info.targetId, @@ -213,6 +237,31 @@ export async function attachInterceptor( }; if (!sessionId || !params.requestId || !params.request) return; const url = params.request.url ?? ""; + const method = params.request.method ?? "GET"; + const hasBody = requestHasBody(params.request); + if ( + canContinueWithoutReading( + method, + url, + hasBody, + options.placeholder.number, + ) + ) { + void continueRequest(connection, sessionId, params.requestId); + return; + } + if ( + !hasBody && + (method.toUpperCase() === "GET" || method.toUpperCase() === "HEAD") + ) { + void failRequest(connection, sessionId, params.requestId); + emit({ + type: "failed", + url: redact(url), + reason: "placeholder_mismatch", + }); + return; + } const session = sessions.get(sessionId); if (isSolverTraffic(url) || (session ? isHandsOffTarget(session) : false)) { void continueSolverRequest( @@ -276,21 +325,6 @@ export async function attachInterceptor( .send("Target.setDiscoverTargets", { discover: true }) .catch(() => {}); - const existing = await connection - .send<{ - targetInfos?: { targetId: string; type: string }[]; - }>("Target.getTargets") - .catch(() => ({ targetInfos: [] })); - for (const info of existing.targetInfos ?? []) { - if (!RELEVANT_TYPES.has(info.type)) continue; - await connection - .send("Target.attachToTarget", { - targetId: info.targetId, - flatten: true, - }) - .catch(() => {}); - } - return { on(listener) { listeners.add(listener); From 3108b0895609769a75b8d56e16c758be50da4d6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 02:56:17 +0000 Subject: [PATCH 07/26] fix(browser-pay): accept the checkout address suggestion before pay The solver cleared hCaptcha, then pay was blocked because Shopify still showed an address suggestion. Accept that suggestion, and type the phone number so the field mask keeps every digit. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 38 ++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 5b48c6264..61a408336 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -306,6 +306,7 @@ async function runCheckout( await fillCard(page, placeholder); mark("card typed"); await assertNoChallenge(page, "before pay"); + await acceptAddressSuggestion(page); await assertReadyToPay(page, placeholder); mark("pre-pay checks passed"); console.log("pay once"); @@ -364,11 +365,42 @@ async function fillShipping(page: Page): Promise { 'input[name="postalCode"]', ); const phone = page.getByLabel(/phone/i).first(); - if (await phone.isVisible().catch(() => false)) - await phone.fill(ADDRESS.phone); + if (await phone.isVisible().catch(() => false)) { + await phone.click(); + await phone.fill(""); + await phone.pressSequentially(ADDRESS.phone, { delay: 20 }); + } await selectState(page); + await acceptAddressSuggestion(page); + await page.waitForTimeout(1500); +} + +async function acceptAddressSuggestion(page: Page): Promise { + const banner = page + .getByText(/did you mean|suggested address|address suggestion/i) + .first(); + if (!(await banner.isVisible().catch(() => false))) { + await dismissAddressSuggestions(page); + return; + } + mark("accepting the address suggestion"); + const buttons = page.getByRole("button"); + const count = await buttons.count(); + for (let index = 0; index < count; index += 1) { + const button = buttons.nth(index); + const name = (await button.innerText().catch(() => "")) + .replace(/\s+/g, " ") + .trim(); + if (/pay now|submit|refund|privacy|terms|contact|order summary|show more/i.test(name)) { + continue; + } + if (/^\d{5}$/.test(name) || /5th|suggested/i.test(name)) { + await button.click(); + await page.waitForTimeout(1000); + return; + } + } await dismissAddressSuggestions(page); - await page.waitForTimeout(2500); } async function dismissAddressSuggestions(page: Page): Promise { From cf225329ec8ea96b0b81a1070c9fa444e2033738 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 02:59:42 +0000 Subject: [PATCH 08/26] fix(browser-pay): attach the interceptor after hCaptcha clears Anchor's solver clears Shopify's hCaptcha when the interceptor is not pausing the widget. The dev-store script now attaches after that, and before the single Pay click, so the card session request is still paused. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 83 +++++++++++--------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 61a408336..747a5d2d6 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -12,7 +12,8 @@ * One checkout per process. Pay is clicked once. RELAY=bt creates a Basis Theory * card token at setup and deletes it afterwards. BROWSER=sapiom asks the Sapiom * browser session to solve captchas, with the proxy and stealth that session - * requires. This script does not bypass them itself. + * requires. The interceptor attaches after that solver finishes and before + * Pay. This script does not bypass captchas itself. */ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; @@ -158,6 +159,16 @@ async function main(): Promise { mark("opening browser session"); browserSession = await openBrowser(); mark("browser session ready"); + driver = await chromium.connectOverCDP(browserSession.cdpUrl); + const context = driver.contexts()[0] ?? (await driver.newContext()); + page = context.pages()[0] ?? (await context.newPage()); + const checkout = page; + await checkout.setViewportSize({ width: 1280, height: 900 }); + await prepareCheckout(checkout, placeholder); + // The session solver clears hCaptcha in a few seconds when nothing else is + // pausing the widget. Attach after that, and before Pay, so the card post + // is still paused. + mark("attaching interceptor"); handle = await attachInterceptor(browserSession.cdpUrl, { relay, placeholder, @@ -166,11 +177,41 @@ async function main(): Promise { timeoutMs: 30_000, }); handle.on((event) => events.push(event)); - driver = await chromium.connectOverCDP(browserSession.cdpUrl); - const context = driver.contexts()[0] ?? (await driver.newContext()); - page = context.pages()[0] ?? (await context.newPage()); - await page.setViewportSize({ width: 1280, height: 900 }); - await runCheckout(page, placeholder, mode); + await assertCardHoldsPlaceholder(checkout, placeholder); + mark("interceptor attached; placeholder still in the card fields"); + console.log("pay once"); + await clickPay(checkout); + if (mode === "refuse") { + await waitFor(async () => checkout.url(), 20_000); + await checkout.waitForTimeout(3000); + if (/thank[-_]you/i.test(checkout.url())) { + throw new CheckoutStopped( + "refusal still reached the thank-you page", + 2, + ); + } + const text = await checkout + .locator("body") + .innerText() + .catch(() => ""); + if (/order\s+#\d+/i.test(text) && /thank you/i.test(text)) { + throw new CheckoutStopped("refusal still created an order", 2); + } + console.log(`refusal held; page ${redactUrl(checkout.url())}`); + } else { + await waitForConfirmation(checkout); + const text = await checkout + .locator("body") + .innerText() + .catch(() => ""); + const order = + text.match(/order\s+#\s*(\d+)/i) ?? + text.match(/confirmation\s+#\s*(\d+)/i); + console.log(`confirmed ${redactUrl(page.url())}`); + console.log( + order ? `order ${order[1]}` : "order id not shown on the page", + ); + } if (!sessionsUrl) { throw new CheckoutStopped( "interceptor did not relay a request to checkout.pci.shopifyinc.com/sessions", @@ -277,10 +318,9 @@ function loadPlaceholder(relayName: string): HeldPlaceholder { }; } -async function runCheckout( +async function prepareCheckout( page: Page, placeholder: HeldPlaceholder, - mode: "pay" | "refuse", ): Promise { console.log("open cart"); await page.goto(CART_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); @@ -309,33 +349,6 @@ async function runCheckout( await acceptAddressSuggestion(page); await assertReadyToPay(page, placeholder); mark("pre-pay checks passed"); - console.log("pay once"); - await clickPay(page); - if (mode === "refuse") { - await waitFor(async () => page.url(), 20_000); - await page.waitForTimeout(3000); - if (/thank[-_]you/i.test(page.url())) { - throw new CheckoutStopped("refusal still reached the thank-you page", 2); - } - const text = await page - .locator("body") - .innerText() - .catch(() => ""); - if (/order\s+#\d+/i.test(text) && /thank you/i.test(text)) { - throw new CheckoutStopped("refusal still created an order", 2); - } - console.log(`refusal held; page ${redactUrl(page.url())}`); - return; - } - await waitForConfirmation(page); - const text = await page - .locator("body") - .innerText() - .catch(() => ""); - const order = - text.match(/order\s+#\s*(\d+)/i) ?? text.match(/confirmation\s+#\s*(\d+)/i); - console.log(`confirmed ${redactUrl(page.url())}`); - console.log(order ? `order ${order[1]}` : "order id not shown on the page"); } async function fillShipping(page: Page): Promise { From 9037e096d0c7005fea89a104b54452105e35219b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:04:22 +0000 Subject: [PATCH 09/26] fix(browser-pay): accept the suggested zip without the Shop Pay overlay The address suggestion sits under a Shop Pay modal, so a normal click never lands. Invoke the suggestion button directly, then fill the phone number. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 747a5d2d6..f64874625 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -377,15 +377,20 @@ async function fillShipping(page: Page): Promise { ADDRESS.zip, 'input[name="postalCode"]', ); + await selectState(page); + await page.waitForTimeout(1000); + await acceptAddressSuggestion(page); const phone = page.getByLabel(/phone/i).first(); if (await phone.isVisible().catch(() => false)) { - await phone.click(); - await phone.fill(""); - await phone.pressSequentially(ADDRESS.phone, { delay: 20 }); + await phone.fill(ADDRESS.phone); + const typed = (await phone.inputValue().catch(() => "")).replace(/\D/g, ""); + if (typed.length < 10) { + await phone.click({ force: true }).catch(() => {}); + await phone.fill(""); + await phone.pressSequentially(ADDRESS.phone, { delay: 30 }); + } } - await selectState(page); - await acceptAddressSuggestion(page); - await page.waitForTimeout(1500); + await page.waitForTimeout(1000); } async function acceptAddressSuggestion(page: Page): Promise { @@ -408,7 +413,10 @@ async function acceptAddressSuggestion(page: Page): Promise { continue; } if (/^\d{5}$/.test(name) || /5th|suggested/i.test(name)) { - await button.click(); + await page.keyboard.press("Escape").catch(() => {}); + await button.evaluate((element) => { + (element as { click: () => void }).click(); + }); await page.waitForTimeout(1000); return; } From 4657d0f990ad199246f518454e4f3988c9da0088 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:08:54 +0000 Subject: [PATCH 10/26] fix(browser-pay): let the solver finish before the address suggestion Escape was dismissing the page while hCaptcha was on screen. Wait for the solver first, and do not send Escape. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index f64874625..412230da6 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -402,6 +402,9 @@ async function acceptAddressSuggestion(page: Page): Promise { return; } mark("accepting the address suggestion"); + if (await detectChallenge(page)) { + await assertNoChallenge(page, "before address suggestion"); + } const buttons = page.getByRole("button"); const count = await buttons.count(); for (let index = 0; index < count; index += 1) { @@ -413,7 +416,6 @@ async function acceptAddressSuggestion(page: Page): Promise { continue; } if (/^\d{5}$/.test(name) || /5th|suggested/i.test(name)) { - await page.keyboard.press("Escape").catch(() => {}); await button.evaluate((element) => { (element as { click: () => void }).click(); }); From 0300709237c8ab66fff11273d7c819f6b390fffd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:14:10 +0000 Subject: [PATCH 11/26] fix(browser-pay): do not hold blank frames while Fetch is enabled A captcha iframe attaches as about:blank. Waiting to enable Fetch before releasing the debugger froze that frame, so the solver could not clear the challenge Shopify shows after Pay. Co-authored-by: Rimmely Ewan --- packages/browser-pay/src/interceptor.ts | 34 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 784b87a0e..b5a269159 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -111,7 +111,9 @@ interface Session { fetchEnabled?: boolean; } -/** Workers and extension pages belong to the captcha solver. Leave them alone. */ +function isBlankTarget(url: string): boolean { + return url === "" || url === "about:blank" || url === "about:srcdoc"; +} function isHandsOffTarget(session: Session): boolean { if ( session.type === "worker" || @@ -171,9 +173,10 @@ export async function attachInterceptor( } seen.add(sessionId); const session = sessions.get(sessionId); - // A captcha frame or the solver's own target must run without Fetch. - // Pausing its requests keeps the widget up past the solver's timeout. - if (session && isHandsOffTarget(session)) { + // A blank frame has not navigated yet. Holding it for Fetch.enable freezes + // a captcha widget that is about to load. Arm Fetch only once the URL is a + // page we might need to pause. + if (!session || isHandsOffTarget(session) || isBlankTarget(session.url)) { await releaseTarget(connection, sessionId, waitingForDebugger); return; } @@ -303,14 +306,21 @@ export async function attachInterceptor( for (const session of sessions.values()) { if (session.targetId !== info.targetId) continue; session.url = info.url; - if (!isHandsOffTarget(session)) continue; - session.fetchEnabled = false; - void connection - .send("Fetch.disable", {}, session.sessionId) - .catch(() => {}); - void connection - .send("Runtime.runIfWaitingForDebugger", {}, session.sessionId) - .catch(() => {}); + if (isHandsOffTarget(session)) { + if (session.fetchEnabled) { + session.fetchEnabled = false; + void connection + .send("Fetch.disable", {}, session.sessionId) + .catch(() => {}); + } + void connection + .send("Runtime.runIfWaitingForDebugger", {}, session.sessionId) + .catch(() => {}); + continue; + } + if (!session.fetchEnabled && !isBlankTarget(session.url)) { + void armFetch(connection, session); + } } } else if (event.method === "Fetch.requestPaused") onPaused(event); }); From fc6cc4b0233ee373c5af42f166aa2c4e4586cb79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:19:36 +0000 Subject: [PATCH 12/26] fix(browser-pay): request the captcha session with the popup blocker off Anchor's popup blocker also hides captcha consent banners. The session input can now send popupBlocker and adblock as false, and the dev-store checkout does. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 5 ++++- packages/tools/src/browser-automation/managed.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 412230da6..f54d11a4a 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -945,6 +945,7 @@ async function openBrowser(): Promise<{ const sapiom: Sapiom = createClient({ apiKey }); // Anchor disables captcha solving unless the session proxy is active. // extra stealth uses the same proxy. headless false keeps the browser headful. + // popupBlocker and adblock default on and can block the captcha banner. const session = await sapiom.browserAutomation.sessions.createManaged({ idempotencyKey: randomUUID(), recording: false, @@ -952,6 +953,8 @@ async function openBrowser(): Promise<{ proxy: true, stealth: true, headless: false, + popupBlocker: false, + adblock: false, }); const fields = session as { sessionId?: string; @@ -974,7 +977,7 @@ async function openBrowser(): Promise<{ ); } console.log( - "Sapiom browser session created (captchaSolver, proxy, and stealth requested; CDP websocket present)", + "Sapiom browser session created (captchaSolver, proxy, stealth, popup blocker off; CDP websocket present)", ); return { cdpUrl, diff --git a/packages/tools/src/browser-automation/managed.ts b/packages/tools/src/browser-automation/managed.ts index 1fb87793c..756964711 100644 --- a/packages/tools/src/browser-automation/managed.ts +++ b/packages/tools/src/browser-automation/managed.ts @@ -13,10 +13,13 @@ export interface ManagedSessionInput extends BrowserMutation { idleTimeoutMinutes?: number; maxDurationMinutes?: number; headless?: false; - adblock?: true; + /** When false, the gateway is asked to disable ad blocking. */ + adblock?: boolean; stealth?: true; captchaSolver?: true; proxy?: true; + /** When false, the gateway is asked to disable the popup blocker. Anchor's blocker also hides captcha consent banners. */ + popupBlocker?: boolean; } /** A session accessed with its secret sessionId. Both a CDP client and a managed task can control it, at separate times. */ export interface ManagedBrowserSession { From e6582abd141ba3aeaa787f5487b8caea06024f57 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:20:53 +0000 Subject: [PATCH 13/26] fix(browser-pay): do not pause new frames for the debugger The gateway rejects popupBlocker and adblock set to false. New frames are no longer held at startup, so the captcha solver can run after Pay while Fetch is already on for the card iframe. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 4 +--- packages/browser-pay/src/interceptor.ts | 2 +- packages/tools/src/browser-automation/managed.ts | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index f54d11a4a..1a06ccc9b 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -953,8 +953,6 @@ async function openBrowser(): Promise<{ proxy: true, stealth: true, headless: false, - popupBlocker: false, - adblock: false, }); const fields = session as { sessionId?: string; @@ -977,7 +975,7 @@ async function openBrowser(): Promise<{ ); } console.log( - "Sapiom browser session created (captchaSolver, proxy, stealth, popup blocker off; CDP websocket present)", + "Sapiom browser session created (captchaSolver, proxy, and stealth requested; CDP websocket present)", ); return { cdpUrl, diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index b5a269159..5faf2c0c7 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -327,7 +327,7 @@ export async function attachInterceptor( await connection.send("Target.setAutoAttach", { autoAttach: true, - waitForDebuggerOnStart: true, + waitForDebuggerOnStart: false, flatten: true, filter: TARGET_FILTER, }); diff --git a/packages/tools/src/browser-automation/managed.ts b/packages/tools/src/browser-automation/managed.ts index 756964711..1fb87793c 100644 --- a/packages/tools/src/browser-automation/managed.ts +++ b/packages/tools/src/browser-automation/managed.ts @@ -13,13 +13,10 @@ export interface ManagedSessionInput extends BrowserMutation { idleTimeoutMinutes?: number; maxDurationMinutes?: number; headless?: false; - /** When false, the gateway is asked to disable ad blocking. */ - adblock?: boolean; + adblock?: true; stealth?: true; captchaSolver?: true; proxy?: true; - /** When false, the gateway is asked to disable the popup blocker. Anchor's blocker also hides captcha consent banners. */ - popupBlocker?: boolean; } /** A session accessed with its secret sessionId. Both a CDP client and a managed task can control it, at separate times. */ export interface ManagedBrowserSession { From 687d75bdeea0eb33f93bf74fe4a607be877b15d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:22:48 +0000 Subject: [PATCH 14/26] fix(browser-pay): click Pay through the Shop Pay overlay The shop portal modal sits on the pay button and swallows the pointer click. Submit that button once directly. Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/shopify-e2e.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 1a06ccc9b..96d548faa 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -689,11 +689,13 @@ function digits(value: string): string { async function clickPay(page: Page): Promise { const named = page.locator("#checkout-pay-button").first(); - const target = (await named.isVisible().catch(() => false)) - ? named - : page.getByRole("button", { name: /pay now/i }).first(); + await named.waitFor({ state: "attached", timeout: 15_000 }); mark("clicking pay once"); - await target.click({ timeout: 15_000 }); + // A Shop Pay overlay sits on top of the button and swallows pointer events. + // One DOM click still submits the form. + await named.evaluate((element) => { + (element as { click: () => void }).click(); + }); mark("pay click returned"); } From 365de79b42b6b13853167f3d4e537d948864762d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:28:03 +0000 Subject: [PATCH 15/26] fix(browser-pay): pause only the card iframe during checkout Pausing the checkout page stopped Anchor's solver from clearing the hCaptcha Shopify shows after Pay. pauseOnlyUrls limits Fetch to the card host. The default is still to pause every page and iframe. Co-authored-by: Rimmely Ewan --- packages/browser-pay/README.md | 2 +- packages/browser-pay/examples/shopify-e2e.ts | 9 ++++++- packages/browser-pay/src/interceptor.ts | 25 +++++++++++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 5bd2ae8a8..7f42f0d55 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -4,7 +4,7 @@ Browser-side payment interceptor for a card checkout in the agent's own Chromium The agent types a placeholder card into the checkout. This module attaches over CDP, pauses the request whose body contains that placeholder, and sends it to the relay. The relay's response fulfills the paused request. The browser never sees a real card value. -M0 pauses page and iframe requests and inspects the body. GET and HEAD requests with no body are continued as soon as the URL is checked, so captcha and other subresources are not held for a remote round trip. A URL that contains the held number is failed. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. +M0 pauses page and iframe requests and inspects the body. Pass `pauseOnlyUrls` to pause only matching targets, which leaves a checkout page free for a captcha solver. GET and HEAD requests with no body are continued as soon as the URL is checked, so captcha and other subresources are not held for a remote round trip. A URL that contains the held number is failed. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. ## Attach diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index 96d548faa..b1053a014 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -175,6 +175,9 @@ async function main(): Promise { transactionId: relayName === "http" ? required("TRANSACTION_ID") : "tx_local_demo", timeoutMs: 30_000, + // Pause only the card iframe. Pausing the checkout page holds the + // captcha solver that runs after Pay. + pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], }); handle.on((event) => events.push(event)); await assertCardHoldsPlaceholder(checkout, placeholder); @@ -412,7 +415,11 @@ async function acceptAddressSuggestion(page: Page): Promise { const name = (await button.innerText().catch(() => "")) .replace(/\s+/g, " ") .trim(); - if (/pay now|submit|refund|privacy|terms|contact|order summary|show more/i.test(name)) { + if ( + /pay now|submit|refund|privacy|terms|contact|order summary|show more/i.test( + name, + ) + ) { continue; } if (/^\d{5}$/.test(name) || /5th|suggested/i.test(name)) { diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 5faf2c0c7..48a0fe8c1 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -13,6 +13,12 @@ export interface AttachOptions { * The relay is not retried. */ timeoutMs?: number; + /** + * When set, only targets whose URL contains one of these strings are paused. + * Other pages keep running, so a captcha solver is not held on their traffic. + * Omit it to pause every page and iframe. + */ + pauseOnlyUrls?: string[]; } export type InterceptorEvent = @@ -114,6 +120,15 @@ interface Session { function isBlankTarget(url: string): boolean { return url === "" || url === "about:blank" || url === "about:srcdoc"; } + +function shouldPauseTarget( + session: Session, + pauseOnlyUrls?: string[], +): boolean { + if (isHandsOffTarget(session) || isBlankTarget(session.url)) return false; + if (!pauseOnlyUrls || pauseOnlyUrls.length === 0) return true; + return pauseOnlyUrls.some((part) => session.url.includes(part)); +} function isHandsOffTarget(session: Session): boolean { if ( session.type === "worker" || @@ -176,7 +191,11 @@ export async function attachInterceptor( // A blank frame has not navigated yet. Holding it for Fetch.enable freezes // a captcha widget that is about to load. Arm Fetch only once the URL is a // page we might need to pause. - if (!session || isHandsOffTarget(session) || isBlankTarget(session.url)) { + if (!session || !shouldPauseTarget(session, options.pauseOnlyUrls)) { + if (session?.fetchEnabled) { + session.fetchEnabled = false; + await connection.send("Fetch.disable", {}, sessionId).catch(() => {}); + } await releaseTarget(connection, sessionId, waitingForDebugger); return; } @@ -306,7 +325,7 @@ export async function attachInterceptor( for (const session of sessions.values()) { if (session.targetId !== info.targetId) continue; session.url = info.url; - if (isHandsOffTarget(session)) { + if (!shouldPauseTarget(session, options.pauseOnlyUrls)) { if (session.fetchEnabled) { session.fetchEnabled = false; void connection @@ -318,7 +337,7 @@ export async function attachInterceptor( .catch(() => {}); continue; } - if (!session.fetchEnabled && !isBlankTarget(session.url)) { + if (!session.fetchEnabled) { void armFetch(connection, session); } } From 48072f4a3c0b361f2a44169eb6693ea0b99af048 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:56:12 +0000 Subject: [PATCH 16/26] fix(browser-pay): fail closed when a placeholder is easy to miss Search the separator-stripped body and URL for the one held number, fail in-flight pauses before disabling Fetch, and do not resume a target whose Fetch.enable failed. The relay contract is version 1, with header pairs, body encoding, and an idempotency key. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-interceptor.md | 2 +- packages/browser-pay/README.md | 13 +- .../browser-pay/examples/relays/bogus.test.ts | 24 +- packages/browser-pay/examples/relays/bogus.ts | 4 +- .../examples/relays/bt-proxy.test.ts | 16 +- .../browser-pay/examples/relays/bt-proxy.ts | 4 +- .../examples/relays/replace-card.ts | 28 +- packages/browser-pay/examples/shopify-e2e.ts | 1 + packages/browser-pay/package.json | 5 + packages/browser-pay/src/cdp.test.ts | 118 +++ packages/browser-pay/src/cdp.ts | 145 +++- packages/browser-pay/src/contract.test.ts | 75 +- packages/browser-pay/src/contract.ts | 107 ++- packages/browser-pay/src/detect.test.ts | 44 + packages/browser-pay/src/detect.ts | 111 ++- .../browser-pay/src/fixtures/refusals.json | 3 +- .../src/fixtures/shopify-card-session.json | 37 +- packages/browser-pay/src/index.ts | 15 +- .../src/interceptor.integration.test.ts | 224 ++++- .../src/interceptor.protocol.test.ts | 709 ++++++++++++++++ packages/browser-pay/src/interceptor.test.ts | 20 +- packages/browser-pay/src/interceptor.ts | 776 ++++++++++++------ packages/browser-pay/src/relay.test.ts | 44 +- packages/browser-pay/src/relay.ts | 31 +- packages/browser-pay/src/testing.ts | 8 + 25 files changed, 2170 insertions(+), 394 deletions(-) create mode 100644 packages/browser-pay/src/cdp.test.ts create mode 100644 packages/browser-pay/src/interceptor.protocol.test.ts create mode 100644 packages/browser-pay/src/testing.ts diff --git a/.changeset/browser-pay-interceptor.md b/.changeset/browser-pay-interceptor.md index f5d1804b7..a6cae9120 100644 --- a/.changeset/browser-pay-interceptor.md +++ b/.changeset/browser-pay-interceptor.md @@ -2,4 +2,4 @@ "@sapiom/browser-pay": minor --- -Add `@sapiom/browser-pay`, the browser-side payment interceptor. It attaches over CDP, pauses a request whose body carries the held placeholder card, and relays that request without ever continuing a placeholder to the payment provider. +Add `@sapiom/browser-pay`, the browser-side payment interceptor. It attaches over CDP, pauses a request whose body or URL carries the held placeholder card, and relays that request without ever continuing a placeholder to the payment provider. The relay contract is version 1: header pairs, body encoding, and an idempotency key. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 7f42f0d55..645189928 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -4,7 +4,7 @@ Browser-side payment interceptor for a card checkout in the agent's own Chromium The agent types a placeholder card into the checkout. This module attaches over CDP, pauses the request whose body contains that placeholder, and sends it to the relay. The relay's response fulfills the paused request. The browser never sees a real card value. -M0 pauses page and iframe requests and inspects the body. Pass `pauseOnlyUrls` to pause only matching targets, which leaves a checkout page free for a captcha solver. GET and HEAD requests with no body are continued as soon as the URL is checked, so captcha and other subresources are not held for a remote round trip. A URL that contains the held number is failed. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, or network error fails the paused request; the placeholder is never sent on. +M0 pauses page and iframe requests and inspects the body and the URL. Pass `pauseOnlyUrls` to pause only matching targets, which leaves a checkout page free for a captcha solver. GET and HEAD requests with no body are continued as soon as the URL is checked, so captcha and other subresources are not held for a remote round trip. A URL that contains the held number is failed. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. One CDP session is kept per target. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, unreadable body, or network error fails the paused request; the placeholder is never sent on. `detach()` fails paused requests that are still undecided or mid-relay before it turns Fetch off. A process crash or a dropped CDP socket can still let Chrome continue those requests. WebSocket frames and client-side-encrypted card payloads are not visible to this check, and worklet targets are not paused. ## Attach @@ -41,7 +41,8 @@ const handle = await attachInterceptor(session.cdpUrl, { }, }); handle.on((event) => { - // paused, relayed, refused, failed. Card numbers are masked if one appears in a URL. + // paused, relayed, relayed_unfulfilled, refused, failed. + // Card numbers are masked if one appears in a URL. }); ``` @@ -57,13 +58,15 @@ await attachInterceptor(process.env.CDP_URL!, { `examples/attach.ts` is that script. The HTTP relay calls `POST /v1/transactions/:id/payment/relay` with the `x-api-key` header. That route may not be deployed yet. Swap a demo relay in without changing the interceptor: both implement `PaymentRelay`. -`merchantOrigin` on the relay request is the top-level page origin (scheme + host), not the card iframe's host. +`merchantOrigin` on the relay request is the top-level page origin (scheme + host). `frameOrigin` is the frame that sent the request. Pass `synthesizeBrowserHeaders: true` only when the processor needs Cookie and Sec-Fetch-\* copied out of the browser. Those headers can include HttpOnly cookies. The relay backend must not log or persist them. ## Relay contract -Types and fixtures live in this package (`RelayRequest`, `RelayResponse`, `shopifyCardSessionRoundTrip`, `relayRefusalFixtures`). The Shopify fixture posts to `https://checkout.pci.shopifyinc.com/sessions`. A refusal is `{ ok: false, reason }` and does not echo the request. Reasons: `wrong_key`, `unknown_placeholder`, `closed_placeholder`, `expired_placeholder`, `off_profile_destination`, `replay`, `merchant_origin`, `changed_total`, `cancelled`. +`RelayRequest` is version 1. `headers` is a list of `[name, value]` pairs so repeated `Set-Cookie` values survive. `bodyEncoding` is `"utf8"` or `"base64"` on the request and the success response. `idempotencyKey` is the CDP request id plus a nonce; the HTTP client also sends it as `idempotency-key`. The relay call takes an `AbortSignal`, and that signal is the only timeout when the interceptor owns it. -Placeholder detection matches the backend's placeholder-detection rules (separators, 13–19 digits, Luhn, then expiry and CVC when those fields are present). It is duplicated here so a browser bundle does not import the backend. It should move to a shared package. +A refusal is `{ ok: false, reason }` and does not echo the request. Reasons: `wrong_key`, `unknown_placeholder`, `closed_placeholder`, `expired_placeholder`, `off_profile_destination`, `replay`, `merchant_origin`, `changed_total`, `cancelled`. An unrecognized reason becomes `unknown`. Fixtures, including the Shopify card-session round trip, are exported from `@sapiom/browser-pay/testing`. + +Placeholder detection matches the backend's placeholder-detection rules (separators, 13–19 digits, Luhn, then expiry and CVC when those fields are present). The held number is also searched for after separators are stripped, so a longer digit run cannot hide it. The rules are duplicated here so a browser bundle does not import the backend. They should move to a shared package. The interceptor does not fulfill a provider response that contains a different Luhn-valid card number. ## Shopify dev-store script diff --git a/packages/browser-pay/examples/relays/bogus.test.ts b/packages/browser-pay/examples/relays/bogus.test.ts index 9ad4b9d07..33fd2feda 100644 --- a/packages/browser-pay/examples/relays/bogus.test.ts +++ b/packages/browser-pay/examples/relays/bogus.test.ts @@ -28,13 +28,16 @@ function request(body = BODY): RelayRequest { merchantOrigin: "https://1a2uh3-r7.myshopify.com", url: "https://checkout.pci.shopifyinc.com/sessions?x=1", method: "POST", - headers: { - "content-type": "application/json", - "content-length": "999", - host: "checkout.pci.shopifyinc.com", - "shopify-identification-signature": "sig", - }, + headers: [ + ["content-type", "application/json"], + ["content-length", "999"], + ["host", "checkout.pci.shopifyinc.com"], + ["shopify-identification-signature", "sig"], + ], body, + bodyEncoding: "utf8", + version: 1, + idempotencyKey: "req_demo", }; } @@ -80,11 +83,12 @@ describe("bogusShopifyRelay", () => { expect(response).toEqual({ ok: true, status: 201, - headers: { - "content-type": "application/json", - "set-cookie": "a=b", - }, + headers: [ + ["content-type", "application/json"], + ["set-cookie", "a=b"], + ], body: '{"id":"sess_bogus"}', + bodyEncoding: "utf8", }); }); diff --git a/packages/browser-pay/examples/relays/bogus.ts b/packages/browser-pay/examples/relays/bogus.ts index 65031c87f..6674723a0 100644 --- a/packages/browser-pay/examples/relays/bogus.ts +++ b/packages/browser-pay/examples/relays/bogus.ts @@ -3,6 +3,7 @@ import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; import { forwardRequestHeaders, providerResponseHeaders, + relayBodyText, replacePlaceholderCard, } from "./replace-card.js"; @@ -21,7 +22,7 @@ export function bogusShopifyRelay(options: { return { async relay(request) { const body = replacePlaceholderCard( - request.body, + relayBodyText(request), options.placeholder.number, { number: BOGUS_CARD.number, @@ -45,6 +46,7 @@ export function bogusShopifyRelay(options: { status: response.status, headers: providerResponseHeaders(response.headers), body: await response.text(), + bodyEncoding: "utf8", }; }, }; diff --git a/packages/browser-pay/examples/relays/bt-proxy.test.ts b/packages/browser-pay/examples/relays/bt-proxy.test.ts index ff9919084..5266771ee 100644 --- a/packages/browser-pay/examples/relays/bt-proxy.test.ts +++ b/packages/browser-pay/examples/relays/bt-proxy.test.ts @@ -23,11 +23,14 @@ const REQUEST: RelayRequest = { merchantOrigin: "https://shop.example", url: "https://checkout.pci.shopifyinc.com/sessions", method: "POST", - headers: { - "content-type": "application/json", - "content-length": "40", - "shopify-identification-signature": "sig", - }, + headers: [ + ["content-type", "application/json"], + ["content-length", "40"], + ["shopify-identification-signature", "sig"], + ], + bodyEncoding: "utf8", + version: 1, + idempotencyKey: "req_demo", body: JSON.stringify({ credit_card: { number: "4242424242424242", @@ -91,8 +94,9 @@ describe("btProxyRelay", () => { expect(response).toEqual({ ok: true, status: 201, - headers: { "content-type": "application/json" }, + headers: [["content-type", "application/json"]], body: '{"id":"sess_bt"}', + bodyEncoding: "utf8", }); if (!response.ok) throw new Error("expected a fulfilled relay response"); expect(JSON.stringify(response.headers)).not.toContain("bt-"); diff --git a/packages/browser-pay/examples/relays/bt-proxy.ts b/packages/browser-pay/examples/relays/bt-proxy.ts index 8c10caa85..4efe336ea 100644 --- a/packages/browser-pay/examples/relays/bt-proxy.ts +++ b/packages/browser-pay/examples/relays/bt-proxy.ts @@ -3,6 +3,7 @@ import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; import { forwardRequestHeaders, providerResponseHeaders, + relayBodyText, replacePlaceholderCard, } from "./replace-card.js"; @@ -258,7 +259,7 @@ export function btProxyRelay(options: { return { async relay(request) { const body = replacePlaceholderCard( - request.body, + relayBodyText(request), options.placeholder.number, { number: expression("number"), @@ -299,6 +300,7 @@ export function btProxyRelay(options: { status: hasDestination ? destination : response.status, headers: providerResponseHeaders(response.headers), body: await response.text(), + bodyEncoding: "utf8", }; }, }; diff --git a/packages/browser-pay/examples/relays/replace-card.ts b/packages/browser-pay/examples/relays/replace-card.ts index 60942c250..0a2e777b1 100644 --- a/packages/browser-pay/examples/relays/replace-card.ts +++ b/packages/browser-pay/examples/relays/replace-card.ts @@ -1,4 +1,5 @@ import { cardNumberCandidate } from "../../src/detect.js"; +import type { RelayHeader, RelayRequest } from "../../src/index.js"; export interface CardReplacement { number: string; @@ -201,20 +202,29 @@ export const HOP_BY_HOP = new Set([ "proxy-authorization", ]); +export function relayBodyText(request: RelayRequest): string { + if (request.bodyEncoding === "base64") { + return Buffer.from(request.body, "base64").toString("utf8"); + } + return request.body; +} + export function forwardRequestHeaders( - headers: Record, + headers: RelayHeader[], ): Record { const out: Record = {}; - for (const [key, value] of Object.entries(headers)) { + for (const [key, value] of headers) { if (HOP_BY_HOP.has(key.toLowerCase())) continue; - out[key] = value; + const existing = Object.keys(out).find( + (candidate) => candidate.toLowerCase() === key.toLowerCase(), + ); + if (existing) out[existing] = `${out[existing]}, ${value}`; + else out[key] = value; } return out; } -export function providerResponseHeaders( - headers: Headers, -): Record { +export function providerResponseHeaders(headers: Headers): RelayHeader[] { const drop = new Set([ "content-encoding", "content-length", @@ -225,13 +235,13 @@ export function providerResponseHeaders( "trailer", "upgrade", ]); - const out: Record = {}; + const out: RelayHeader[] = []; headers.forEach((value, key) => { if (drop.has(key) || key.startsWith("bt-") || key === "set-cookie") return; - out[key] = value; + out.push([key, value]); }); const setCookies = typeof headers.getSetCookie === "function" ? headers.getSetCookie() : []; - if (setCookies.length > 0) out["set-cookie"] = setCookies.join("\n"); + for (const cookie of setCookies) out.push(["set-cookie", cookie]); return out; } diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts index b1053a014..2bf491a62 100644 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ b/packages/browser-pay/examples/shopify-e2e.ts @@ -178,6 +178,7 @@ async function main(): Promise { // Pause only the card iframe. Pausing the checkout page holds the // captcha solver that runs after Pay. pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], + synthesizeBrowserHeaders: true, }); handle.on((event) => events.push(event)); await assertCardHoldsPlaceholder(checkout, placeholder); diff --git a/packages/browser-pay/package.json b/packages/browser-pay/package.json index 39625aa0f..07a0adeec 100644 --- a/packages/browser-pay/package.json +++ b/packages/browser-pay/package.json @@ -28,6 +28,11 @@ "types": "./dist/cjs/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" + }, + "./testing": { + "types": "./dist/cjs/testing.d.ts", + "import": "./dist/esm/testing.js", + "require": "./dist/cjs/testing.js" } }, "files": [ diff --git a/packages/browser-pay/src/cdp.test.ts b/packages/browser-pay/src/cdp.test.ts new file mode 100644 index 000000000..1774d0728 --- /dev/null +++ b/packages/browser-pay/src/cdp.test.ts @@ -0,0 +1,118 @@ +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 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 index 048fb2ebb..a0170a9f2 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -22,24 +22,32 @@ type Pending = { timer: NodeJS.Timeout; }; +/** One CDP payload, including a large getRequestPostData body, stays under this. */ +const MAX_FRAME_BYTES = 16 * 1024 * 1024; + class WebSocketClient { - private buffer = Buffer.alloc(0); + 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 constructor(private readonly socket: Duplex) { socket.on("data", (chunk: Buffer) => { - this.buffer = Buffer.concat([this.buffer, chunk]); - this.parse(); + this.append(chunk); }); socket.on("close", () => { + if (this.closed) return; this.closed = true; this.onclose(); }); - socket.on("error", () => { - this.closed = true; + socket.on("error", (error: Error) => { + this.fail( + error instanceof Error ? error : new Error("CDP websocket error"), + ); }); } @@ -75,10 +83,7 @@ class WebSocketClient { return; } const client = new WebSocketClient(socket); - if (head?.length) { - client.buffer = Buffer.from(head); - client.parse(); - } + if (head?.length) client.append(Buffer.from(head)); resolve(client); }); request.on("response", (response) => { @@ -96,34 +101,107 @@ class WebSocketClient { }); } + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + this.onerror(error); + this.socket.destroy(); + 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 (;;) { - if (this.buffer.length < 2) return; - const first = this.buffer[0]; - const second = this.buffer[1]; + 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) { - if (this.buffer.length < 4) return; - length = this.buffer.readUInt16BE(2); + const extended = this.peek(4); + if (!extended) return; + length = extended.readUInt16BE(2); offset = 4; } else if (length === 127) { - if (this.buffer.length < 10) return; - length = Number(this.buffer.readBigUInt64BE(2)); + 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; - if (this.buffer.length < offset + length) return; - let payload = this.buffer.subarray(offset, offset + length); + const frame = this.consume(offset + length); + if (!frame) return; + let payload = frame.subarray(offset, offset + length); if (masked) { - const mask = this.buffer.subarray(maskOffset, maskOffset + 4); - payload = Buffer.from( - payload.map((byte, index) => byte ^ mask[index % 4]), - ); + 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; } - this.buffer = this.buffer.subarray(offset + length); const opcode = first & 0x0f; const fin = (first & 0x80) !== 0; if (opcode === 0x8) { @@ -136,10 +214,16 @@ class WebSocketClient { } 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); } } @@ -165,7 +249,10 @@ class WebSocketClient { header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(length), 2); } - const body = Buffer.from(data.map((byte, index) => byte ^ mask[index % 4])); + 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])); } @@ -218,13 +305,19 @@ export class CdpConnection { }; for (const listener of this.listeners) listener(event); }; - socket.onclose = () => { + const rejectAll = (error: Error) => { for (const pending of this.pending.values()) { clearTimeout(pending.timer); - pending.reject(new Error("CDP connection closed")); + pending.reject(error); } this.pending.clear(); }; + socket.onerror = (error) => { + rejectAll(error); + }; + socket.onclose = () => { + rejectAll(new Error("CDP connection closed")); + }; } static async connect( diff --git a/packages/browser-pay/src/contract.test.ts b/packages/browser-pay/src/contract.test.ts index 3c7959043..aa872b6d8 100644 --- a/packages/browser-pay/src/contract.test.ts +++ b/packages/browser-pay/src/contract.test.ts @@ -1,19 +1,82 @@ -import { parseRelayResponse } from "./contract"; +import { parseRelayResponse, relayHeadersFromRecord } from "./contract"; describe("parseRelayResponse", () => { - it("rejects a payload that is not a success or a known refusal", () => { + it("rejects a payload that is not a success or a refusal", () => { expect(() => parseRelayResponse(null)).toThrow( "Payment relay returned an unreadable response", ); + expect(() => parseRelayResponse({ ok: false })).toThrow( + "Payment relay returned an unreadable response", + ); expect(() => + parseRelayResponse({ ok: true, status: 200, body: 1, headers: {} }), + ).toThrow("Payment relay returned an unreadable response"); + }); + + it("maps an unknown refusal reason and keeps a known one", () => { + expect( parseRelayResponse({ ok: false, reason: "made_up", body: "4242424242424242", }), - ).toThrow("Payment relay returned an unreadable response"); - expect(() => - parseRelayResponse({ ok: true, status: 200, body: 1, headers: {} }), - ).toThrow("Payment relay returned an unreadable response"); + ).toEqual({ ok: false, reason: "unknown" }); + expect(parseRelayResponse({ ok: false, reason: "replay" })).toEqual({ + ok: false, + reason: "replay", + }); + }); + + it("accepts header tuples, repeated Set-Cookie, and a base64 body", () => { + expect( + parseRelayResponse({ + ok: true, + status: 200, + headers: [ + ["content-type", "application/json"], + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ], + body: Buffer.from('{"id":"s"}', "utf8").toString("base64"), + bodyEncoding: "base64", + }), + ).toEqual({ + ok: true, + status: 200, + headers: [ + ["content-type", "application/json"], + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ], + body: Buffer.from('{"id":"s"}', "utf8").toString("base64"), + bodyEncoding: "base64", + }); + }); + + it("accepts a legacy header map and defaults body encoding to utf8", () => { + expect( + parseRelayResponse({ + ok: true, + status: 201, + headers: { + "content-type": "application/json", + "set-cookie": "a=1\nb=2", + }, + body: "{}", + }), + ).toEqual({ + ok: true, + status: 201, + headers: [ + ["content-type", "application/json"], + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ], + body: "{}", + bodyEncoding: "utf8", + }); + expect(relayHeadersFromRecord({ accept: "application/json" })).toEqual([ + ["accept", "application/json"], + ]); }); }); diff --git a/packages/browser-pay/src/contract.ts b/packages/browser-pay/src/contract.ts index 6176ba255..ed1a77183 100644 --- a/packages/browser-pay/src/contract.ts +++ b/packages/browser-pay/src/contract.ts @@ -5,8 +5,15 @@ * on the response is a credential: a success body is the payment provider's * opaque response, and a refusal names a reason without echoing the request. * Real card values never travel back to the browser. + * + * `headers` may include browser cookies. The relay backend must not log or + * persist them. The backend also scrubs provider response bodies and headers + * before they are returned; the interceptor refuses to fulfill a response that + * still contains a card number other than the held placeholder. */ +export const RELAY_CONTRACT_VERSION = 1; + export const RELAY_REFUSAL_REASONS = [ "wrong_key", "unknown_placeholder", @@ -17,23 +24,38 @@ export const RELAY_REFUSAL_REASONS = [ "merchant_origin", "changed_total", "cancelled", + "unknown", ] as const; export type RelayRefusalReason = (typeof RELAY_REFUSAL_REASONS)[number]; +export type RelayBodyEncoding = "utf8" | "base64"; + +/** One header. The same name may appear more than once (Set-Cookie). */ +export type RelayHeader = [name: string, value: string]; + /** * What the interceptor sends to the relay. * `merchantOrigin` is the top-level page origin (scheme + host), the payTo * value bound at authorization. It is never the card iframe's processor host. + * `frameOrigin` is the origin of the frame that issued the request. */ export interface RelayRequest { + version: typeof RELAY_CONTRACT_VERSION; transactionId: string; injectionRef: string; merchantOrigin: string; + frameOrigin?: string; url: string; method: string; - headers: Record; + headers: RelayHeader[]; body: string; + bodyEncoding: RelayBodyEncoding; + /** + * CDP request id plus a nonce. The relay uses it to recognize a retry of + * the same paused request (`replay`). + */ + idempotencyKey: string; } /** Provider response to fulfill the paused browser request with. */ @@ -41,9 +63,13 @@ export interface RelaySuccess { ok: true; /** Provider HTTP status. */ status: number; - headers: Record; - /** Provider response body. Must not contain a real card value. */ + headers: RelayHeader[]; + /** + * Provider response body. Must not contain a real card value. + * `bodyEncoding` says how `body` is encoded. + */ body: string; + bodyEncoding: RelayBodyEncoding; } /** Verification failed. Nothing was forwarded. The request is not echoed. */ @@ -60,16 +86,59 @@ export function isRelayRefusalReason( return (RELAY_REFUSAL_REASONS as readonly string[]).includes(value); } -function isHeaderRecord(value: unknown): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - return Object.values(value as Record).every( - (entry) => typeof entry === "string", +export function isRelayBodyEncoding( + value: unknown, +): value is RelayBodyEncoding { + return value === "utf8" || value === "base64"; +} + +function isRelayHeaderList(value: unknown): value is RelayHeader[] { + return ( + Array.isArray(value) && + value.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string", + ) ); } +/** A string map, with multiple Set-Cookie values split on newlines. */ +export function relayHeadersFromRecord( + headers: Record, +): RelayHeader[] { + const out: RelayHeader[] = []; + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() === "set-cookie") { + for (const part of value.split("\n")) { + if (part.length > 0) out.push([name, part]); + } + continue; + } + out.push([name, value]); + } + return out; +} + +function headersFromUnknown(value: unknown): RelayHeader[] | undefined { + if (isRelayHeaderList(value)) return value; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + if (!Object.values(record).every((entry) => typeof entry === "string")) { + return undefined; + } + return relayHeadersFromRecord(record as Record); +} + /** - * Accept a success payload or a typed refusal. Anything else throws, and the - * message never includes the payload (it may contain a card or a request echo). + * Accept a success payload or a typed refusal. An unknown refusal reason + * becomes `unknown` so a newer backend does not look like a network error. + * Anything else throws, and the message never includes the payload (it may + * contain a card or a request echo). */ export function parseRelayResponse(value: unknown): RelayResponse { if (!value || typeof value !== "object") { @@ -77,29 +146,33 @@ export function parseRelayResponse(value: unknown): RelayResponse { } const body = value as Record; if (body.ok === true) { + const headers = headersFromUnknown(body.headers); + const bodyEncoding = + body.bodyEncoding === undefined ? "utf8" : body.bodyEncoding; if ( typeof body.status !== "number" || !Number.isInteger(body.status) || body.status < 100 || body.status > 599 || typeof body.body !== "string" || - !isHeaderRecord(body.headers) + !headers || + !isRelayBodyEncoding(bodyEncoding) ) { throw new Error("Payment relay returned an unreadable response"); } return { ok: true, status: body.status, - headers: body.headers, + headers, body: body.body, + bodyEncoding, }; } - if ( - typeof body.reason === "string" && - isRelayRefusalReason(body.reason) && - body.ok !== true - ) { - return { ok: false, reason: body.reason }; + if (body.ok === false && typeof body.reason === "string") { + return { + ok: false, + reason: isRelayRefusalReason(body.reason) ? body.reason : "unknown", + }; } throw new Error("Payment relay returned an unreadable response"); } diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts index 7ed9ce5df..6c16c9de5 100644 --- a/packages/browser-pay/src/detect.test.ts +++ b/packages/browser-pay/src/detect.test.ts @@ -2,6 +2,8 @@ import { cardNumberCandidate, matchHeldPlaceholder, matchHeldPlaceholderInBody, + textContainsForeignCard, + textHoldsPlaceholderNumber, type HeldPlaceholder, } from "./detect"; import { maskCardNumber, redact } from "./redact"; @@ -111,6 +113,34 @@ describe("matchHeldPlaceholderInBody", () => { ).toEqual({ kind: "not_held" }); }); + it("still sees the placeholder when more digits follow it", () => { + expect(matchHeldPlaceholderInBody("4242 4242 4242 4242 123", HELD)).toEqual( + { kind: "match", injectionRef: "inj_test" }, + ); + expect(matchHeldPlaceholderInBody("4242 4242 4242 4242 999", HELD)).toEqual( + { kind: "mismatch", injectionRef: "inj_test", mismatch: "cvc" }, + ); + expect( + matchHeldPlaceholderInBody( + json({ number: "4242 4242 4242 4242 12/28" }), + HELD, + ), + ).toEqual({ kind: "match", injectionRef: "inj_test" }); + expect( + matchHeldPlaceholderInBody("4242 4242 4242 4242 01/28", HELD), + ).toEqual({ + kind: "mismatch", + injectionRef: "inj_test", + mismatch: "expiry", + }); + const boundary = "----WebKitFormBoundary123"; + const multipart = `--${boundary}\r\nContent-Disposition: form-data; name="number"\r\n\r\n4242424242424242\r\n--${boundary}--`; + expect(matchHeldPlaceholderInBody(multipart, HELD)).toEqual({ + kind: "match", + injectionRef: "inj_test", + }); + }); + it("mismatches a held number with the wrong cvc", () => { expect( matchHeldPlaceholderInBody( @@ -178,6 +208,20 @@ describe("matchHeldPlaceholderInBody", () => { kind: "not_held", }); }); + + it("finds a percent-encoded placeholder and ignores a different Luhn card inside a longer run", () => { + const encoded = encodeURIComponent("4242 4242 4242 4242"); + expect(textHoldsPlaceholderNumber(encoded, HELD.number)).toBe(true); + expect(matchHeldPlaceholderInBody(encoded, HELD)).toEqual({ + kind: "match", + injectionRef: "inj_test", + }); + expect(textContainsForeignCard("4111111111111111", HELD.number)).toBe(true); + expect(textContainsForeignCard(HELD.number, HELD.number)).toBe(false); + expect(textContainsForeignCard('{"id":"west-fixture"}', HELD.number)).toBe( + false, + ); + }); }); describe("redact", () => { diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts index bc092d589..f9f19bcb5 100644 --- a/packages/browser-pay/src/detect.ts +++ b/packages/browser-pay/src/detect.ts @@ -89,11 +89,58 @@ export function luhnCheckDigit(body: string): string { return String((10 - (sum % 10)) % 10); } +/** + * Whether `text` contains the held number. Separators, `+`, `%20`, percent + * escapes, and JSON `\u` escapes are removed first. One placeholder is held, + * so a longer digit run that still contains that number counts. + */ +export function textHoldsPlaceholderNumber( + text: string, + number: string, +): boolean { + if (!/^\d{13,19}$/.test(number) || text.length === 0) return false; + return normalizeForHeldSearch(text).includes(number); +} + +/** + * A Luhn-valid card number in `text` that is not the held placeholder. + * Used to refuse a relay response that still carries a real PAN. + */ +export function textContainsForeignCard( + text: string, + placeholderNumber: string, +): boolean { + const decoded = decodeForHeldSearch(text); + const runs = decoded.match(/\d(?:[\s+-]*\d)+/g) ?? []; + for (const run of runs) { + const digits = run.replace(/\D/g, ""); + if (digits.length < 13) continue; + const last = Math.min(19, digits.length); + for (let length = 13; length <= last; length++) { + for (let index = 0; index + length <= digits.length; index++) { + const candidate = digits.slice(index, index + length); + if ( + candidate === placeholderNumber || + placeholderNumber.includes(candidate) + ) { + continue; + } + if (cardNumberCandidate(candidate)) return true; + } + } + } + return false; +} + /** * Whether `body` contains the single held placeholder. * `not_held` means the placeholder number is not in the body (other card * numbers included). `mismatch` means the number is present but the expiry or * CVC beside it disagrees. `match` carries only the injection ref. + * + * The structured parse decides match versus mismatch. If that parse misses + * the number because more digits follow it, the separator-stripped body is + * searched for the held number and the request is still not `not_held`. */ export function matchHeldPlaceholderInBody( body: string, @@ -110,7 +157,10 @@ export function matchHeldPlaceholderInBody( if (result.kind === "mismatch" && !mismatch) mismatch = result; } if (mismatch) return mismatch; - return { kind: "not_held" }; + if (!textHoldsPlaceholderNumber(body, held.number)) { + return { kind: "not_held" }; + } + return classifyAdjacentHeld(body, held); } /** Card numbers in a body, with expiry and CVC taken from the same object or form group. */ @@ -366,6 +416,65 @@ function readExpiry( return { expMonth: Number(match[1]), expYear: Number(match[2]) }; } +function classifyAdjacentHeld( + body: string, + held: HeldPlaceholder, +): BodyPlaceholderMatch { + const sources = [body]; + const decoded = decodeForHeldSearch(body); + if (decoded !== body) sources.push(decoded); + const pattern = new RegExp(held.number.split("").join("[\\s+-]*"), "g"); + for (const source of sources) { + for (const match of source.matchAll(pattern)) { + const after = source.slice((match.index ?? 0) + match[0].length); + const expiry = after.match( + /^\s*(0?[1-9]|1[0-2])\s*[/-]\s*(\d{2}|\d{4})(?!\d)/, + ); + if (expiry) { + const expMonth = Number(expiry[1]); + const expYear = Number(expiry[2]); + if ( + expMonth !== held.expMonth || + expYear % 100 !== held.expYear % 100 + ) { + return { + kind: "mismatch", + injectionRef: held.injectionRef, + mismatch: "expiry", + }; + } + continue; + } + const cvc = after.match(/^\s*(\d{3,4})(?!\d)/); + if (cvc && cvc[1] !== held.cvc) { + return { + kind: "mismatch", + injectionRef: held.injectionRef, + mismatch: "cvc", + }; + } + } + } + return { kind: "match", injectionRef: held.injectionRef }; +} + +function decodeForHeldSearch(text: string): string { + const withUnicode = text.replace( + /\\u([0-9a-fA-F]{4})/g, + (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), + ); + const plus = withUnicode.replace(/\+/g, " "); + try { + return decodeURIComponent(plus); + } catch { + return plus; + } +} + +function normalizeForHeldSearch(text: string): string { + return decodeForHeldSearch(text).replace(/[\s+-]|%20/gi, ""); +} + function scanRawNumbers(body: string): string[] { const found = new Set(); for (const match of body.matchAll(/\d(?:[\s-]*\d){12,18}/g)) { diff --git a/packages/browser-pay/src/fixtures/refusals.json b/packages/browser-pay/src/fixtures/refusals.json index 43c2ae523..7b827d0e7 100644 --- a/packages/browser-pay/src/fixtures/refusals.json +++ b/packages/browser-pay/src/fixtures/refusals.json @@ -10,5 +10,6 @@ "replay": { "ok": false, "reason": "replay" }, "merchant_origin": { "ok": false, "reason": "merchant_origin" }, "changed_total": { "ok": false, "reason": "changed_total" }, - "cancelled": { "ok": false, "reason": "cancelled" } + "cancelled": { "ok": false, "reason": "cancelled" }, + "unknown": { "ok": false, "reason": "unknown" } } diff --git a/packages/browser-pay/src/fixtures/shopify-card-session.json b/packages/browser-pay/src/fixtures/shopify-card-session.json index f385b446e..a5cc490ed 100644 --- a/packages/browser-pay/src/fixtures/shopify-card-session.json +++ b/packages/browser-pay/src/fixtures/shopify-card-session.json @@ -1,28 +1,35 @@ { "request": { + "version": 1, "transactionId": "tx_fixture_shopify", "injectionRef": "inj_fixture_shopify", "merchantOrigin": "https://shop.example", + "frameOrigin": "https://checkout.pci.shopifyinc.com", "url": "https://checkout.pci.shopifyinc.com/sessions", "method": "POST", - "headers": { - "accept": "application/json", - "content-type": "application/json", - "origin": "https://checkout.pci.shopifyinc.com", - "referer": "https://checkout.pci.shopifyinc.com/", - "user-agent": "Mozilla/5.0 (fixture)", - "accept-language": "en-US,en;q=0.9", - "shopify-identification-signature": "fixture-signature" - }, - "body": "{\"credit_card\":{\"number\":\"4242424242424242\",\"month\":12,\"year\":2028,\"verification_value\":\"123\",\"name\":\"Fixture Buyer\"},\"payment_session_scope\":\"fixture_scope\"}" + "headers": [ + ["accept", "application/json"], + ["content-type", "application/json"], + ["origin", "https://checkout.pci.shopifyinc.com"], + ["referer", "https://checkout.pci.shopifyinc.com/"], + ["user-agent", "Mozilla/5.0 (fixture)"], + ["accept-language", "en-US,en;q=0.9"], + ["shopify-identification-signature", "fixture-signature"] + ], + "body": "{\"credit_card\":{\"number\":\"4242424242424242\",\"month\":12,\"year\":2028,\"verification_value\":\"123\",\"name\":\"Fixture Buyer\"},\"payment_session_scope\":\"fixture_scope\"}", + "bodyEncoding": "utf8", + "idempotencyKey": "req_fixture_shopify" }, "response": { "ok": true, "status": 200, - "headers": { - "content-type": "application/json", - "access-control-allow-origin": "https://checkout.pci.shopifyinc.com" - }, - "body": "{\"id\":\"west-fixture-session\"}" + "headers": [ + ["content-type", "application/json"], + ["access-control-allow-origin", "https://checkout.pci.shopifyinc.com"], + ["set-cookie", "session=a"], + ["set-cookie", "session=b"] + ], + "body": "{\"id\":\"west-fixture-session\"}", + "bodyEncoding": "utf8" } } diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts index 7e96cc2a3..3ed1fe534 100644 --- a/packages/browser-pay/src/index.ts +++ b/packages/browser-pay/src/index.ts @@ -7,14 +7,22 @@ export type { } from "./interceptor.js"; export { httpPaymentRelay, stubPaymentRelay } from "./relay.js"; -export type { HttpRelayOptions, PaymentRelay } from "./relay.js"; +export type { + HttpRelayOptions, + PaymentRelay, + RelayCallOptions, +} from "./relay.js"; export { + RELAY_CONTRACT_VERSION, RELAY_REFUSAL_REASONS, isRelayRefusalReason, parseRelayResponse, + relayHeadersFromRecord, } from "./contract.js"; export type { + RelayBodyEncoding, + RelayHeader, RelayRefusal, RelayRefusalReason, RelayRequest, @@ -36,9 +44,4 @@ export type { TypedCard, } from "./detect.js"; -export { - relayRefusalFixtures, - shopifyCardSessionRoundTrip, -} from "./fixtures.js"; - export { maskCardNumber, redact } from "./redact.js"; diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 8713db91c..844155d82 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -1,4 +1,5 @@ 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"; @@ -6,8 +7,14 @@ import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { attachInterceptor, type InterceptorEvent } from "./interceptor"; -import { stubPaymentRelay, type PaymentRelay } from "./relay"; -import type { HeldPlaceholder, RelayRequest } from "./index"; +import { + stubPaymentRelay, + type PaymentRelay, + type RelayCallOptions, +} from "./relay"; +import type { HeldPlaceholder, RelayHeader, RelayRequest } from "./index"; + +const nodeRequire = createRequire(__filename); const PLACEHOLDER: HeldPlaceholder = { number: "4242424242424242", @@ -88,9 +95,14 @@ function freePort(): Promise { function startServer( hits: Hit[], html: string, + host: string, ): Promise<{ server: http.Server; port: number }> { const server = http.createServer((req, res) => { - if (req.method === "GET" && req.url === "/frame.html") { + 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; @@ -107,7 +119,7 @@ function startServer( }); }); return new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { + server.listen(0, host, () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; resolve({ server, port }); @@ -115,15 +127,28 @@ function startServer( }); } -function chromeExecutable(): string | undefined { +function chromeExecutable(): string { const candidates = [ process.env.CHROME_PATH, "/usr/local/bin/google-chrome", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", - ]; - return candidates.find((candidate) => candidate && existsSync(candidate)); + ].filter((candidate): candidate is string => Boolean(candidate)); + const found = candidates.find((candidate) => existsSync(candidate)); + if (found) return found; + let playwrightPath = ""; + try { + playwrightPath = nodeRequire( + "playwright", + ).chromium.executablePath() as string; + } catch { + playwrightPath = ""; + } + if (playwrightPath && existsSync(playwrightPath)) return playwrightPath; + throw new Error( + `Chrome is required for the interceptor integration test. Set CHROME_PATH. Looked for: ${[...candidates, playwrightPath].filter(Boolean).join(", ")}`, + ); } async function waitForDebugger( @@ -152,9 +177,9 @@ async function waitForDebugger( throw new Error(`CDP endpoint did not come up (${last})`); } -const describeWithChrome = chromeExecutable() ? describe : describe.skip; +const chromeBin = chromeExecutable(); -describeWithChrome("attachInterceptor on headless Chromium", () => { +describe("attachInterceptor on headless Chromium", () => { let chrome: ChildProcess; let userDataDir: string; let cdpUrl: string; @@ -163,21 +188,16 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { const hits: Hit[] = []; beforeAll(async () => { - const executable = chromeExecutable(); - if (!executable) { - throw new Error( - "google-chrome or chromium is required for the interceptor integration test", - ); - } userDataDir = await mkdtemp(path.join(os.tmpdir(), "browser-pay-")); const port = await freePort(); chrome = spawn( - executable, + chromeBin, [ "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", + "--site-per-process", `--remote-debugging-port=${port}`, "--remote-allow-origins=*", `--user-data-dir=${userDataDir}`, @@ -188,8 +208,12 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { { stdio: "ignore" }, ); cdpUrl = await waitForDebugger(port, chrome); - card = await startServer(hits, FRAME_HTML); - merchant = await startServer(hits, ""); + card = await startServer(hits, FRAME_HTML, "127.0.0.1"); + merchant = await startServer( + hits, + `merchant`, + "localhost", + ); }, 30_000); afterAll(async () => { @@ -206,20 +230,21 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { 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) => { + relay: async (request, options) => { calls.push(request); - return relay.relay(request); + return relay.relay(request, options); }, }; const handle = await attachInterceptor(cdpUrl, { relay: wrapped, placeholder: PLACEHOLDER, transactionId: "tx_integration", - timeoutMs: 5_000, + timeoutMs, }); handle.on((event) => events.push(event)); const { chromium } = await import("playwright"); @@ -228,12 +253,8 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { try { const context = browser.contexts()[0]; page = context.pages()[0] ?? (await context.newPage()); - const merchantOrigin = `http://127.0.0.1:${merchant.port}`; + const merchantOrigin = `http://localhost:${merchant.port}`; await page.goto(`${merchantOrigin}/`, { waitUntil: "domcontentloaded" }); - await page.setContent( - `merchant`, - { waitUntil: "domcontentloaded" }, - ); // The driver's Playwright session is a second CDP client on this browser. expect( await page.evaluate( @@ -241,10 +262,26 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { (globalThis as { document?: { title: string } }).document?.title, ), ).toBe("merchant"); - const frame = page + let frame = page .frames() .find((candidate) => candidate.url().includes(String(card.port))); - if (!frame) throw new Error("cross-origin card frame was not attached"); + 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", ); @@ -277,8 +314,9 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { stubPaymentRelay(async () => ({ ok: true, status: 201, - headers: { "content-type": "application/json" }, + headers: [["content-type", "application/json"]], body: '{"id":"sess_test"}', + bodyEncoding: "utf8", })), { ping: PING, other: UNRELATED, mismatch: MISMATCH, card: CARD }, ); @@ -291,15 +329,22 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ + version: 1, transactionId: "tx_integration", injectionRef: "inj_integration", merchantOrigin, + frameOrigin: `http://127.0.0.1:${card.port}`, method: "POST", url: `http://127.0.0.1:${card.port}/sessions`, body: CARD, + bodyEncoding: "utf8", }); + expect(calls[0].idempotencyKey).toMatch(/:[0-9a-f]{16}$/); expect(calls[0].merchantOrigin).not.toContain(String(card.port)); expect(header(calls[0].headers, "content-type")).toBe("application/json"); + expect(events.filter((event) => event.type === "paused")).toEqual([ + expect.objectContaining({ type: "paused", targetType: "iframe" }), + ]); expect(result).toEqual({ ping: { status: 200, text: JSON.stringify({ ok: true, via: "server" }) }, @@ -354,14 +399,119 @@ describeWithChrome("attachInterceptor on headless Chromium", () => { 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 { + ok: true as const, + status: 201, + headers: [["content-type", "text/plain"] as RelayHeader], + body: "ok", + bodyEncoding: "utf8" as const, + }; + }, + }, + placeholder: PLACEHOLDER, + transactionId: "tx_integration", + 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({ error: "TypeError" }); + 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(1); + expect(calls[0].body).toContain("4242 4242 4242 4242"); + expect(events.filter((event) => event.type === "paused")).toHaveLength(1); + expect(events.some((event) => event.type === "failed")).toBe(true); + } finally { + await page.close().catch(() => {}); + await handle.detach(); + void browser; + } + }, 60_000); }); -function header( - headers: Record, - name: string, -): string | undefined { - const key = Object.keys(headers).find( - (candidate) => candidate.toLowerCase() === name, - ); - return key ? headers[key] : undefined; +function header(headers: RelayHeader[], name: string): string | undefined { + return headers.find(([key]) => key.toLowerCase() === name)?.[1]; } 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..2cac7c46b --- /dev/null +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -0,0 +1,709 @@ +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 { stubPaymentRelay, type RelayCallOptions } from "./relay"; +import type { HeldPlaceholder, RelayRequest, RelayResponse } from "./index"; + +const HELD: HeldPlaceholder = { + number: "4242424242424242", + expMonth: 12, + expYear: 2028, + cvc: "123", + 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 { + failFetch?: boolean; + failFulfill?: boolean; + postData?: string; + postDataError?: boolean; +} + +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[]; + emit: (event: Record) => void; + close: () => Promise; +}> { + const commands: Command[] = []; + let socket: Duplex | undefined; + 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 === "Fetch.enable" && options.failFetch) { + reply(message.id, undefined, { message: "Fetch is unsupported" }); + return; + } + if (message.method === "Fetch.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; + } + 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) 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, + 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 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 => ({ + ok: true, + status: 201, + headers: [["content-type", "application/json"]], + body: '{"id":"sess"}', + bodyEncoding: "utf8", +}); + +describe("interceptor CDP decisions", () => { + 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, + transactionId: "tx", + }); + 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, + transactionId: "tx", + }); + 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", + 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.some( + (command) => command.method === "Fetch.getRequestPostData", + ), + ).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].body).toBe(CARD); + expect(calls[0].version).toBe(1); + expect(calls[0].bodyEncoding).toBe("utf8"); + expect(calls[0].idempotencyKey).toMatch(/^req-1:[0-9a-f]{16}$/); + expect(calls[0].headers).toEqual([["content-type", "application/json"]]); + 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, + transactionId: "tx", + }); + 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", + 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("fails 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, + transactionId: "tx", + }); + 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=${HELD.number}`, + method: "POST", + postData: '{"event":"ping"}', + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "failed"), + "failed", + ); + expect(calls).toHaveLength(0); + expect(events[0]).toMatchObject({ + type: "failed", + reason: "placeholder_mismatch", + }); + expect(JSON.stringify(events)).not.toContain(HELD.number); + 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, + transactionId: "tx", + }); + 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].body).toBe(multipart); + 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, + transactionId: "tx", + 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, + transactionId: "tx", + }); + 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, + }); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not fulfill a response that contains a different card number", async () => { + const stub = await startStub(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => ({ + ok: true, + status: 200, + headers: [["content-type", "text/plain"]], + body: "4111111111111111", + bodyEncoding: "utf8", + })), + placeholder: HELD, + transactionId: "tx", + }); + 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 === "failed"), + "failed", + ); + expect(events.map((event) => event.type)).toEqual(["paused", "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("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, + transactionId: "tx", + }); + 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 === "Fetch.getRequestPostData", + ), + ).toBe(false); + await handle.detach(); + } finally { + await stub.close(); + } + }); +}); diff --git a/packages/browser-pay/src/interceptor.test.ts b/packages/browser-pay/src/interceptor.test.ts index 48f9603b5..a32f87ecc 100644 --- a/packages/browser-pay/src/interceptor.test.ts +++ b/packages/browser-pay/src/interceptor.test.ts @@ -1,4 +1,8 @@ -import { canContinueWithoutReading, isSolverTraffic } from "./interceptor"; +import { + canContinueWithoutReading, + isSolverTraffic, + postDataPlan, +} from "./interceptor"; describe("isSolverTraffic", () => { it("matches captcha widgets and the browser solver, not the card host", () => { @@ -82,3 +86,17 @@ describe("canContinueWithoutReading", () => { ).toBe(false); }); }); + +describe("postDataPlan", () => { + it("fetches when an entry has no bytes or the body was not inlined", () => { + expect(postDataPlan({ postData: '{"a":1}' })).toBe("inline"); + expect(postDataPlan({ postDataEntries: [{ bytes: "e30=" }] })).toBe( + "entries", + ); + expect(postDataPlan({ hasPostData: true, postDataEntries: [{}] })).toBe( + "fetch", + ); + expect(postDataPlan({ hasPostData: true })).toBe("fetch"); + expect(postDataPlan({})).toBe("empty"); + }); +}); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 48a0fe8c1..4a7138d00 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -1,5 +1,12 @@ +import crypto from "node:crypto"; import { CdpConnection, type CdpEvent } from "./cdp.js"; -import { matchHeldPlaceholderInBody, type HeldPlaceholder } from "./detect.js"; +import { RELAY_CONTRACT_VERSION, type RelayHeader } from "./contract.js"; +import { + matchHeldPlaceholderInBody, + textContainsForeignCard, + textHoldsPlaceholderNumber, + type HeldPlaceholder, +} from "./detect.js"; import type { PaymentRelay, RelayResponse } from "./relay.js"; import { redact } from "./redact.js"; @@ -19,11 +26,19 @@ export interface AttachOptions { * Omit it to pause every page and iframe. */ pauseOnlyUrls?: string[]; + /** + * Copy Cookie, Accept-Language, and Sec-Fetch-* from the browser onto the + * relay request. Off by default. Those headers can include HttpOnly cookies + * the relay backend must not log or persist. Shopify's card session needs + * them, so the dev-store script turns this on. + */ + synthesizeBrowserHeaders?: boolean; } export type InterceptorEvent = - | { type: "paused"; url: string; method: string } + | { 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: string }; @@ -151,6 +166,10 @@ function isHandsOffTarget(session: Session): boolean { * response. A mismatch, refusal, timeout, or network error fails the paused * request. The placeholder is never continued: it is a random number in a * real BIN range. + * + * `detach()` fails in-flight paused requests before it disables Fetch, so a + * placeholder mid-relay is not released. If this process crashes or the CDP + * socket drops first, Chrome continues those paused requests on its own. */ export async function attachInterceptor( target: InterceptorTarget, @@ -161,6 +180,8 @@ export async function attachInterceptor( const sessions = new Map(); const seen = new Set(); const listeners = new Set<(event: InterceptorEvent) => void>(); + const inflight = new Map(); + const relayAborts = new Set(); let closed = false; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -200,10 +221,20 @@ export async function attachInterceptor( return; } // Fetch has to be on before the target runs, or the card iframe can post - // before we are listening. waitForDebuggerOnStart holds it until then. - // Network has to be enabled before Network.getCookies will return the - // jar the card session needs. - await armFetch(connection, session); + // before we are listening. Network has to be enabled before + // Network.getCookies will return the jar the card session needs. + const armed = await armFetch(connection, session); + if (!armed) { + emit({ + type: "failed", + url: redact(session.url || session.targetId), + reason: "intercept_unavailable", + }); + await connection + .send("Target.closeTarget", { targetId: session.targetId }) + .catch(() => {}); + return; + } await releaseTarget(connection, sessionId, waitingForDebugger); }; @@ -240,6 +271,23 @@ export async function attachInterceptor( url: info.url ?? "", ...(event.sessionId ? { parentSessionId: event.sessionId } : {}), }); + // Browser-level auto-attach covers pages. Child iframes attach only when + // the page (or parent frame) session asks for them. A second session for + // the same target is detached in onAttached. + if (info.type === "page" || info.type === "iframe") { + void connection + .send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ type: "iframe" }], + }, + sessionId, + ) + .catch(() => {}); + } void enableSession(sessionId, params.waitingForDebugger === true); }; @@ -248,19 +296,31 @@ export async function attachInterceptor( const params = event.params as { requestId?: string; resourceType?: string; - request?: { - url?: string; - method?: string; - headers?: unknown; - postData?: string; - hasPostData?: boolean; - postDataEntries?: { bytes?: string }[]; - }; + request?: PausedRequest; }; if (!sessionId || !params.requestId || !params.request) return; const url = params.request.url ?? ""; const method = params.request.method ?? "GET"; const hasBody = requestHasBody(params.request); + const key = inflightKey(sessionId, params.requestId); + if (inflight.has(key)) return; + const entry: Inflight = { + key, + sessionId, + requestId: params.requestId, + settled: false, + }; + inflight.set(key, entry); + const ctx: PauseContext = { + connection, + sessions, + options, + timeoutMs, + emit, + inflight, + relayAborts, + isClosed: () => closed, + }; if ( canContinueWithoutReading( method, @@ -269,49 +329,46 @@ export async function attachInterceptor( options.placeholder.number, ) ) { - void continueRequest(connection, sessionId, params.requestId); + entry.continuing = continueRequest( + connection, + sessionId, + params.requestId, + ).finally(() => { + entry.settled = true; + inflight.delete(key); + }); return; } if ( !hasBody && (method.toUpperCase() === "GET" || method.toUpperCase() === "HEAD") ) { - void failRequest(connection, sessionId, params.requestId); - emit({ - type: "failed", - url: redact(url), - reason: "placeholder_mismatch", + void finish(ctx, entry, "fail").then(() => { + emit({ + type: "failed", + url: redact(url), + reason: "placeholder_mismatch", + }); }); return; } + if (closed) { + void settleClosed(ctx, entry, url, params.request); + return; + } const session = sessions.get(sessionId); if (isSolverTraffic(url) || (session ? isHandsOffTarget(session) : false)) { - void continueSolverRequest( - connection, - options, - emit, - sessionId, - params.requestId, - url, - params.request, - ); + void continueSolverRequest(ctx, entry, url, params.request); return; } - void handlePaused( - connection, - sessions, - options, - timeoutMs, - emit, - () => closed, - sessionId, - params.requestId, - params.resourceType ?? "", - params.request, - ); + void handlePaused(ctx, entry, params.resourceType ?? "", params.request); }; connection.onEvent((event) => { + if (event.method === "Fetch.requestPaused") { + onPaused(event); + return; + } if (closed) return; if (event.method === "Target.attachedToTarget") onAttached(event); else if (event.method === "Target.detachedFromTarget") { @@ -338,10 +395,20 @@ export async function attachInterceptor( continue; } if (!session.fetchEnabled) { - void armFetch(connection, session); + void armFetch(connection, session).then((armed) => { + if (armed || closed) return; + emit({ + type: "failed", + url: redact(session.url || session.targetId), + reason: "intercept_unavailable", + }); + void connection + .send("Target.closeTarget", { targetId: session.targetId }) + .catch(() => {}); + }); } } - } else if (event.method === "Fetch.requestPaused") onPaused(event); + } }); await connection.send("Target.setAutoAttach", { @@ -362,9 +429,14 @@ export async function attachInterceptor( async detach() { if (closed) return; closed = true; + for (const controller of relayAborts) controller.abort(); + await drainInflight(connection, inflight); for (const sessionId of sessions.keys()) { await 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 drainInflight(connection, inflight); await connection .send("Target.setAutoAttach", { autoAttach: false, @@ -377,143 +449,332 @@ export async function attachInterceptor( }; } -async function handlePaused( +interface Inflight { + key: string; + sessionId: string; + requestId: string; + settled: boolean; + continuing?: Promise; +} + +interface PausedRequest { + url?: string; + method?: string; + headers?: unknown; + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; +} + +interface PauseContext { + connection: CdpConnection; + sessions: Map; + options: AttachOptions; + timeoutMs: number; + emit: (event: InterceptorEvent) => void; + inflight: Map; + relayAborts: Set; + isClosed: () => boolean; +} + +function inflightKey(sessionId: string, requestId: string): string { + return `${sessionId}\n${requestId}`; +} + +async function drainInflight( connection: CdpConnection, - sessions: Map, - options: AttachOptions, - timeoutMs: number, - emit: (event: InterceptorEvent) => void, - isClosed: () => boolean, - sessionId: string, - requestId: string, + inflight: Map, +): Promise { + const entries = [...inflight.values()]; + await Promise.all( + entries.map(async (entry) => { + if (entry.settled) return; + if (entry.continuing) { + await entry.continuing; + return; + } + entry.settled = true; + inflight.delete(entry.key); + await failRequest(connection, entry.sessionId, entry.requestId); + }), + ); +} + +async function finish( + ctx: PauseContext, + entry: Inflight, + action: "fail" | "continue", +): Promise { + if (entry.settled) return false; + entry.settled = true; + ctx.inflight.delete(entry.key); + if (action === "continue") { + await continueRequest(ctx.connection, entry.sessionId, entry.requestId); + } else { + await failRequest(ctx.connection, entry.sessionId, entry.requestId); + } + return true; +} + +async function finishContinue( + ctx: PauseContext, + entry: Inflight, +): Promise { + if (entry.settled) return; + const promise = continueRequest( + ctx.connection, + entry.sessionId, + entry.requestId, + ).finally(() => { + entry.settled = true; + ctx.inflight.delete(entry.key); + }); + entry.continuing = promise; + await promise; +} + +async function handlePaused( + ctx: PauseContext, + entry: Inflight, resourceType: string, - request: { - url?: string; - method?: string; - headers?: unknown; - postData?: string; - hasPostData?: boolean; - postDataEntries?: { bytes?: string }[]; - }, + request: PausedRequest, ): Promise { const url = request.url ?? ""; const method = request.method ?? "GET"; const safeUrl = redact(url); + const targetType = ctx.sessions.get(entry.sessionId)?.type ?? ""; try { - let body = ""; - try { - body = await readBody(connection, sessionId, requestId, request); - } catch { - // A body we cannot read might still be the placeholder. Fail it. - if (request.hasPostData) { - await failRequest(connection, sessionId, requestId); - emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); + const read = await readBodyResult( + ctx.connection, + entry.sessionId, + entry.requestId, + request, + ); + if (read.unreadable) { + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); + return; + } + const urlHolds = textHoldsPlaceholderNumber( + url, + ctx.options.placeholder.number, + ); + const match = matchHeldPlaceholderInBody( + read.body, + ctx.options.placeholder, + ); + if (match.kind === "not_held" && !urlHolds) { + if (ctx.isClosed()) { + await finishContinue(ctx, entry); return; } - } - const match = matchHeldPlaceholderInBody(body, options.placeholder); - if (match.kind === "not_held") { - await continueRequest(connection, sessionId, requestId); + await finishContinue(ctx, entry); return; } - if (match.kind === "mismatch") { - // The placeholder number is here with someone else's expiry or CVC. - // Continuing it would still put that number on the network. - await failRequest(connection, sessionId, requestId); - emit({ + if (match.kind !== "match" || ctx.isClosed()) { + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "placeholder_mismatch", }); return; } - if (isClosed()) { - await failRequest(connection, sessionId, requestId); - return; - } - // A later injection profile (W7 U3) can reject a matched destination - // here, before the relay call, and must fail the request rather than - // continue it. M0 has no URL filter. - emit({ type: "paused", url: safeUrl, method }); + ctx.emit({ type: "paused", url: safeUrl, method, targetType }); const merchantOrigin = await readMerchantOrigin( - connection, - sessions, - sessionId, + ctx.connection, + ctx.sessions, + entry.sessionId, ); - if (!merchantOrigin) { - await failRequest(connection, sessionId, requestId); - emit({ type: "failed", url: safeUrl, reason: "merchant_origin" }); + if (!merchantOrigin || ctx.isClosed()) { + await finish(ctx, entry, "fail"); + ctx.emit({ + type: "failed", + url: safeUrl, + reason: merchantOrigin ? "detached" : "merchant_origin", + }); return; } - const headers = await completeHeaders( - connection, - sessions, - sessionId, - url, - resourceType, - request.headers, - ); + const headers = ctx.options.synthesizeBrowserHeaders + ? await completeHeaders( + ctx.connection, + ctx.sessions, + entry.sessionId, + url, + resourceType, + request.headers, + ) + : headerList(request.headers); + const frameOrigin = readFrameOrigin(ctx.sessions, entry.sessionId, url); + const controller = new AbortController(); + ctx.relayAborts.add(controller); + const timer = setTimeout(() => controller.abort(), ctx.timeoutMs); let response: RelayResponse; try { - response = await withTimeout( - options.relay.relay({ - transactionId: options.transactionId, + response = await ctx.options.relay.relay( + { + version: RELAY_CONTRACT_VERSION, + transactionId: ctx.options.transactionId, injectionRef: match.injectionRef, merchantOrigin, + ...(frameOrigin ? { frameOrigin } : {}), url, method, headers, - body, - }), - timeoutMs, + body: read.body, + bodyEncoding: "utf8", + idempotencyKey: `${entry.requestId}:${crypto.randomBytes(8).toString("hex")}`, + }, + { signal: controller.signal }, ); } catch (error) { - await failRequest(connection, sessionId, requestId); - emit({ + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, - reason: failureReason(error), + reason: ctx.isClosed() ? "detached" : failureReason(error), }); return; + } finally { + clearTimeout(timer); + ctx.relayAborts.delete(controller); } if (!response.ok) { - await failRequest(connection, sessionId, requestId); - emit({ type: "refused", url: safeUrl, reason: response.reason }); + await finish(ctx, entry, "fail"); + ctx.emit({ type: "refused", url: safeUrl, reason: response.reason }); + return; + } + const responseText = + response.bodyEncoding === "base64" + ? Buffer.from(response.body, "base64").toString("utf8") + : response.body; + if (textContainsForeignCard(responseText, ctx.options.placeholder.number)) { + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "response_credential" }); + return; + } + let fulfilled = false; + try { + if (!entry.settled) { + await fulfillRequest( + ctx.connection, + entry.sessionId, + entry.requestId, + response, + ); + fulfilled = true; + } + } catch { + fulfilled = false; + } + if (!entry.settled) { + entry.settled = true; + ctx.inflight.delete(entry.key); + } + ctx.emit( + fulfilled + ? { type: "relayed", url: safeUrl, status: response.status } + : { + type: "relayed_unfulfilled", + url: safeUrl, + status: response.status, + }, + ); + } catch { + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "network" }); + } +} + +async function settleClosed( + ctx: PauseContext, + entry: Inflight, + url: string, + request: PausedRequest, +): Promise { + const safeUrl = redact(url); + try { + let holds = textHoldsPlaceholderNumber(url, ctx.options.placeholder.number); + let unreadable = false; + if (!holds && requestHasBody(request)) { + const read = await readBodyResult( + ctx.connection, + entry.sessionId, + entry.requestId, + request, + ); + unreadable = read.unreadable; + holds = + unreadable || + matchHeldPlaceholderInBody(read.body, ctx.options.placeholder).kind !== + "not_held"; + } + if (holds) { + await finish(ctx, entry, "fail"); + ctx.emit({ + type: "failed", + url: safeUrl, + reason: unreadable ? "unreadable_body" : "placeholder_mismatch", + }); return; } - await fulfillRequest(connection, sessionId, requestId, response); - emit({ type: "relayed", url: safeUrl, status: response.status }); + await finishContinue(ctx, entry); } catch { - await failRequest(connection, sessionId, requestId); - emit({ type: "failed", url: safeUrl, reason: "network" }); + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); } } -async function readBody( - connection: CdpConnection, - sessionId: string, - requestId: string, - request: { - postData?: string; - hasPostData?: boolean; - postDataEntries?: { bytes?: string }[]; - }, -): Promise { +export type PostDataPlan = "inline" | "entries" | "fetch" | "empty"; + +/** How a paused request's body should be read. Missing entry bytes are fetched. */ +export function postDataPlan(request: { + postData?: string; + hasPostData?: boolean; + postDataEntries?: { bytes?: string }[]; +}): PostDataPlan { if (typeof request.postData === "string" && request.postData.length > 0) { - return request.postData; + return "inline"; } if (request.postDataEntries && request.postDataEntries.length > 0) { - return request.postDataEntries + 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 readBodyResult( + connection: CdpConnection, + sessionId: string, + requestId: string, + request: PausedRequest, +): Promise<{ body: string; unreadable: boolean }> { + const plan = postDataPlan(request); + if (plan === "empty") return { body: "", unreadable: false }; + if (plan === "inline") + return { body: request.postData ?? "", unreadable: false }; + if (plan === "entries") { + const body = (request.postDataEntries ?? []) .map((entry) => Buffer.from(entry.bytes ?? "", "base64").toString("utf8")) .join(""); + return { body, unreadable: false }; + } + try { + const result = await connection.send<{ postData?: string }>( + "Fetch.getRequestPostData", + { requestId }, + sessionId, + ); + if (typeof result.postData !== "string" || result.postData.length === 0) { + return { body: "", unreadable: true }; + } + return { body: result.postData, unreadable: false }; + } catch { + return { body: "", unreadable: true }; } - if (!request.hasPostData) return ""; - const result = await connection.send<{ postData?: string }>( - "Fetch.getRequestPostData", - { requestId }, - sessionId, - ); - return result.postData ?? ""; } async function readMerchantOrigin( @@ -522,19 +783,43 @@ async function readMerchantOrigin( sessionId: string, ): Promise { const pageSessionId = topPageSession(sessions, sessionId); - if (!pageSessionId) return undefined; + if (pageSessionId) { + const fromPage = await originFromSession( + connection, + sessions, + pageSessionId, + ); + if (fromPage) return fromPage; + } + const ancestors = await connection + .send<{ result?: { value?: unknown } }>( + "Runtime.evaluate", + { + expression: + "location.ancestorOrigins[location.ancestorOrigins.length - 1]", + returnByValue: true, + }, + sessionId, + ) + .catch(() => null); + const value = ancestors?.result?.value; + if (typeof value === "string" && /^https?:\/\//.test(value)) return value; + return undefined; +} + +async function originFromSession( + connection: CdpConnection, + sessions: Map, + sessionId: string, +): Promise { const evaluated = await connection .send<{ result?: { value?: unknown }; - }>( - "Runtime.evaluate", - { expression: "location.origin", returnByValue: true }, - pageSessionId, - ) + }>("Runtime.evaluate", { expression: "location.origin", returnByValue: true }, sessionId) .catch(() => null); const value = evaluated?.result?.value; if (typeof value === "string" && /^https?:\/\//.test(value)) return value; - const url = sessions.get(pageSessionId)?.url; + const url = sessions.get(sessionId)?.url; if (!url) return undefined; try { const origin = new URL(url).origin; @@ -544,6 +829,20 @@ async function readMerchantOrigin( } } +function readFrameOrigin( + sessions: Map, + sessionId: string, + requestUrl: string, +): string | undefined { + const raw = sessions.get(sessionId)?.url || requestUrl; + try { + const origin = new URL(raw).origin; + return /^https?:\/\//.test(origin) ? origin : undefined; + } catch { + return undefined; + } +} + function topPageSession( sessions: Map, sessionId: string, @@ -553,18 +852,23 @@ function topPageSession( while (current && !seen.has(current)) { seen.add(current); const session = sessions.get(current); - if (!session) return undefined; + if (!session) break; if (session.type === "page") return current; - if (!session.parentSessionId) return undefined; + 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; } /** * At the Fetch Request stage Chrome has not yet added the headers its network * stack appends (Cookie, Sec-Fetch-*, Accept-Language). Shopify's card session - * needs those. Never override a header the request already has. + * needs those. Never override a header the request already has. Only called + * when `synthesizeBrowserHeaders` is set. */ async function completeHeaders( connection: CdpConnection, @@ -573,10 +877,10 @@ async function completeHeaders( requestUrl: string, resourceType: string, rawHeaders: unknown, -): Promise> { - const headers = headerRecord(rawHeaders); +): Promise { + const headers = headerList(rawHeaders); const has = (name: string) => - Object.keys(headers).some((key) => key.toLowerCase() === name); + headers.some(([key]) => key.toLowerCase() === name); const frameUrl = sessions.get(sessionId)?.url || requestUrl; try { const target = new URL(requestUrl); @@ -584,38 +888,39 @@ async function completeHeaders( if (!has("sec-fetch-site")) { const site = (value: URL) => value.hostname.split(".").slice(-2).join("."); - headers["Sec-Fetch-Site"] = + headers.push([ + "Sec-Fetch-Site", target.origin === frame.origin ? "same-origin" : site(target) === site(frame) && target.protocol === frame.protocol ? "same-site" - : "cross-site"; + : "cross-site", + ]); } if (!has("sec-fetch-dest") && /^(Fetch|XHR)$/.test(resourceType)) { - headers["Sec-Fetch-Dest"] = "empty"; + headers.push(["Sec-Fetch-Dest", "empty"]); } if (!has("accept-language")) { const languages = await connection .send<{ result?: { value?: unknown }; - }>( - "Runtime.evaluate", - { expression: "navigator.languages", returnByValue: true }, - sessionId, - ) + }>("Runtime.evaluate", { expression: "navigator.languages", returnByValue: true }, sessionId) .catch(() => null); const list = languages?.result?.value; if ( Array.isArray(list) && list.every((entry) => typeof entry === "string") ) { - headers["Accept-Language"] = list - .map((language, index) => - index === 0 - ? language - : `${language};q=${Math.max(0.1, 1 - index / 10).toFixed(1)}`, - ) - .join(","); + headers.push([ + "Accept-Language", + list + .map((language, index) => + index === 0 + ? language + : `${language};q=${Math.max(0.1, 1 - index / 10).toFixed(1)}`, + ) + .join(","), + ]); } } if (!has("cookie")) { @@ -625,9 +930,12 @@ async function completeHeaders( }>("Network.getCookies", { urls: [requestUrl] }, sessionId) .catch(() => null); if (cookies?.cookies?.length) { - headers["Cookie"] = cookies.cookies - .map((cookie) => `${cookie.name}=${cookie.value}`) - .join("; "); + headers.push([ + "Cookie", + cookies.cookies + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "), + ]); } } } catch { @@ -636,34 +944,39 @@ async function completeHeaders( return headers; } -function headerRecord(headers: unknown): Record { - if (!headers) return {}; +function headerList(headers: unknown): RelayHeader[] { + if (!headers) return []; if (Array.isArray(headers)) { - const out: Record = {}; + const out: RelayHeader[] = []; for (const entry of headers) { - if (entry && typeof entry.name === "string") { - out[entry.name] = String(entry.value ?? ""); + if (Array.isArray(entry) && entry.length === 2) { + out.push([String(entry[0]), String(entry[1])]); + continue; + } + if ( + entry && + typeof entry === "object" && + typeof (entry as { name?: unknown }).name === "string" + ) { + const named = entry as { name: string; value?: unknown }; + out.push([named.name, String(named.value ?? "")]); } } return out; } if (typeof headers === "object") { - const out: Record = {}; + const out: RelayHeader[] = []; for (const [key, value] of Object.entries( headers as Record, )) { - if (typeof value === "string") out[key] = value; + if (typeof value === "string") out.push([key, value]); } return out; } - return {}; + return []; } -function requestHasBody(request: { - postData?: string; - hasPostData?: boolean; - postDataEntries?: { bytes?: string }[]; -}): boolean { +function requestHasBody(request: PausedRequest): boolean { if (request.hasPostData) return true; if (typeof request.postData === "string" && request.postData.length > 0) { return true; @@ -674,19 +987,23 @@ function requestHasBody(request: { async function armFetch( connection: CdpConnection, session: Session | undefined, -): Promise { - if (!session || session.fetchEnabled || isHandsOffTarget(session)) return; +): Promise { + if (!session || isHandsOffTarget(session)) return false; + if (session.fetchEnabled) return true; await connection .send("Network.enable", {}, session.sessionId) .catch(() => {}); - await connection - .send( + try { + await connection.send( "Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, session.sessionId, - ) - .catch(() => {}); + ); + } catch { + return false; + } session.fetchEnabled = true; + return true; } async function releaseTarget( @@ -701,27 +1018,35 @@ async function releaseTarget( } async function continueSolverRequest( - connection: CdpConnection, - options: AttachOptions, - emit: (event: InterceptorEvent) => void, - sessionId: string, - requestId: string, + ctx: PauseContext, + entry: Inflight, url: string, - request: { - postData?: string; - hasPostData?: boolean; - postDataEntries?: { bytes?: string }[]; - }, + request: PausedRequest, ): Promise { - if (requestHasBody(request)) { + const safeUrl = redact(url); + if ( + textHoldsPlaceholderNumber(url, ctx.options.placeholder.number) || + requestHasBody(request) + ) { try { - const body = await readBody(connection, sessionId, requestId, request); - const match = matchHeldPlaceholderInBody(body, options.placeholder); - if (match.kind !== "not_held") { - await failRequest(connection, sessionId, requestId); - emit({ + const read = requestHasBody(request) + ? await readBodyResult( + ctx.connection, + entry.sessionId, + entry.requestId, + request, + ) + : { body: "", unreadable: false }; + const held = + textHoldsPlaceholderNumber(url, ctx.options.placeholder.number) || + (!read.unreadable && + matchHeldPlaceholderInBody(read.body, ctx.options.placeholder) + .kind !== "not_held"); + if (held) { + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", - url: redact(url), + url: safeUrl, reason: "placeholder_mismatch", }); return; @@ -730,7 +1055,7 @@ async function continueSolverRequest( // An unreadable captcha body is not the card post. Continue it. } } - await continueRequest(connection, sessionId, requestId); + await finishContinue(ctx, entry); } async function continueRequest( @@ -765,54 +1090,35 @@ async function fulfillRequest( requestId: string, response: Extract, ): Promise { - const responseHeaders = Object.entries(response.headers).flatMap( - ([name, value]) => { - if (RESPONSE_HOP.has(name.toLowerCase())) return []; - if (name.toLowerCase() === "set-cookie") { - return value - .split("\n") - .filter((part) => part.length > 0) - .map((part) => ({ name, value: part })); - } - return [{ name, value }]; - }, - ); + const responseHeaders = response.headers.flatMap(([name, value]) => { + if (RESPONSE_HOP.has(name.toLowerCase())) return []; + return [{ name, value }]; + }); + const body = + response.bodyEncoding === "base64" + ? response.body + : Buffer.from(response.body, "utf8").toString("base64"); await connection.send( "Fetch.fulfillRequest", { requestId, responseCode: response.status, responseHeaders, - body: Buffer.from(response.body, "utf8").toString("base64"), + body, }, sessionId, ); } -async function withTimeout( - promise: Promise, - timeoutMs: number, -): Promise { - let timer: NodeJS.Timeout; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - const error = new Error("Payment relay timed out"); - error.name = "TimeoutError"; - reject(error); - }, timeoutMs); - }); - try { - return await Promise.race([promise, timeout]); - } finally { - clearTimeout(timer!); - } -} - function failureReason(error: unknown): string { + if (!error || typeof error !== "object") return "network"; + const name = "name" in error ? String(error.name) : ""; + const message = "message" in error ? String(error.message) : ""; if ( - error instanceof Error && - (error.name === "TimeoutError" || - error.message === "Payment relay timed out") + name === "TimeoutError" || + name === "AbortError" || + message === "Payment relay timed out" || + /timeout/i.test(message) ) { return "timeout"; } diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 539013e75..cac47c131 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -30,19 +30,15 @@ describe("T7 fixtures", () => { expect(request.merchantOrigin).toBe("https://shop.example"); expect(Object.keys(response).sort()).toEqual([ "body", + "bodyEncoding", "headers", "ok", "status", ]); - expect(response).toEqual({ - ok: true, - status: 200, - headers: { - "content-type": "application/json", - "access-control-allow-origin": "https://checkout.pci.shopifyinc.com", - }, - body: '{"id":"west-fixture-session"}', - }); + expect(response.headers.filter(([name]) => name === "set-cookie")).toEqual([ + ["set-cookie", "session=a"], + ["set-cookie", "session=b"], + ]); expect( matchHeldPlaceholderInBody(request.body, { number: "4242424242424242", @@ -105,6 +101,7 @@ describe("httpPaymentRelay", () => { "/v1/transactions/tx_fixture_shopify/payment/relay", ); expect(seen.headers?.["x-api-key"]).toBe("test-key"); + expect(seen.headers?.["idempotency-key"]).toBe(REQUEST.idempotencyKey); expect(JSON.parse(seen.body ?? "")).toEqual(REQUEST); } finally { await new Promise((resolve) => server.close(resolve)); @@ -140,6 +137,35 @@ describe("httpPaymentRelay", () => { } }); + it("aborts when the caller signal fires and does not keep its own timer", 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", + timeoutMs: 30_000, + }); + 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("times out without including a response body", async () => { const server = await listen(() => { // Never respond. diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index 6749ed8f4..bbf47c86e 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -6,9 +6,17 @@ import { export type { RelayRequest, RelayResponse }; +export interface RelayCallOptions { + /** Aborts the relay. The interceptor aborts this when its own timeout fires or it detaches. */ + signal?: AbortSignal; +} + /** Pluggable relay. The HTTP client and the in-memory stub both implement this. */ export interface PaymentRelay { - relay(request: RelayRequest): Promise; + relay( + request: RelayRequest, + options?: RelayCallOptions, + ): Promise; } export interface HttpRelayOptions { @@ -38,8 +46,11 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const fetchImpl = options.fetchImpl ?? fetch; return { - async relay(request) { + async relay(request, options) { const url = `${baseUrl}/v1/transactions/${encodeURIComponent(request.transactionId)}/payment/relay`; + // The caller owns the deadline when it passes a signal. Otherwise this + // client is the only timeout. + const signal = options?.signal ?? AbortSignal.timeout(timeoutMs); let response: Response; try { response = await fetchImpl(url, { @@ -49,12 +60,15 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { accept: "application/json", "content-type": "application/json", "x-api-key": apiKey, + "idempotency-key": request.idempotencyKey, }, body: JSON.stringify(request), - signal: AbortSignal.timeout(timeoutMs), + signal, }); } catch (error) { - if (isTimeout(error)) throw new Error("Payment relay timed out"); + if (signal.aborted || isTimeout(error)) { + throw new Error("Payment relay timed out"); + } throw new Error("Payment relay failed"); } // A redirect would be the provider (or an attacker) pointing the real @@ -83,11 +97,14 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { /** In-memory relay for tests and local demos. */ export function stubPaymentRelay( - handler: (request: RelayRequest) => RelayResponse | Promise, + handler: ( + request: RelayRequest, + options?: RelayCallOptions, + ) => RelayResponse | Promise, ): PaymentRelay { return { - relay(request) { - return Promise.resolve(handler(request)); + relay(request, options) { + return Promise.resolve(handler(request, options)); }, }; } diff --git a/packages/browser-pay/src/testing.ts b/packages/browser-pay/src/testing.ts new file mode 100644 index 000000000..be927e09a --- /dev/null +++ b/packages/browser-pay/src/testing.ts @@ -0,0 +1,8 @@ +/** + * Test fixtures. Import from `@sapiom/browser-pay/testing`, not the package root. + * The Shopify fixture contains a published test PAN. + */ +export { + relayRefusalFixtures, + shopifyCardSessionRoundTrip, +} from "./fixtures.js"; From fe7a9873550aa1d19b4a91d20aabdb7652c83a07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 20:40:46 +0000 Subject: [PATCH 17/26] fix(browser-pay): relay the held placeholder without continuing it Match a Luhn-valid placeholder anywhere in the raw body, key Shopify retries by sha256 of method, URL, and body, and fill the headers Chrome adds after Fetch. Payment iframes are paused until Fetch is enabled. A refusal or a failed fulfill fails the paused request. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-interceptor.md | 2 +- packages/browser-pay/CHANGELOG.md | 2 +- packages/browser-pay/README.md | 127 +- packages/browser-pay/examples/attach.ts | 25 +- .../browser-pay/examples/relays/bogus.test.ts | 115 -- packages/browser-pay/examples/relays/bogus.ts | 53 - .../examples/relays/bt-proxy.test.ts | 264 ---- .../browser-pay/examples/relays/bt-proxy.ts | 318 ----- .../examples/relays/replace-card.ts | 247 ---- .../relays/shopify-payments-test-card.ts | 10 - packages/browser-pay/examples/shopify-e2e.ts | 1089 ----------------- packages/browser-pay/package.json | 1 + packages/browser-pay/src/cdp.ts | 64 +- packages/browser-pay/src/contract.test.ts | 59 +- packages/browser-pay/src/contract.ts | 180 +-- packages/browser-pay/src/detect.test.ts | 222 +--- packages/browser-pay/src/detect.ts | 469 +------ packages/browser-pay/src/fixtures.ts | 71 +- .../browser-pay/src/fixtures/refusals.json | 21 +- .../src/fixtures/shopify-card-session.json | 5 +- packages/browser-pay/src/headers.test.ts | 42 + packages/browser-pay/src/headers.ts | 65 + packages/browser-pay/src/index.ts | 21 +- .../src/interceptor.integration.test.ts | 35 +- .../src/interceptor.protocol.test.ts | 262 +++- packages/browser-pay/src/interceptor.ts | 696 +++++++---- packages/browser-pay/src/logger.ts | 20 + packages/browser-pay/src/relay.test.ts | 25 +- packages/browser-pay/src/relay.ts | 66 +- 29 files changed, 1405 insertions(+), 3171 deletions(-) delete mode 100644 packages/browser-pay/examples/relays/bogus.test.ts delete mode 100644 packages/browser-pay/examples/relays/bogus.ts delete mode 100644 packages/browser-pay/examples/relays/bt-proxy.test.ts delete mode 100644 packages/browser-pay/examples/relays/bt-proxy.ts delete mode 100644 packages/browser-pay/examples/relays/replace-card.ts delete mode 100644 packages/browser-pay/examples/relays/shopify-payments-test-card.ts delete mode 100644 packages/browser-pay/examples/shopify-e2e.ts create mode 100644 packages/browser-pay/src/headers.test.ts create mode 100644 packages/browser-pay/src/headers.ts create mode 100644 packages/browser-pay/src/logger.ts diff --git a/.changeset/browser-pay-interceptor.md b/.changeset/browser-pay-interceptor.md index a6cae9120..2e2c030f9 100644 --- a/.changeset/browser-pay-interceptor.md +++ b/.changeset/browser-pay-interceptor.md @@ -2,4 +2,4 @@ "@sapiom/browser-pay": minor --- -Add `@sapiom/browser-pay`, the browser-side payment interceptor. It attaches over CDP, pauses a request whose body or URL carries the held placeholder card, and relays that request without ever continuing a placeholder to the payment provider. The relay contract is version 1: header pairs, body encoding, and an idempotency key. +Add `@sapiom/browser-pay`. It pauses a held placeholder card over CDP and posts the T7 body to `POST /v1/transactions/{id}/payment/relay`. Matching is a Luhn gate plus the exact number anywhere in the raw body. The idempotency key is sha256 of method, URL, and body. diff --git a/packages/browser-pay/CHANGELOG.md b/packages/browser-pay/CHANGELOG.md index 6f2b5178e..cc6ea7ee1 100644 --- a/packages/browser-pay/CHANGELOG.md +++ b/packages/browser-pay/CHANGELOG.md @@ -2,4 +2,4 @@ ## 0.0.0 -Initial package. The first published version is cut by Changesets. +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/README.md b/packages/browser-pay/README.md index 645189928..6df4be5c0 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -1,88 +1,89 @@ # @sapiom/browser-pay -Browser-side payment interceptor for a card checkout in the agent's own Chromium. +Browser-side payment interceptor. The agent types a governance placeholder into checkout. This package pauses that request over CDP and hands it to the 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 or a relay error fails the paused request. + +## What David implements + +`POST /v1/transactions/{transactionId}/payment/relay` with `x-api-key` and `idempotency-key`. + +Request JSON: + +```json +{ + "transactionId": "tx_…", + "injectionRef": "inj_…", + "merchantOrigin": "https://shop.example", + "url": "https://checkout.pci.shopifyinc.com/sessions", + "method": "POST", + "headers": [["content-type", "application/json"], ["cookie", "…"]], + "body": "", + "idempotencyKey": "" +} +``` + +`merchantOrigin` is the top-level page origin. `headers` is `[name, value][]` so repeated `Set-Cookie` values on the way back survive. `idempotencyKey` is also the `idempotency-key` header. Shopify's card iframe retries share one key. The body can contain the placeholder number. Do not log or persist `headers` (they can include HttpOnly cookies) or the body. + +Success: -The agent types a placeholder card into the checkout. This module attaches over CDP, pauses the request whose body contains that placeholder, and sends it to the relay. The relay's response fulfills the paused request. The browser never sees a real card value. +```json +{ + "kind": "relayed", + "status": 200, + "headers": [["content-type", "application/json"], ["set-cookie", "a=1"]], + "body": "", + "bodyEncoding": "utf8" +} +``` + +`bodyEncoding` is optional and defaults to `utf8`. Use `base64` when the processor body is not UTF-8 text. -M0 pauses page and iframe requests and inspects the body and the URL. Pass `pauseOnlyUrls` to pause only matching targets, which leaves a checkout page free for a captcha solver. GET and HEAD requests with no body are continued as soon as the URL is checked, so captcha and other subresources are not held for a remote round trip. A URL that contains the held number is failed. Captcha widgets, extension pages, and workers are left running so a session captcha solver is not held on the debugger. One CDP session is kept per target. Injection profiles (which URLs to pause) are deferred. A request without the placeholder is continued. A match is relayed once. A mismatch, refusal, timeout, unreadable body, or network error fails the paused request; the placeholder is never sent on. `detach()` fails paused requests that are still undecided or mid-relay before it turns Fetch off. A process crash or a dropped CDP socket can still let Chrome continue those requests. WebSocket frames and client-side-encrypted card payloads are not visible to this check, and worklet targets are not paused. +Refusal, nothing forwarded: -## Attach +```json +{ "kind": "refused", "reason": "already_relayed" } +``` -`attachInterceptor` opens its own CDP connection. Pass a websocket URL, or a Playwright/Puppeteer browser or page that exposes one. The same function is what a later hosted-attach mode would call. +Reasons this client accepts: `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `expired`, `closed`, `refused`, `unknown`. Any other string is reported as `unknown`. A transport error, a redirect, or an unreadable body fails the paused browser request. It is not fulfilled with the refusal JSON. -An agent platform that already uses Sapiom for the browser creates a session, then attaches. Captcha solving is the browser capability's `captchaSolver` flag. The provider turns that solver off unless `proxy` is also set; `stealth` and `headless: false` match the headful residential session the solver expects. This package does not solve captchas. +The same `idempotencyKey` after a successful forward should return the stored processor response. A new key after the placeholder was consumed is `already_relayed`. + +The page is fulfilled with `status`, `headers`, and `body`. `content-length`, `content-encoding`, and other hop-by-hop headers are dropped because CDP sends the body bytes itself. Return the body the page should read, not a gzip wrapper. If that fulfill fails, the paused request is failed and the placeholder is not continued. The agent event is `relayed_unfulfilled`. + +## What an agent does ```typescript -import { randomUUID } from "node:crypto"; -import { createClient } from "@sapiom/tools"; import { attachInterceptor, httpPaymentRelay } from "@sapiom/browser-pay"; -const sapiom = createClient({ apiKey: process.env.SAPIOM_API_KEY }); -const session = await sapiom.browserAutomation.sessions.createManaged({ - idempotencyKey: randomUUID(), - recording: false, - captchaSolver: true, - proxy: true, - stealth: true, - headless: false, -}); -const handle = await attachInterceptor(session.cdpUrl, { +const handle = await attachInterceptor(cdpUrl, { relay: httpPaymentRelay({ baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", apiKey: process.env.SAPIOM_API_KEY, }), - transactionId: process.env.TRANSACTION_ID!, - placeholder: { - number: process.env.PLACEHOLDER_NUMBER!, - expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), - expYear: Number(process.env.PLACEHOLDER_EXP_YEAR), - cvc: process.env.PLACEHOLDER_CVC!, - injectionRef: process.env.INJECTION_REF!, - }, -}); -handle.on((event) => { - // paused, relayed, relayed_unfulfilled, refused, failed. - // Card numbers are masked if one appears in a URL. -}); -``` - -A platform that already has a CDP URL skips session create: - -```typescript -await attachInterceptor(process.env.CDP_URL!, { - relay, transactionId, - placeholder, + placeholder, // number, expMonth, expYear, cvc, injectionRef + pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], }); ``` -`examples/attach.ts` is that script. The HTTP relay calls `POST /v1/transactions/:id/payment/relay` with the `x-api-key` header. That route may not be deployed yet. Swap a demo relay in without changing the interceptor: both implement `PaymentRelay`. - -`merchantOrigin` on the relay request is the top-level page origin (scheme + host). `frameOrigin` is the frame that sent the request. Pass `synthesizeBrowserHeaders: true` only when the processor needs Cookie and Sec-Fetch-\* copied out of the browser. Those headers can include HttpOnly cookies. The relay backend must not log or persist them. +`pauseOnlyUrls` limits Fetch to the payment host so a captcha solver on the checkout page is not paused. Omit it to pause every page and iframe. -## Relay contract +Matching is a Luhn check on the held number, then an exact search for that digit sequence anywhere in the raw body. Spaces, dashes, and their URL-encoded or JSON-escaped forms count. Expiry and CVC are not compared. A different Luhn-valid number is continued, not relayed. -`RelayRequest` is version 1. `headers` is a list of `[name, value]` pairs so repeated `Set-Cookie` values survive. `bodyEncoding` is `"utf8"` or `"base64"` on the request and the success response. `idempotencyKey` is the CDP request id plus a nonce; the HTTP client also sends it as `idempotency-key`. The relay call takes an `AbortSignal`, and that signal is the only timeout when the interceptor owns it. +Identical in-flight retries (same method, URL, and body) share one relay call. A refusal is logged once. A transport failure drops the key so a later retry can resend. -A refusal is `{ ok: false, reason }` and does not echo the request. Reasons: `wrong_key`, `unknown_placeholder`, `closed_placeholder`, `expired_placeholder`, `off_profile_destination`, `replay`, `merchant_origin`, `changed_total`, `cancelled`. An unrecognized reason becomes `unknown`. Fixtures, including the Shopify card-session round trip, are exported from `@sapiom/browser-pay/testing`. +Missing `Sec-Fetch-Site`, `Sec-Fetch-Dest`, `Accept-Language`, and `Cookie` are filled the way Chrome would, without overriding a header the request already has. `Sec-Fetch-Site` uses the schemeful same-site check with the last two hostname labels as the registrable domain. This package has no public suffix list, so a multi-label suffix such as `co.uk` is classified too broadly. Shopify's card hosts are not in that set. -Placeholder detection matches the backend's placeholder-detection rules (separators, 13–19 digits, Luhn, then expiry and CVC when those fields are present). The held number is also searched for after separators are stripped, so a longer digit run cannot hide it. The rules are duplicated here so a browser bundle does not import the backend. They should move to a shared package. The interceptor does not fulfill a provider response that contains a different Luhn-valid card number. +Child iframes are auto-attached with `waitForDebuggerOnStart`. `Fetch.enable` runs on each child session before it is resumed. An iframe already on the payment host is attached before `attachInterceptor` resolves. A paused request whose CDP session was never attached is failed. -## Shopify dev-store script - -`examples/shopify-e2e.ts` is opt-in and is not part of `pnpm test`. It launches headless Chromium with a CDP port, or `BROWSER=sapiom` to create a Sapiom browser session (`captchaSolver`, `proxy`, `stealth`, and `headless: false`) and attach to the returned CDP URL. A second Playwright connection drives the dev store. The store is `https://1a2uh3-r7.myshopify.com`, variant `67652208394372`, Shopify Payments test mode. - -Demo relays live under `examples/relays/` and are not published: - -- `RELAY=bogus` substitutes Shopify Payments' published test card (Visa ending 4242, expiry 12/2030, any CVC) and forwards. -- `RELAY=bt` vaults that test card in Basis Theory and sends the paused request through the ephemeral proxy with token expressions. `BT_PRIVATE_KEY` may be a private application key or a test-tenant management key. A management key cannot create tokens, so the script creates a private application for the run and deletes it afterwards. The card token is deleted too. `BT_TOKEN_ID` is not required. -- `RELAY=http` uses `httpPaymentRelay` (`SAPIOM_BASE_URL` + `SAPIOM_API_KEY`). - -```bash -cd packages/browser-pay -RELAY=bogus npx tsx examples/shopify-e2e.ts -BROWSER=sapiom RELAY=bt SAPIOM_API_KEY=… BT_PRIVATE_KEY=… npx tsx examples/shopify-e2e.ts -MODE=refuse RELAY=bogus npx tsx examples/shopify-e2e.ts -``` +`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. -`MODE=refuse` fails the card request closed so the placeholder is not forwarded and no order is confirmed. The script clicks pay once. With `BROWSER=sapiom` it waits for the session's captcha solver. It does not bypass a captcha itself, and it stops if one is still showing. +`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 index e7aa25458..10db4d919 100644 --- a/packages/browser-pay/examples/attach.ts +++ b/packages/browser-pay/examples/attach.ts @@ -15,8 +15,18 @@ import { attachInterceptor, httpPaymentRelay, maskCardNumber, + type PayLogger, } from "../src/index.js"; +const log: PayLogger = { + info(message, fields) { + process.stdout.write(`${message} ${JSON.stringify(fields ?? {})}\n`); + }, + warn(message, fields) { + 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"); @@ -51,12 +61,16 @@ async function main(): Promise { cvc: process.env.PLACEHOLDER_CVC ?? "", injectionRef, }, + logger: log, }); - console.log(`interceptor attached; placeholder ${maskCardNumber(number)}`); + log.info("interceptor attached", { placeholder: maskCardNumber(number) }); handle.on((event) => { - if (event.type === "relayed") console.log(`relayed ${event.status}`); - else if (event.type === "refused") console.log(`refused ${event.reason}`); - else if (event.type === "failed") console.log(`failed ${event.reason}`); + 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 }); + } }); const shutdown = async () => { @@ -72,6 +86,7 @@ async function main(): Promise { } main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : "attach failed"); + const message = error instanceof Error ? error.message : "attach failed"; + process.stderr.write(`${message}\n`); process.exit(1); }); diff --git a/packages/browser-pay/examples/relays/bogus.test.ts b/packages/browser-pay/examples/relays/bogus.test.ts deleted file mode 100644 index 33fd2feda..000000000 --- a/packages/browser-pay/examples/relays/bogus.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { HeldPlaceholder, RelayRequest } from "../../src/index.js"; -import { bogusShopifyRelay } from "./bogus.js"; -import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; - -const PLACEHOLDER: HeldPlaceholder = { - number: "4123456789012349", - expMonth: 11, - expYear: 2029, - cvc: "321", - injectionRef: "inj_demo", -}; - -const BODY = JSON.stringify({ - credit_card: { - number: "4123-4567-8901-2349", - name: "Test Buyer", - month: 11, - year: 2029, - verification_value: "321", - }, - payment_session_scope: "scope", -}); - -function request(body = BODY): RelayRequest { - return { - transactionId: "tx_demo", - injectionRef: "inj_demo", - merchantOrigin: "https://1a2uh3-r7.myshopify.com", - url: "https://checkout.pci.shopifyinc.com/sessions?x=1", - method: "POST", - headers: [ - ["content-type", "application/json"], - ["content-length", "999"], - ["host", "checkout.pci.shopifyinc.com"], - ["shopify-identification-signature", "sig"], - ], - body, - bodyEncoding: "utf8", - version: 1, - idempotencyKey: "req_demo", - }; -} - -describe("bogusShopifyRelay", () => { - it("substitutes the Shopify Payments test card and forwards once", async () => { - const seen: { url?: string; init?: RequestInit } = {}; - const fetchImpl = (async ( - url: string | URL | Request, - init?: RequestInit, - ) => { - seen.url = String(url); - seen.init = init; - return new Response(JSON.stringify({ id: "sess_bogus" }), { - status: 201, - headers: { - "content-type": "application/json", - "content-length": "20", - "set-cookie": "a=b", - }, - }); - }) as typeof fetch; - const relay = bogusShopifyRelay({ placeholder: PLACEHOLDER, fetchImpl }); - const response = await relay.relay(request()); - expect(seen.url).toBe("https://checkout.pci.shopifyinc.com/sessions?x=1"); - expect(seen.init?.method).toBe("POST"); - const headers = seen.init?.headers as Record; - expect(headers["shopify-identification-signature"]).toBe("sig"); - expect(headers["content-length"]).toBeUndefined(); - expect(headers.host).toBeUndefined(); - const forwarded = JSON.parse(String(seen.init?.body)); - expect(forwarded).toEqual({ - credit_card: { - number: SHOPIFY_PAYMENTS_TEST_CARD.number, - name: "Test Buyer", - month: SHOPIFY_PAYMENTS_TEST_CARD.expMonth, - year: SHOPIFY_PAYMENTS_TEST_CARD.expYear, - verification_value: SHOPIFY_PAYMENTS_TEST_CARD.cvc, - }, - payment_session_scope: "scope", - }); - expect(String(seen.init?.body)).not.toContain(PLACEHOLDER.number); - expect(String(seen.init?.body)).not.toContain(PLACEHOLDER.cvc); - expect(response).toEqual({ - ok: true, - status: 201, - headers: [ - ["content-type", "application/json"], - ["set-cookie", "a=b"], - ], - body: '{"id":"sess_bogus"}', - bodyEncoding: "utf8", - }); - }); - - it("does not forward when the placeholder cannot be replaced", async () => { - const fetchImpl = jest.fn() as unknown as typeof fetch; - const relay = bogusShopifyRelay({ placeholder: PLACEHOLDER, fetchImpl }); - await expect(relay.relay(request('{"event":"ping"}'))).rejects.toThrow( - "Placeholder card fields were not replaced", - ); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("refuses a redirect instead of following it", async () => { - const fetchImpl = (async () => - new Response(null, { - status: 302, - headers: { location: "https://evil.example" }, - })) as typeof fetch; - const relay = bogusShopifyRelay({ placeholder: PLACEHOLDER, fetchImpl }); - await expect(relay.relay(request())).rejects.toThrow( - "Payment relay refused a redirect", - ); - }); -}); diff --git a/packages/browser-pay/examples/relays/bogus.ts b/packages/browser-pay/examples/relays/bogus.ts deleted file mode 100644 index 6674723a0..000000000 --- a/packages/browser-pay/examples/relays/bogus.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { HeldPlaceholder, PaymentRelay } from "../../src/index.js"; -import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; -import { - forwardRequestHeaders, - providerResponseHeaders, - relayBodyText, - replacePlaceholderCard, -} from "./replace-card.js"; - -/** - * Demo relay that forwards the paused request with Shopify Payments' published - * test card in place of the placeholder. Not part of the published SDK. - * The dev store rejects the old Bogus Gateway success value ("1"). - */ -export const BOGUS_CARD = SHOPIFY_PAYMENTS_TEST_CARD; - -export function bogusShopifyRelay(options: { - placeholder: HeldPlaceholder; - fetchImpl?: typeof fetch; -}): PaymentRelay { - const fetchImpl = options.fetchImpl ?? fetch; - return { - async relay(request) { - const body = replacePlaceholderCard( - relayBodyText(request), - options.placeholder.number, - { - number: BOGUS_CARD.number, - expMonth: BOGUS_CARD.expMonth, - expYear: BOGUS_CARD.expYear, - cvc: BOGUS_CARD.cvc, - }, - ); - const response = await fetchImpl(request.url, { - method: request.method, - redirect: "manual", - headers: forwardRequestHeaders(request.headers), - body, - signal: AbortSignal.timeout(30_000), - }); - if (response.status >= 300 && response.status < 400) { - throw new Error("Payment relay refused a redirect"); - } - return { - ok: true, - status: response.status, - headers: providerResponseHeaders(response.headers), - body: await response.text(), - bodyEncoding: "utf8", - }; - }, - }; -} diff --git a/packages/browser-pay/examples/relays/bt-proxy.test.ts b/packages/browser-pay/examples/relays/bt-proxy.test.ts deleted file mode 100644 index 5266771ee..000000000 --- a/packages/browser-pay/examples/relays/bt-proxy.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { HeldPlaceholder, RelayRequest } from "../../src/index.js"; -import { - btAccessFromKey, - btProxyRelay, - createShopifyPaymentsTestToken, -} from "./bt-proxy.js"; -import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; - -const PLACEHOLDER: HeldPlaceholder = { - number: "4242424242424242", - expMonth: 11, - expYear: 2029, - cvc: "321", - injectionRef: "inj_demo", -}; - -const API_KEY = "bt_test_key_should_not_leak"; -const TOKEN_ID = "tok_test_123"; - -const REQUEST: RelayRequest = { - transactionId: "tx_demo", - injectionRef: "inj_demo", - merchantOrigin: "https://shop.example", - url: "https://checkout.pci.shopifyinc.com/sessions", - method: "POST", - headers: [ - ["content-type", "application/json"], - ["content-length", "40"], - ["shopify-identification-signature", "sig"], - ], - bodyEncoding: "utf8", - version: 1, - idempotencyKey: "req_demo", - body: JSON.stringify({ - credit_card: { - number: "4242424242424242", - month: 11, - year: 2029, - verification_value: "321", - }, - }), -}; - -describe("btProxyRelay", () => { - it("posts token expressions to the Basis Theory proxy", async () => { - const seen: { url?: string; init?: RequestInit } = {}; - const fetchImpl = (async ( - url: string | URL | Request, - init?: RequestInit, - ) => { - seen.url = String(url); - seen.init = init; - return new Response('{"id":"sess_bt"}', { - status: 200, - headers: { - "content-type": "application/json", - "bt-proxy-destination-status": "201", - "bt-request-id": "req_secret", - "content-encoding": "gzip", - }, - }); - }) as typeof fetch; - const relay = btProxyRelay({ - placeholder: PLACEHOLDER, - apiKey: API_KEY, - tokenId: TOKEN_ID, - fetchImpl, - }); - const response = await relay.relay(REQUEST); - expect(seen.url).toBe("https://api.basistheory.com/proxy"); - const headers = seen.init?.headers as Record; - expect(headers["BT-API-KEY"]).toBe(API_KEY); - expect(headers["BT-PROXY-URL"]).toBe( - "https://checkout.pci.shopifyinc.com/sessions", - ); - expect(headers["shopify-identification-signature"]).toBe("sig"); - expect(headers["content-length"]).toBeUndefined(); - const body = String(seen.init?.body); - expect(body).not.toContain(PLACEHOLDER.number); - expect(body).not.toContain(PLACEHOLDER.cvc); - const card = JSON.parse(body).credit_card; - expect(card.number).toBe( - "{{ token: tok_test_123 | json: '$.data.number' }}", - ); - expect(card.month).toBe( - "{{ token: tok_test_123 | json: '$.data.expiration_month' }}", - ); - expect(card.year).toBe( - "{{ token: tok_test_123 | json: '$.data.expiration_year' }}", - ); - expect(card.verification_value).toBe( - "{{ token: tok_test_123 | json: '$.data.cvc' }}", - ); - expect(response).toEqual({ - ok: true, - status: 201, - headers: [["content-type", "application/json"]], - body: '{"id":"sess_bt"}', - bodyEncoding: "utf8", - }); - if (!response.ok) throw new Error("expected a fulfilled relay response"); - expect(JSON.stringify(response.headers)).not.toContain("bt-"); - }); - - it("fails closed on a proxy error and does not include the key", async () => { - const fetchImpl = (async () => - new Response("nope", { status: 401 })) as typeof fetch; - const relay = btProxyRelay({ - placeholder: PLACEHOLDER, - apiKey: API_KEY, - tokenId: TOKEN_ID, - fetchImpl, - }); - await expect(relay.relay(REQUEST)).rejects.toThrow( - "Payment relay failed (HTTP 401)", - ); - }); -}); - -describe("createShopifyPaymentsTestToken", () => { - it("vaults the Shopify Payments test card and deletes the token afterwards", async () => { - const calls: { url: string; init?: RequestInit }[] = []; - const fetchImpl = (async ( - url: string | URL | Request, - init?: RequestInit, - ) => { - calls.push({ url: String(url), init }); - if (init?.method === "DELETE") return new Response(null, { status: 204 }); - return new Response( - JSON.stringify({ - id: "tok_created_1", - data: { number: SHOPIFY_PAYMENTS_TEST_CARD.number }, - }), - { status: 201, headers: { "content-type": "application/json" } }, - ); - }) as typeof fetch; - const created = await createShopifyPaymentsTestToken({ - apiKey: API_KEY, - fetchImpl, - }); - expect(created.tokenId).toBe("tok_created_1"); - const create = calls[0]; - expect(create?.url).toBe("https://api.basistheory.com/tokens"); - expect(create?.init?.method).toBe("POST"); - const headers = create?.init?.headers as Record; - expect(headers["BT-API-KEY"]).toBe(API_KEY); - expect(JSON.parse(String(create?.init?.body))).toEqual({ - type: "card", - data: { - number: SHOPIFY_PAYMENTS_TEST_CARD.number, - expiration_month: SHOPIFY_PAYMENTS_TEST_CARD.expMonth, - expiration_year: SHOPIFY_PAYMENTS_TEST_CARD.expYear, - cvc: SHOPIFY_PAYMENTS_TEST_CARD.cvc, - }, - }); - await created.release(); - expect(calls[1]?.url).toBe( - "https://api.basistheory.com/tokens/tok_created_1", - ); - expect(calls[1]?.init?.method).toBe("DELETE"); - }); - - it("does not echo the card number when token create fails", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - title: "bad", - echo: SHOPIFY_PAYMENTS_TEST_CARD.number, - }), - { status: 400 }, - )) as typeof fetch; - await expect( - createShopifyPaymentsTestToken({ apiKey: API_KEY, fetchImpl }), - ).rejects.toThrow("Basis Theory token create failed (HTTP 400: bad)"); - try { - await createShopifyPaymentsTestToken({ apiKey: API_KEY, fetchImpl }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - expect(message).not.toContain(SHOPIFY_PAYMENTS_TEST_CARD.number); - expect(message).not.toContain(API_KEY); - } - }); -}); - -describe("btAccessFromKey", () => { - it("uses a private key without creating an application", async () => { - const fetchImpl = jest.fn(async () => - Response.json({ type: "private" }), - ) as unknown as typeof fetch; - const access = await btAccessFromKey({ apiKey: API_KEY, fetchImpl }); - expect(access.createdApplication).toBe(false); - expect(access.apiKey).toBe(API_KEY); - await access.release(); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it("creates a private application from a management key and deletes it", async () => { - const calls: { url: string; init?: RequestInit }[] = []; - const createdKey = "key_test_private_created"; - const fetchImpl = (async ( - url: string | URL | Request, - init?: RequestInit, - ) => { - calls.push({ url: String(url), init }); - if (!init || init.method === "GET" || init.method === undefined) { - return Response.json({ type: "management" }); - } - if (init.method === "DELETE") return new Response(null, { status: 204 }); - return Response.json({ - id: "app_demo_1", - keys: [{ key: createdKey }], - }); - }) as typeof fetch; - const access = await btAccessFromKey({ - apiKey: "key_test_management", - fetchImpl, - }); - expect(access.createdApplication).toBe(true); - expect(access.apiKey).toBe(createdKey); - const create = calls[1]; - expect(create?.url).toBe("https://api.basistheory.com/applications"); - expect(JSON.parse(String(create?.init?.body))).toEqual({ - name: "browser-pay demo", - type: "private", - permissions: [ - "token:create", - "token:delete", - "token:use", - "proxy:invoke", - ], - }); - await access.release(); - expect(calls[2]?.url).toBe( - "https://api.basistheory.com/applications/app_demo_1", - ); - expect(calls[2]?.init?.method).toBe("DELETE"); - const deleteHeaders = calls[2]?.init?.headers as Record; - expect(deleteHeaders["BT-API-KEY"]).toBe("key_test_management"); - }); - - it("does not include the created key when application create fails", async () => { - const fetchImpl = (async ( - _url: string | URL | Request, - init?: RequestInit, - ) => { - if (!init || init.method === undefined || init.method === "GET") { - return Response.json({ type: "management" }); - } - return Response.json( - { - title: "missing_permission", - detail: "Missing permission: application:create", - key: "key_test_should_not_leak", - }, - { status: 403 }, - ); - }) as typeof fetch; - await expect( - btAccessFromKey({ apiKey: "key_test_management", fetchImpl }), - ).rejects.toThrow( - "Basis Theory application create failed (HTTP 403: missing_permission: Missing permission: application:create)", - ); - }); -}); diff --git a/packages/browser-pay/examples/relays/bt-proxy.ts b/packages/browser-pay/examples/relays/bt-proxy.ts deleted file mode 100644 index 4efe336ea..000000000 --- a/packages/browser-pay/examples/relays/bt-proxy.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { HeldPlaceholder, PaymentRelay } from "../../src/index.js"; -import { SHOPIFY_PAYMENTS_TEST_CARD } from "./shopify-payments-test-card.js"; -import { - forwardRequestHeaders, - providerResponseHeaders, - relayBodyText, - replacePlaceholderCard, -} from "./replace-card.js"; - -/** - * Demo stand-in for W3 B6. Not part of the published SDK. - * Sends the paused Shopify card-session request through Basis Theory's - * ephemeral proxy. The placeholder number, expiry, and CVC are replaced with - * token expressions; Basis Theory inserts the vaulted card. Reads - * BT_PRIVATE_KEY and BT_TOKEN_ID from the environment when constructed from - * `btProxyRelayFromEnv`. Never logs the key, the token id, or a card value. - */ - -export interface CreatedCardToken { - tokenId: string; - /** Deletes the token. A failed delete is logged as a status code only. */ - release: () => Promise; -} - -export interface BtAccess { - apiKey: string; - /** True when this run created a private application from a management key. */ - createdApplication: boolean; - /** No-op for a private key. Deletes an application created for a management key. */ - release: () => Promise; -} - -const PRIVATE_APP_PERMISSIONS = [ - "token:create", - "token:delete", - "token:use", - "proxy:invoke", -] as const; - -async function safeFailure(response: Response, action: string): Promise { - let extra = ""; - try { - const body = (await response.json()) as { - title?: unknown; - detail?: unknown; - }; - const title = typeof body.title === "string" ? body.title : ""; - const detail = typeof body.detail === "string" ? body.detail : ""; - const combined = [title, detail].filter(Boolean).join(": "); - if ( - combined && - combined.length <= 180 && - !/key_|[0-9a-f]{8}-[0-9a-f]{4}/i.test(combined) && - !/\d(?:\s*\d){11,}/.test(combined) - ) { - extra = `: ${combined}`; - } - } catch { - // The status is enough when the body is not JSON. - } - return new Error( - `Basis Theory ${action} failed (HTTP ${response.status}${extra})`, - ); -} - -function btBaseUrl(baseUrl?: string): string { - return ( - baseUrl ?? - process.env.BT_API_BASE ?? - "https://api.basistheory.com" - ).replace(/\/$/, ""); -} - -/** - * Vault Shopify Payments' published test card in the Basis Theory test tenant. - * The PAN is sent only on this create call. The relay then uses token expressions. - */ -export async function createShopifyPaymentsTestToken(options: { - apiKey: string; - baseUrl?: string; - fetchImpl?: typeof fetch; -}): Promise { - if (!options.apiKey) { - throw new Error("BT_PRIVATE_KEY is required to create a card token"); - } - const fetchImpl = options.fetchImpl ?? fetch; - const base = btBaseUrl(options.baseUrl); - const card = SHOPIFY_PAYMENTS_TEST_CARD; - const response = await fetchImpl(`${base}/tokens`, { - method: "POST", - redirect: "manual", - headers: { - "BT-API-KEY": options.apiKey, - "content-type": "application/json", - }, - body: JSON.stringify({ - type: "card", - data: { - number: card.number, - expiration_month: card.expMonth, - expiration_year: card.expYear, - cvc: card.cvc, - }, - }), - signal: AbortSignal.timeout(30_000), - }); - if (response.status >= 300 && response.status < 400) { - throw new Error("Basis Theory token create refused a redirect"); - } - if (!response.ok) throw await safeFailure(response, "token create"); - const payload = (await response.json()) as { id?: unknown }; - const tokenId = payload.id; - if (typeof tokenId !== "string" || !/^[A-Za-z0-9._:-]+$/.test(tokenId)) { - throw new Error("Basis Theory token create returned no token id"); - } - return { - tokenId, - release: async () => { - try { - const deleted = await fetchImpl( - `${base}/tokens/${encodeURIComponent(tokenId)}`, - { - method: "DELETE", - redirect: "manual", - headers: { "BT-API-KEY": options.apiKey }, - signal: AbortSignal.timeout(30_000), - }, - ); - if (!deleted.ok && deleted.status !== 404) { - console.error( - `Basis Theory token delete failed (HTTP ${deleted.status})`, - ); - } - await deleted.arrayBuffer().catch(() => undefined); - } catch { - console.error("Basis Theory token delete failed"); - } - }, - }; -} - -/** - * A private application key that can create a card token and invoke the proxy. - * `BT_PRIVATE_KEY` in this environment may be a management key, which cannot - * do either. A management key creates a private application for this run. - * The application is deleted by `release`. The returned key is never logged. - */ -export async function btAccessFromKey(options: { - apiKey: string; - baseUrl?: string; - fetchImpl?: typeof fetch; -}): Promise { - if (!options.apiKey) { - throw new Error("BT_PRIVATE_KEY is required"); - } - const fetchImpl = options.fetchImpl ?? fetch; - const base = btBaseUrl(options.baseUrl); - const current = await fetchImpl(`${base}/applications/key`, { - method: "GET", - redirect: "manual", - headers: { "BT-API-KEY": options.apiKey, accept: "application/json" }, - signal: AbortSignal.timeout(30_000), - }); - if (current.status >= 300 && current.status < 400) { - throw new Error("Basis Theory application lookup refused a redirect"); - } - if (!current.ok) { - await current.arrayBuffer().catch(() => undefined); - return { - apiKey: options.apiKey, - createdApplication: false, - release: async () => {}, - }; - } - const app = (await current.json()) as { type?: unknown }; - if (app.type !== "management") { - return { - apiKey: options.apiKey, - createdApplication: false, - release: async () => {}, - }; - } - const created = await fetchImpl(`${base}/applications`, { - method: "POST", - redirect: "manual", - headers: { - "BT-API-KEY": options.apiKey, - "content-type": "application/json", - }, - body: JSON.stringify({ - name: "browser-pay demo", - type: "private", - permissions: [...PRIVATE_APP_PERMISSIONS], - }), - signal: AbortSignal.timeout(30_000), - }); - if (created.status >= 300 && created.status < 400) { - throw new Error("Basis Theory application create refused a redirect"); - } - if (!created.ok) throw await safeFailure(created, "application create"); - const body = (await created.json()) as { - id?: unknown; - keys?: Array<{ key?: unknown }>; - }; - const applicationId = body.id; - const privateKey = body.keys?.find( - (item) => typeof item.key === "string", - )?.key; - if ( - typeof applicationId !== "string" || - !/^[A-Za-z0-9._:-]+$/.test(applicationId) || - typeof privateKey !== "string" || - privateKey.length < 8 - ) { - throw new Error("Basis Theory application create returned no key"); - } - return { - apiKey: privateKey, - createdApplication: true, - release: async () => { - try { - const deleted = await fetchImpl( - `${base}/applications/${encodeURIComponent(applicationId)}`, - { - method: "DELETE", - redirect: "manual", - headers: { "BT-API-KEY": options.apiKey }, - signal: AbortSignal.timeout(30_000), - }, - ); - if (!deleted.ok && deleted.status !== 404) { - console.error( - `Basis Theory application delete failed (HTTP ${deleted.status})`, - ); - } - await deleted.arrayBuffer().catch(() => undefined); - } catch { - console.error("Basis Theory application delete failed"); - } - }, - }; -} - -export function btProxyRelay(options: { - placeholder: HeldPlaceholder; - apiKey: string; - tokenId: string; - /** Defaults to `https://api.basistheory.com`, or `BT_API_BASE` when omitted. */ - baseUrl?: string; - fetchImpl?: typeof fetch; -}): PaymentRelay { - if (!/^[A-Za-z0-9._:-]+$/.test(options.tokenId)) { - throw new Error("BT token id is not a usable token id"); - } - const fetchImpl = options.fetchImpl ?? fetch; - const base = btBaseUrl(options.baseUrl); - const expression = (path: string) => - `{{ token: ${options.tokenId} | json: '$.data.${path}' }}`; - return { - async relay(request) { - const body = replacePlaceholderCard( - relayBodyText(request), - options.placeholder.number, - { - number: expression("number"), - expMonth: expression("expiration_month"), - expYear: expression("expiration_year"), - cvc: expression("cvc"), - }, - ); - const response = await fetchImpl(`${base}/proxy`, { - method: "POST", - redirect: "manual", - headers: { - ...forwardRequestHeaders(request.headers), - "BT-API-KEY": options.apiKey, - "BT-PROXY-URL": request.url, - }, - body, - signal: AbortSignal.timeout(30_000), - }); - if (response.status >= 300 && response.status < 400) { - throw new Error("Payment relay refused a redirect"); - } - const destination = Number( - response.headers.get("bt-proxy-destination-status"), - ); - const hasDestination = - Number.isInteger(destination) && - destination >= 100 && - destination <= 599; - if ( - !hasDestination && - (response.status < 200 || response.status >= 300) - ) { - throw new Error(`Payment relay failed (HTTP ${response.status})`); - } - return { - ok: true, - status: hasDestination ? destination : response.status, - headers: providerResponseHeaders(response.headers), - body: await response.text(), - bodyEncoding: "utf8", - }; - }, - }; -} - -export function btProxyRelayFromEnv( - placeholder: HeldPlaceholder, -): PaymentRelay { - const apiKey = process.env.BT_PRIVATE_KEY; - const tokenId = process.env.BT_TOKEN_ID; - if (!apiKey || !tokenId) { - throw new Error("BT_PRIVATE_KEY and BT_TOKEN_ID are required for RELAY=bt"); - } - return btProxyRelay({ placeholder, apiKey, tokenId }); -} diff --git a/packages/browser-pay/examples/relays/replace-card.ts b/packages/browser-pay/examples/relays/replace-card.ts deleted file mode 100644 index 0a2e777b1..000000000 --- a/packages/browser-pay/examples/relays/replace-card.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { cardNumberCandidate } from "../../src/detect.js"; -import type { RelayHeader, RelayRequest } from "../../src/index.js"; - -export interface CardReplacement { - number: string; - expMonth: string | number; - expYear: string | number; - cvc: string; -} - -/** - * Replace the placeholder's number, expiry, and CVC in a JSON or form body. - * Throws, without forwarding, when the placeholder number is still present. - */ -export function replacePlaceholderCard( - body: string, - placeholderNumber: string, - next: CardReplacement, -): string { - const trimmed = body.trim(); - let replaced = ""; - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - const result = replaceJson(JSON.parse(body), placeholderNumber, next); - if (!result.replaced) { - throw new Error("Placeholder card fields were not replaced"); - } - replaced = JSON.stringify(result.value); - } else if (trimmed.includes("=")) { - replaced = replaceForm(body, placeholderNumber, next); - } else { - throw new Error("Placeholder card fields were not replaced"); - } - if (numberStillPresent(replaced, placeholderNumber)) { - throw new Error("Placeholder card fields were not replaced"); - } - return replaced; -} - -function replaceJson( - value: unknown, - placeholderNumber: string, - next: CardReplacement, -): { value: unknown; replaced: boolean } { - if (Array.isArray(value)) { - let replaced = false; - const items = value.map((item) => { - const child = replaceJson(item, placeholderNumber, next); - replaced = replaced || child.replaced; - return child.value; - }); - return { value: items, replaced }; - } - if (!value || typeof value !== "object") return { value, replaced: false }; - const obj = value as Record; - if (objectHasNumber(obj, placeholderNumber)) { - return { - value: replaceObject(obj, placeholderNumber, next), - replaced: true, - }; - } - let replaced = false; - const out: Record = {}; - for (const [key, child] of Object.entries(obj)) { - const nextChild = replaceJson(child, placeholderNumber, next); - replaced = replaced || nextChild.replaced; - out[key] = nextChild.value; - } - return { value: out, replaced }; -} - -function objectHasNumber( - obj: Record, - placeholderNumber: string, -): boolean { - return Object.values(obj).some( - (value) => - typeof value === "string" && - cardNumberCandidate(value) === placeholderNumber, - ); -} - -function replaceObject( - obj: Record, - placeholderNumber: string, - next: CardReplacement, -): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(obj)) { - if ( - typeof value === "string" && - cardNumberCandidate(value) === placeholderNumber - ) { - out[key] = next.number; - continue; - } - const kind = fieldKind(key); - if (kind === "month") { - out[key] = like(value, next.expMonth); - continue; - } - if (kind === "year") { - out[key] = like(value, next.expYear); - continue; - } - if (kind === "cvc") { - out[key] = next.cvc; - continue; - } - if (kind === "expiry") { - out[key] = formatExpiry(next); - continue; - } - out[key] = value; - } - return out; -} - -function replaceForm( - body: string, - placeholderNumber: string, - next: CardReplacement, -): string { - const params = new URLSearchParams(body); - let found = false; - for (const [key, value] of params) { - if (cardNumberCandidate(value) === placeholderNumber) { - params.set(key, next.number); - found = true; - } - } - if (!found) throw new Error("Placeholder card fields were not replaced"); - for (const [key, value] of [...params.entries()]) { - const kind = fieldKind(key.split("[").pop()?.replace("]", "") ?? key); - if (kind === "month") params.set(key, String(like(value, next.expMonth))); - if (kind === "year") params.set(key, String(like(value, next.expYear))); - if (kind === "cvc") params.set(key, next.cvc); - if (kind === "expiry") params.set(key, formatExpiry(next)); - } - return params.toString(); -} - -function like(original: unknown, value: string | number): string | number { - if (typeof value === "string" && !/^\d+$/.test(value)) return value; - if (typeof original === "number") return Number(value); - return String(value); -} - -function formatExpiry(next: CardReplacement): string { - if (typeof next.expMonth === "string" && !/^\d+$/.test(next.expMonth)) { - return `${next.expMonth}/${next.expYear}`; - } - const month = String(next.expMonth).padStart(2, "0"); - const year = String(next.expYear); - const shortYear = year.length === 4 ? year.slice(-2) : year; - return `${month}/${shortYear}`; -} - -function numberStillPresent(body: string, number: string): boolean { - const pattern = new RegExp(number.split("").join("[\\s-]*")); - return pattern.test(body); -} - -function fieldKind(key: string): "month" | "year" | "expiry" | "cvc" | "other" { - const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); - if ( - normalized.includes("startmonth") || - normalized.includes("startyear") || - normalized.includes("issuen") - ) { - return "other"; - } - if ( - normalized.includes("cvc") || - normalized.includes("cvv") || - normalized.includes("verificationvalue") || - normalized.includes("securitycode") - ) { - return "cvc"; - } - if (normalized === "year" || normalized.endsWith("year")) return "year"; - if (normalized === "month" || normalized.endsWith("month")) return "month"; - if ( - normalized === "expiry" || - normalized === "expiration" || - normalized === "exp" - ) { - return "expiry"; - } - return "other"; -} - -export const HOP_BY_HOP = new Set([ - "host", - "content-length", - "connection", - "keep-alive", - "transfer-encoding", - "te", - "trailer", - "upgrade", - "proxy-connection", - "proxy-authorization", -]); - -export function relayBodyText(request: RelayRequest): string { - if (request.bodyEncoding === "base64") { - return Buffer.from(request.body, "base64").toString("utf8"); - } - return request.body; -} - -export function forwardRequestHeaders( - headers: RelayHeader[], -): Record { - const out: Record = {}; - for (const [key, value] of headers) { - if (HOP_BY_HOP.has(key.toLowerCase())) continue; - const existing = Object.keys(out).find( - (candidate) => candidate.toLowerCase() === key.toLowerCase(), - ); - if (existing) out[existing] = `${out[existing]}, ${value}`; - else out[key] = value; - } - return out; -} - -export function providerResponseHeaders(headers: Headers): RelayHeader[] { - const drop = new Set([ - "content-encoding", - "content-length", - "transfer-encoding", - "connection", - "keep-alive", - "te", - "trailer", - "upgrade", - ]); - const out: RelayHeader[] = []; - headers.forEach((value, key) => { - if (drop.has(key) || key.startsWith("bt-") || key === "set-cookie") return; - out.push([key, value]); - }); - const setCookies = - typeof headers.getSetCookie === "function" ? headers.getSetCookie() : []; - for (const cookie of setCookies) out.push(["set-cookie", cookie]); - return out; -} diff --git a/packages/browser-pay/examples/relays/shopify-payments-test-card.ts b/packages/browser-pay/examples/relays/shopify-payments-test-card.ts deleted file mode 100644 index afb2b6fce..000000000 --- a/packages/browser-pay/examples/relays/shopify-payments-test-card.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Shopify Payments test-mode Visa. Shopify publishes this number for test - * checkouts. It is not a live card. Logs must still mask it to the last four. - */ -export const SHOPIFY_PAYMENTS_TEST_CARD = { - number: "4242424242424242", - expMonth: 12, - expYear: 2030, - cvc: "123", -} as const; diff --git a/packages/browser-pay/examples/shopify-e2e.ts b/packages/browser-pay/examples/shopify-e2e.ts deleted file mode 100644 index 2bf491a62..000000000 --- a/packages/browser-pay/examples/shopify-e2e.ts +++ /dev/null @@ -1,1089 +0,0 @@ -/** - * Opt-in Shopify dev-store checkout. Not part of CI. - * - * RELAY=bogus npx tsx examples/shopify-e2e.ts - * BROWSER=sapiom RELAY=bt SAPIOM_API_KEY=… BT_PRIVATE_KEY=… \ - * npx tsx examples/shopify-e2e.ts - * RELAY=http SAPIOM_BASE_URL=… SAPIOM_API_KEY=… TRANSACTION_ID=… \ - * INJECTION_REF=… PLACEHOLDER_NUMBER=… PLACEHOLDER_EXP_MONTH=… \ - * PLACEHOLDER_EXP_YEAR=… PLACEHOLDER_CVC=… npx tsx examples/shopify-e2e.ts - * MODE=refuse RELAY=bogus npx tsx examples/shopify-e2e.ts - * - * One checkout per process. Pay is clicked once. RELAY=bt creates a Basis Theory - * card token at setup and deletes it afterwards. BROWSER=sapiom asks the Sapiom - * browser session to solve captchas, with the proxy and stealth that session - * requires. The interceptor attaches after that solver finishes and before - * Pay. This script does not bypass captchas itself. - */ -import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { createClient, type Sapiom } from "@sapiom/tools"; -import { chromium, type Browser, type Frame, type Page } from "playwright"; -import { - attachInterceptor, - httpPaymentRelay, - maskCardNumber, - redact, - stubPaymentRelay, - type HeldPlaceholder, - type InterceptorEvent, - type InterceptorHandle, - type PaymentRelay, -} from "../src/index.js"; -import { bogusShopifyRelay } from "./relays/bogus.js"; -import { - btAccessFromKey, - btProxyRelay, - createShopifyPaymentsTestToken, -} from "./relays/bt-proxy.js"; -import { SHOPIFY_PAYMENTS_TEST_CARD } from "./relays/shopify-payments-test-card.js"; - -const CART_URL = "https://1a2uh3-r7.myshopify.com/cart/67652208394372:1"; -const SHOP_ORIGIN = "https://1a2uh3-r7.myshopify.com"; -const SOLVER_WAIT_MS = 180_000; -const ADDRESS = { - line: "350 5th Ave", - city: "New York", - state: "New York", - zip: "10118", - phone: "2127363100", -} as const; - -let startedAt = Date.now(); - -function mark(message: string): void { - const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); - console.log(`t+${seconds}s ${message}`); -} - -class CheckoutStopped extends Error { - constructor( - message: string, - readonly code: number, - ) { - super(message); - } -} - -async function main(): Promise { - const relayName = process.env.RELAY; - if (relayName !== "bogus" && relayName !== "bt" && relayName !== "http") { - throw new CheckoutStopped( - "Set RELAY=bogus, RELAY=bt, or RELAY=http. See the comment at the top of examples/shopify-e2e.ts.", - 1, - ); - } - const mode = process.env.MODE === "refuse" ? "refuse" : "pay"; - startedAt = Date.now(); - sessionsObserved = false; - const placeholder = loadPlaceholder(relayName); - console.log( - `checkout ${mode} via RELAY=${relayName} BROWSER=${process.env.BROWSER ?? "local"} placeholder ${maskCardNumber(placeholder.number)} test card ${maskCardNumber(SHOPIFY_PAYMENTS_TEST_CARD.number)}`, - ); - - const events: InterceptorEvent[] = []; - let relayCalls = 0; - let sessionsUrl = false; - let merchantOrigin = ""; - let relayNote = ""; - let releaseToken: (() => Promise) | undefined; - let releaseAccess: (() => Promise) | undefined; - let browserSession: - | { cdpUrl: string; close: () => Promise } - | undefined; - let handle: InterceptorHandle | undefined; - let driver: Browser | undefined; - let page: Page | undefined; - try { - let btTokenId: string | undefined; - let btApiKey: string | undefined; - if (relayName === "bt" && mode === "pay") { - const access = await btAccessFromKey({ - apiKey: required("BT_PRIVATE_KEY"), - }); - releaseAccess = access.release; - btApiKey = access.apiKey; - console.log( - access.createdApplication - ? "Basis Theory management key; created a private application for this run" - : "using the Basis Theory private key from the environment", - ); - const created = await createShopifyPaymentsTestToken({ - apiKey: access.apiKey, - }); - releaseToken = created.release; - btTokenId = created.tokenId; - console.log( - "Basis Theory card token created for the Shopify Payments test card", - ); - } - const selected = - mode === "refuse" - ? refusingRelay() - : selectRelay(relayName, placeholder, btApiKey, btTokenId); - const relay: PaymentRelay = { - async relay(request) { - relayCalls += 1; - const isSessions = request.url.includes( - "checkout.pci.shopifyinc.com/sessions", - ); - if (isSessions && !sessionsUrl) { - sessionsObserved = true; - mark("pci sessions paused"); - } - sessionsUrl = sessionsUrl || isSessions; - merchantOrigin = request.merchantOrigin; - const result = await selected.relay(request); - if (request.url.includes("checkout.pci.shopifyinc.com/sessions")) { - mark( - result.ok - ? `pci sessions relayed HTTP ${result.status}` - : `pci sessions refused ${result.reason}`, - ); - } - const note = result.ok - ? `status ${result.status} body ${redact(result.body).slice(0, 180)}` - : `refused ${result.reason}`; - relayNote = btTokenId - ? note.split(btTokenId).join("tok_redacted") - : note; - return result; - }, - }; - - mark("opening browser session"); - browserSession = await openBrowser(); - mark("browser session ready"); - driver = await chromium.connectOverCDP(browserSession.cdpUrl); - const context = driver.contexts()[0] ?? (await driver.newContext()); - page = context.pages()[0] ?? (await context.newPage()); - const checkout = page; - await checkout.setViewportSize({ width: 1280, height: 900 }); - await prepareCheckout(checkout, placeholder); - // The session solver clears hCaptcha in a few seconds when nothing else is - // pausing the widget. Attach after that, and before Pay, so the card post - // is still paused. - mark("attaching interceptor"); - handle = await attachInterceptor(browserSession.cdpUrl, { - relay, - placeholder, - transactionId: - relayName === "http" ? required("TRANSACTION_ID") : "tx_local_demo", - timeoutMs: 30_000, - // Pause only the card iframe. Pausing the checkout page holds the - // captcha solver that runs after Pay. - pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], - synthesizeBrowserHeaders: true, - }); - handle.on((event) => events.push(event)); - await assertCardHoldsPlaceholder(checkout, placeholder); - mark("interceptor attached; placeholder still in the card fields"); - console.log("pay once"); - await clickPay(checkout); - if (mode === "refuse") { - await waitFor(async () => checkout.url(), 20_000); - await checkout.waitForTimeout(3000); - if (/thank[-_]you/i.test(checkout.url())) { - throw new CheckoutStopped( - "refusal still reached the thank-you page", - 2, - ); - } - const text = await checkout - .locator("body") - .innerText() - .catch(() => ""); - if (/order\s+#\d+/i.test(text) && /thank you/i.test(text)) { - throw new CheckoutStopped("refusal still created an order", 2); - } - console.log(`refusal held; page ${redactUrl(checkout.url())}`); - } else { - await waitForConfirmation(checkout); - const text = await checkout - .locator("body") - .innerText() - .catch(() => ""); - const order = - text.match(/order\s+#\s*(\d+)/i) ?? - text.match(/confirmation\s+#\s*(\d+)/i); - console.log(`confirmed ${redactUrl(page.url())}`); - console.log( - order ? `order ${order[1]}` : "order id not shown on the page", - ); - } - if (!sessionsUrl) { - throw new CheckoutStopped( - "interceptor did not relay a request to checkout.pci.shopifyinc.com/sessions", - 2, - ); - } - if (mode === "refuse") { - if (!events.some((event) => event.type === "refused")) { - throw new CheckoutStopped("refusal did not fail the paused request", 2); - } - if (events.some((event) => event.type === "relayed")) { - throw new CheckoutStopped("refusal fulfilled the paused request", 2); - } - } - if (merchantOrigin && merchantOrigin !== SHOP_ORIGIN) { - throw new CheckoutStopped(`merchant origin was ${merchantOrigin}`, 2); - } - console.log( - `relay calls ${relayCalls}; events ${events.map(summarize).join(", ") || "none"}; ${relayNote}`, - ); - } catch (error) { - if (page) console.error(scrub(await diagnostics(page))); - console.log( - `relay calls ${relayCalls}; events ${events.map(summarize).join(", ") || "none"}; ${relayNote}`, - ); - console.error( - scrub( - error instanceof Error ? (error.stack ?? error.message) : String(error), - ), - ); - throw error; - } finally { - await releaseToken?.().catch(() => {}); - await releaseAccess?.().catch(() => {}); - await settle(page?.close()); - await settle(handle?.detach()); - await settle(driver?.close()); - await settle(browserSession?.close()); - } -} - -function settle(op: Promise | undefined): Promise { - if (!op) return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(resolve, 15_000); - op.then( - () => { - clearTimeout(timer); - resolve(); - }, - () => { - clearTimeout(timer); - resolve(); - }, - ); - }); -} - -function selectRelay( - name: string, - placeholder: HeldPlaceholder, - btApiKey?: string, - btTokenId?: string, -): PaymentRelay { - if (name === "bogus") return bogusShopifyRelay({ placeholder }); - if (name === "bt") { - if (!btApiKey || !btTokenId) { - throw new CheckoutStopped("Basis Theory card token was not created", 1); - } - return btProxyRelay({ - placeholder, - apiKey: btApiKey, - tokenId: btTokenId, - }); - } - return httpPaymentRelay({ - baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", - apiKey: required("SAPIOM_API_KEY"), - }); -} - -function refusingRelay(): PaymentRelay { - return stubPaymentRelay(async () => ({ ok: false, reason: "cancelled" })); -} - -function loadPlaceholder(relayName: string): HeldPlaceholder { - if (relayName === "http") { - return { - number: required("PLACEHOLDER_NUMBER"), - expMonth: Number(required("PLACEHOLDER_EXP_MONTH")), - expYear: Number(required("PLACEHOLDER_EXP_YEAR")), - cvc: required("PLACEHOLDER_CVC"), - injectionRef: required("INJECTION_REF"), - }; - } - // A Luhn-valid stand-in. The relay replaces it before the request is - // forwarded. It is not sent as itself. - return { - number: "4123456789012349", - expMonth: 11, - expYear: 2029, - cvc: "321", - injectionRef: "inj_local_demo", - }; -} - -async function prepareCheckout( - page: Page, - placeholder: HeldPlaceholder, -): Promise { - console.log("open cart"); - await page.goto(CART_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); - await page.waitForTimeout(1500); - await assertNoChallenge(page, "after cart"); - if (page.url().includes("shop.app")) { - const back = new URL(page.url()).searchParams.get("ur_back_url"); - if (!back) { - throw new CheckoutStopped( - "Shop Pay did not offer a standard checkout URL", - 2, - ); - } - console.log("leave Shop Pay for the card checkout"); - await page.goto(back, { waitUntil: "domcontentloaded", timeout: 60_000 }); - } - await assertNoChallenge(page, "on checkout"); - console.log("fill shipping"); - await fillShipping(page); - console.log("choose shipping"); - await chooseShipping(page); - console.log("fill card fields"); - await fillCard(page, placeholder); - mark("card typed"); - await assertNoChallenge(page, "before pay"); - await acceptAddressSuggestion(page); - await assertReadyToPay(page, placeholder); - mark("pre-pay checks passed"); -} - -async function fillShipping(page: Page): Promise { - await fill( - page, - /email/i, - "buyer@example.com", - 'input[type="email"], input[name="email"]', - ); - const country = page - .locator('select[name="countryCode"], select[name="country"]') - .first(); - if (await country.isVisible().catch(() => false)) { - await country.selectOption({ label: "United States" }).catch(async () => { - await country.selectOption("US"); - }); - } - await fill(page, /first name/i, "Test", 'input[name="firstName"]'); - await fill(page, /last name/i, "Buyer", 'input[name="lastName"]'); - await fill(page, /^address$/i, ADDRESS.line, 'input[name="address1"]'); - await dismissAddressSuggestions(page); - await fill(page, /^city$/i, ADDRESS.city, 'input[name="city"]'); - await fill( - page, - /zip code|postal code/i, - ADDRESS.zip, - 'input[name="postalCode"]', - ); - await selectState(page); - await page.waitForTimeout(1000); - await acceptAddressSuggestion(page); - const phone = page.getByLabel(/phone/i).first(); - if (await phone.isVisible().catch(() => false)) { - await phone.fill(ADDRESS.phone); - const typed = (await phone.inputValue().catch(() => "")).replace(/\D/g, ""); - if (typed.length < 10) { - await phone.click({ force: true }).catch(() => {}); - await phone.fill(""); - await phone.pressSequentially(ADDRESS.phone, { delay: 30 }); - } - } - await page.waitForTimeout(1000); -} - -async function acceptAddressSuggestion(page: Page): Promise { - const banner = page - .getByText(/did you mean|suggested address|address suggestion/i) - .first(); - if (!(await banner.isVisible().catch(() => false))) { - await dismissAddressSuggestions(page); - return; - } - mark("accepting the address suggestion"); - if (await detectChallenge(page)) { - await assertNoChallenge(page, "before address suggestion"); - } - const buttons = page.getByRole("button"); - const count = await buttons.count(); - for (let index = 0; index < count; index += 1) { - const button = buttons.nth(index); - const name = (await button.innerText().catch(() => "")) - .replace(/\s+/g, " ") - .trim(); - if ( - /pay now|submit|refund|privacy|terms|contact|order summary|show more/i.test( - name, - ) - ) { - continue; - } - if (/^\d{5}$/.test(name) || /5th|suggested/i.test(name)) { - await button.evaluate((element) => { - (element as { click: () => void }).click(); - }); - await page.waitForTimeout(1000); - return; - } - } - await dismissAddressSuggestions(page); -} - -async function dismissAddressSuggestions(page: Page): Promise { - const decline = page.getByRole("button", { name: /not now/i }).first(); - if (await decline.isVisible().catch(() => false)) { - await decline.click(); - } - await page.keyboard.press("Escape").catch(() => {}); - await page.keyboard.press("Tab").catch(() => {}); -} - -async function fill( - page: Page, - label: RegExp, - value: string, - fallback: string, -): Promise { - const byLabel = page.getByLabel(label).first(); - if (await byLabel.isVisible().catch(() => false)) { - await byLabel.fill(value); - return; - } - const field = page.locator(fallback).first(); - await field.waitFor({ state: "visible", timeout: 20_000 }); - await field.fill(value); -} - -async function selectState(page: Page): Promise { - const select = page - .locator('select[name="zone"], select[name="province"]') - .first(); - if (await select.isVisible().catch(() => false)) { - await select.selectOption({ label: "New York" }).catch(async () => { - await select.selectOption("NY"); - }); - return; - } - const combo = page.getByLabel(/state|province|region/i).first(); - if (await combo.isVisible().catch(() => false)) { - await combo.click(); - await combo.fill("New York"); - await page.keyboard.press("Enter"); - } -} - -async function chooseShipping(page: Page): Promise { - const deadline = Date.now() + 20_000; - while (Date.now() < deadline) { - const radios = page.locator('input[type="radio"]'); - const count = await radios.count(); - for (let index = 0; index < count; index += 1) { - const radio = radios.nth(index); - const blob = `${(await radio.getAttribute("name")) ?? ""} ${(await radio.getAttribute("id")) ?? ""}`; - if (!/shipping|delivery|rate/i.test(blob)) continue; - if (!(await radio.isChecked().catch(() => false))) { - await radio.check({ force: true }).catch(() => {}); - } - return; - } - await page.waitForTimeout(400); - } - console.log("no shipping-rate radio appeared"); -} - -async function cardFrames(page: Page): Promise> { - await page - .waitForSelector( - 'iframe[src*="checkout.pci.shopifyinc.com"], iframe[name*="card-fields"], iframe[title*="Card number"]', - { timeout: 30_000 }, - ) - .catch(() => {}); - const roles = new Map(); - for (const frame of page.frames()) { - const blob = `${frame.name()} ${frame.url()}`; - if (!/checkout\.pci\.shopifyinc\.com|card-fields/i.test(blob)) continue; - const role = frameRole(blob); - if (role !== "unknown" && !roles.has(role)) roles.set(role, frame); - } - return roles; -} - -async function fillCard( - page: Page, - placeholder: HeldPlaceholder, -): Promise { - if (await detectChallenge(page)) { - await assertNoChallenge(page, "before card entry"); - } - const roles = await cardFrames(page); - const frames = page.frames().filter((frame) => { - const blob = `${frame.name()} ${frame.url()}`; - return /checkout\.pci\.shopifyinc\.com|card-fields/i.test(blob); - }); - const number = roles.get("number"); - if (!number) { - throw new CheckoutStopped( - `card number iframe not found (${frames.map((frame) => pathOnly(frame.url())).join(", ") || "no pci frames"})`, - 2, - ); - } - await typeFrame(number, placeholder.number); - const expiry = roles.get("expiry"); - if (expiry) { - await typeFrame( - expiry, - `${String(placeholder.expMonth).padStart(2, "0")}${String(placeholder.expYear).slice(-2)}`, - ); - } else { - const month = roles.get("month"); - const year = roles.get("year"); - if (month) - await typeFrame(month, String(placeholder.expMonth).padStart(2, "0")); - if (year) await typeFrame(year, String(placeholder.expYear).slice(-2)); - } - const cvc = roles.get("cvc"); - if (!cvc) throw new CheckoutStopped("card security code iframe not found", 2); - await typeFrame(cvc, placeholder.cvc); - const name = roles.get("name"); - if (name) await typeFrame(name, "Test Buyer"); -} - -function frameRole(blob: string): string { - const value = blob.toLowerCase(); - if (/verification|security.?code|cvv|cvc/.test(value)) return "cvc"; - if (/expir/.test(value)) return "expiry"; - if (/month/.test(value) && !/year/.test(value)) return "month"; - if (/year/.test(value)) return "year"; - if (/number/.test(value)) return "number"; - if (/name/.test(value)) return "name"; - return "unknown"; -} - -async function typeFrame(frame: Frame, text: string): Promise { - // Shopify's card iframes put a honeypot input first. It is aria-hidden and - // covered by the real field, so a click on it never lands. - const inputs = frame.locator( - 'input:not([data-honeypot-field]):not([aria-hidden="true"]), textarea:not([data-honeypot-field])', - ); - await inputs.first().waitFor({ state: "attached", timeout: 15_000 }); - const count = await inputs.count(); - let target = inputs.first(); - for (let index = 0; index < count; index += 1) { - const candidate = inputs.nth(index); - const tab = await candidate.getAttribute("tabindex"); - if (tab === "-1") continue; - target = candidate; - break; - } - await target.scrollIntoViewIfNeeded().catch(() => {}); - try { - await target.click({ force: true, timeout: 5_000 }); - } catch (error) { - const page = frame.page(); - if (!(await detectChallenge(page))) throw error; - await assertNoChallenge(page, "during card entry"); - await target.scrollIntoViewIfNeeded().catch(() => {}); - await target.click({ force: true, timeout: 5_000 }); - } - await target.fill(""); - await target.pressSequentially(text, { delay: 20 }); -} - -async function assertReadyToPay( - page: Page, - placeholder: HeldPlaceholder, -): Promise { - const suggestion = page - .getByText(/did you mean|suggested address|address suggestion/i) - .first(); - if (await suggestion.isVisible().catch(() => false)) { - throw new CheckoutStopped("address suggestion still shown before pay", 2); - } - const invalid = await page - .locator('input[aria-invalid="true"], select[aria-invalid="true"]') - .count(); - if (invalid > 0) { - throw new CheckoutStopped(`${invalid} invalid fields before pay`, 2); - } - const alerts = await page - .locator('[role="alert"]') - .allInnerTexts() - .catch(() => [] as string[]); - const fieldError = alerts - .map((alert) => alert.replace(/\s+/g, " ").trim()) - .find((alert) => - /address|phone|email|city|zip|postal|state|required|invalid|enter a/i.test( - alert, - ), - ); - if (fieldError) { - throw new CheckoutStopped( - `field error before pay: ${redact(fieldError).slice(0, 180)}`, - 2, - ); - } - const anyChecked = await page - .locator('input[type="radio"]:checked') - .evaluateAll((nodes) => - nodes.some((node) => - /shipping|delivery|rate/i.test( - `${(node as { name?: string }).name ?? ""} ${(node as { id?: string }).id ?? ""}`, - ), - ), - ) - .catch(() => false); - const radios = await page.locator('input[type="radio"]').count(); - if (radios > 0 && !anyChecked) { - throw new CheckoutStopped("no shipping rate selected before pay", 2); - } - await assertCardHoldsPlaceholder(page, placeholder); - console.log( - `card iframes hold placeholder ${maskCardNumber(placeholder.number)}`, - ); -} - -async function assertCardHoldsPlaceholder( - page: Page, - placeholder: HeldPlaceholder, -): Promise { - const roles = await cardFrames(page); - const number = digits(await cardFieldValue(requiredFrame(roles, "number"))); - const expiryFrame = roles.get("expiry"); - const expiry = expiryFrame ? digits(await cardFieldValue(expiryFrame)) : ""; - const cvc = digits(await cardFieldValue(requiredFrame(roles, "cvc"))); - const numberOk = - number === placeholder.number || - (number.length >= 4 && number.endsWith(placeholder.number.slice(-4))); - const expiryOk = - !expiryFrame || - (expiry.includes(String(placeholder.expMonth).padStart(2, "0")) && - expiry.includes(String(placeholder.expYear).slice(-2))); - const cvcOk = cvc === placeholder.cvc; - if (!numberOk || !expiryOk || !cvcOk) { - throw new CheckoutStopped( - `card iframes do not hold the placeholder (number last4 ${number.slice(-4) || "empty"}, expiry ${expiry ? "present" : "empty"}, cvc ${cvc ? "present" : "empty"})`, - 2, - ); - } -} - -function requiredFrame(roles: Map, role: string): Frame { - const frame = roles.get(role); - if (!frame) throw new CheckoutStopped(`card ${role} iframe not found`, 2); - return frame; -} - -async function cardFieldValue(frame: Frame): Promise { - const inputs = frame.locator( - 'input:not([data-honeypot-field]):not([aria-hidden="true"])', - ); - const count = await inputs.count(); - for (let index = 0; index < count; index += 1) { - const candidate = inputs.nth(index); - if ((await candidate.getAttribute("tabindex")) === "-1") continue; - return candidate.inputValue(); - } - return inputs.first().inputValue(); -} - -function digits(value: string): string { - return value.replace(/\D/g, ""); -} - -async function clickPay(page: Page): Promise { - const named = page.locator("#checkout-pay-button").first(); - await named.waitFor({ state: "attached", timeout: 15_000 }); - mark("clicking pay once"); - // A Shop Pay overlay sits on top of the button and swallows pointer events. - // One DOM click still submits the form. - await named.evaluate((element) => { - (element as { click: () => void }).click(); - }); - mark("pay click returned"); -} - -async function waitForConfirmation(page: Page): Promise { - const deadline = Date.now() + (solverEnabled() ? 240_000 : 60_000); - let captchaAt = 0; - let captchaCleared = false; - let sawSessions = false; - while (Date.now() < deadline) { - const challenge = await detectChallenge(page); - if (challenge) { - if (!captchaAt) { - captchaAt = Date.now(); - mark(`captcha visible after pay (${challenge})`); - } - if (Date.now() - captchaAt > SOLVER_WAIT_MS) { - const waited = ((Date.now() - captchaAt) / 1000).toFixed(1); - throw new CheckoutStopped( - `captcha still shown ${waited}s after pay. Not bypassing it.`, - 3, - ); - } - await page.waitForTimeout(1000); - continue; - } - if (captchaAt && !captchaCleared) { - captchaCleared = true; - mark( - `captcha cleared ${((Date.now() - captchaAt) / 1000).toFixed(1)}s after it appeared`, - ); - } - if (sessionsSeen() && !sawSessions) { - sawSessions = true; - mark("pci sessions observed while waiting for confirmation"); - } - if (/thank[-_]you/i.test(page.url())) { - mark("thank-you page"); - return; - } - const heading = page.getByRole("heading", { name: /thank you/i }).first(); - if (await heading.isVisible().catch(() => false)) { - mark("thank-you heading"); - return; - } - const paymentError = await paymentAlert(page); - if (paymentError) throw new CheckoutStopped(redact(paymentError), 2); - await page.waitForTimeout(500); - } - if (captchaAt && !captchaCleared) { - throw new CheckoutStopped( - "captcha still shown after pay. Not bypassing it.", - 3, - ); - } - throw new CheckoutStopped( - sessionsSeen() - ? `sessions relayed but checkout did not confirm (${redactUrl(page.url())})` - : `pci sessions request was not relayed (${redactUrl(page.url())})`, - 2, - ); -} - -function solverEnabled(): boolean { - return (process.env.BROWSER ?? "local") === "sapiom"; -} - -let sessionsObserved = false; - -function sessionsSeen(): boolean { - return sessionsObserved; -} - -async function paymentAlert(page: Page): Promise { - const alerts = await page - .locator('[role="alert"]') - .allInnerTexts() - .catch(() => [] as string[]); - for (const alert of alerts) { - const text = alert.replace(/\s+/g, " ").trim(); - if (!text || /being processed/i.test(text)) continue; - if (/card|payment|gateway|declined/i.test(text)) return text; - } - return null; -} - -async function assertNoChallenge(page: Page, where: string): Promise { - if (!(await detectChallenge(page))) return; - if (solverEnabled()) { - const seen = Date.now(); - mark(`captcha visible ${where}; waiting up to 180s for the solver`); - while (Date.now() - seen < SOLVER_WAIT_MS) { - if (/thank[-_]you/i.test(page.url())) return; - if (!(await detectChallenge(page))) { - mark( - `captcha cleared ${where} after ${((Date.now() - seen) / 1000).toFixed(1)}s`, - ); - return; - } - await page.waitForTimeout(1000); - } - if (/thank[-_]you/i.test(page.url())) return; - } - const challenge = (await detectChallenge(page)) ?? "visible"; - throw new CheckoutStopped( - `Shopify showed a captcha or bot challenge (${challenge}) ${where}. Not bypassing it.`, - 3, - ); -} - -async function detectChallenge(page: Page): Promise { - const url = page.url(); - if (/hcaptcha\.com|recaptcha|\/checkpoint/i.test(url)) return "url"; - const text = await page - .locator("body") - .innerText({ timeout: 2_000 }) - .catch(() => ""); - if ( - /verify you are human|are you a human|checking your browser|solve the captcha/i.test( - text, - ) - ) { - return "text"; - } - if (/enter store password|opening soon/i.test(text)) return "store-password"; - for (const frame of page.frames()) { - if ( - !/hcaptcha\.com|google\.com\/recaptcha|challenges\.cloudflare/i.test( - frame.url(), - ) - ) { - continue; - } - const element = await frame.frameElement().catch(() => null); - const box = element ? await element.boundingBox().catch(() => null) : null; - if (box && box.width >= 120 && box.height >= 40) return "frame"; - } - return null; -} - -async function diagnostics(page: Page): Promise { - const frames = page.frames().map((frame) => pathOnly(frame.url())); - const buttons = await page - .locator("button") - .allInnerTexts() - .catch(() => [] as string[]); - const fields = await page - .locator("input, select, textarea") - .evaluateAll((nodes) => - nodes.map((node) => { - const field = node as { - name?: string; - id?: string; - value?: string; - getAttribute?: (name: string) => string | null; - }; - const name = - field.name || - field.id || - field.getAttribute?.("aria-label") || - "field"; - if (/number|verif|cvv|cvc|expir|security|code/i.test(name)) { - return `${name}=filled`; - } - return `${name}=${String(field.value ?? "").slice(0, 40)}`; - }), - ) - .catch(() => [] as string[]); - const alerts = await page - .locator('[role="alert"]') - .allInnerTexts() - .catch(() => [] as string[]); - const radios = await page - .locator('input[type="radio"]') - .evaluateAll((nodes) => - nodes.map((node) => { - const input = node as { name?: string; id?: string; checked?: boolean }; - return `${input.name || input.id || "radio"}${input.checked ? " checked" : ""}`; - }), - ) - .catch(() => [] as string[]); - const text = redact( - await page - .locator("body") - .innerText({ timeout: 2_000 }) - .catch(() => ""), - ) - .replace(/\s+/g, " ") - .slice(0, 700); - const challenge = await detectChallenge(page); - return [ - `url ${redactUrl(page.url())}`, - `frames ${frames.join(", ")}`, - `buttons ${buttons - .map((label) => label.replace(/\s+/g, " ").trim()) - .filter(Boolean) - .slice(0, 12) - .join(" | ")}`, - `radios ${radios.join(", ") || "none"}`, - `fields ${fields.join(" | ")}`, - `alerts ${alerts.map((label) => redact(label).replace(/\s+/g, " ").trim()).join(" | ") || "none"}`, - `text ${text}`, - `challenge ${challenge ?? "no"}`, - ].join("\n"); -} - -function pathOnly(url: string): string { - try { - return new URL(url).pathname; - } catch { - return "frame"; - } -} - -function redactUrl(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.origin}${parsed.pathname}`; - } catch { - return "unparsed-url"; - } -} - -function summarize(event: InterceptorEvent): string { - if (event.type === "relayed") return `relayed ${event.status}`; - if (event.type === "refused") return `refused ${event.reason}`; - if (event.type === "failed") return `failed ${event.reason}`; - return "paused"; -} - -async function waitFor( - read: () => Promise, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await read(); - await new Promise((resolve) => setTimeout(resolve, 250)); - } -} - -async function openBrowser(): Promise<{ - cdpUrl: string; - close: () => Promise; -}> { - if ((process.env.BROWSER ?? "local") === "sapiom") { - const apiKey = process.env.SAPIOM_API_KEY; - if (!apiKey) - throw new CheckoutStopped("BROWSER=sapiom requires SAPIOM_API_KEY", 1); - const sapiom: Sapiom = createClient({ apiKey }); - // Anchor disables captcha solving unless the session proxy is active. - // extra stealth uses the same proxy. headless false keeps the browser headful. - // popupBlocker and adblock default on and can block the captcha banner. - const session = await sapiom.browserAutomation.sessions.createManaged({ - idempotencyKey: randomUUID(), - recording: false, - captchaSolver: true, - proxy: true, - stealth: true, - headless: false, - }); - const fields = session as { - sessionId?: string; - session_id?: string; - cdpUrl?: string; - cdp_url?: string; - }; - const sessionId = fields.sessionId || fields.session_id; - const cdpUrl = fields.cdpUrl || fields.cdp_url; - if (!sessionId || !cdpUrl || !/^wss?:\/\//.test(cdpUrl)) { - if (sessionId) { - await sapiom.browserAutomation.sessions - .closeManaged(sessionId) - .catch(() => {}); - } - await sapiom.shutdown().catch(() => {}); - throw new CheckoutStopped( - `Sapiom browser session did not include a CDP websocket (fields: ${Object.keys(session).sort().join(",")})`, - 1, - ); - } - console.log( - "Sapiom browser session created (captchaSolver, proxy, and stealth requested; CDP websocket present)", - ); - return { - cdpUrl, - close: async () => { - await sapiom.browserAutomation.sessions - .closeManaged(sessionId) - .catch(() => {}); - await sapiom.shutdown().catch(() => {}); - }, - }; - } - const executable = [ - process.env.CHROME_PATH, - "/usr/local/bin/google-chrome", - "/usr/bin/google-chrome", - ].find((candidate) => candidate && existsSync(candidate)); - if (!executable) throw new CheckoutStopped("local Chrome was not found", 1); - const userDataDir = await mkdtemp( - path.join(os.tmpdir(), "browser-pay-shopify-"), - ); - const port = await new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const value = typeof address === "object" && address ? address.port : 0; - server.close(() => resolve(value)); - }); - server.on("error", reject); - }); - const chrome: ChildProcess = spawn( - executable, - [ - "--headless=new", - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - `--remote-debugging-port=${port}`, - "--remote-allow-origins=*", - `--user-data-dir=${userDataDir}`, - "--no-first-run", - "--no-default-browser-check", - "about:blank", - ], - { stdio: "ignore" }, - ); - const cdpUrl = await waitForCdp(port, chrome); - return { - cdpUrl, - close: async () => { - chrome.kill("SIGKILL"); - await new Promise((resolve) => setTimeout(resolve, 200)); - await rm(userDataDir, { - recursive: true, - force: true, - maxRetries: 3, - }).catch(() => {}); - }, - }; -} - -async function waitForCdp(port: number, proc: ChildProcess): Promise { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (proc.exitCode !== null) - throw new CheckoutStopped("Chrome exited before CDP was ready", 1); - 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 { - // Chrome is still starting. - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new CheckoutStopped("CDP endpoint did not come up", 1); -} - -function required(name: string): string { - const value = process.env[name]; - if (!value) throw new CheckoutStopped(`${name} is required`, 1); - return value; -} - -function scrub(value: string): string { - return redact(value).replace(/wss?:\/\/\S+/gi, "wss://redacted"); -} - -main().catch((error: unknown) => { - if (error instanceof CheckoutStopped) { - console.error(error.message); - process.exit(error.code); - } - console.error( - scrub(error instanceof Error ? error.message : "checkout failed"), - ); - process.exit(2); -}); diff --git a/packages/browser-pay/package.json b/packages/browser-pay/package.json index 07a0adeec..307badec7 100644 --- a/packages/browser-pay/package.json +++ b/packages/browser-pay/package.json @@ -37,6 +37,7 @@ }, "files": [ "dist", + "!dist/**/*.tsbuildinfo", "README.md", "LICENSE", "CHANGELOG.md" diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index a0170a9f2..987ba2378 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -12,16 +12,53 @@ import type { Duplex } from "node:stream"; export interface CdpEvent { method: string; - params: Record; + params: Record; sessionId?: string; } type Pending = { - resolve: (value: any) => void; + resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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; @@ -280,18 +317,21 @@ export class CdpConnection { private constructor(private readonly socket: WebSocketClient) { socket.onmessage = (data) => { - let message: any; + let parsed: unknown; try { - message = JSON.parse(data); + parsed = JSON.parse(data); } catch { return; } - if (message.id && this.pending.has(message.id)) { - const pending = this.pending.get(message.id)!; + const message = parseCdpMessage(parsed); + if (!message) return; + if (message.id !== undefined && this.pending.has(message.id)) { + const pending = this.pending.get(message.id); + if (!pending) return; this.pending.delete(message.id); clearTimeout(pending.timer); - if (message.error) { - pending.reject(new Error(`CDP ${message.error.message}`)); + if (message.errorMessage) { + pending.reject(new Error(`CDP ${message.errorMessage}`)); } else { pending.resolve(message.result); } @@ -300,7 +340,7 @@ export class CdpConnection { if (!message.method) return; const event: CdpEvent = { method: message.method, - params: message.params ?? {}, + params: message.params, ...(message.sessionId ? { sessionId: message.sessionId } : {}), }; for (const listener of this.listeners) listener(event); @@ -327,14 +367,14 @@ export class CdpConnection { return new CdpConnection(await WebSocketClient.connect(url, timeoutMs)); } - send( + send( method: string, params: Record = {}, sessionId?: string, timeoutMs = 15_000, - ): Promise { + ): Promise { const id = ++this.nextId; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP ${method} timed out`)); diff --git a/packages/browser-pay/src/contract.test.ts b/packages/browser-pay/src/contract.test.ts index aa872b6d8..6cf3fdda8 100644 --- a/packages/browser-pay/src/contract.test.ts +++ b/packages/browser-pay/src/contract.test.ts @@ -1,62 +1,87 @@ -import { parseRelayResponse, relayHeadersFromRecord } from "./contract"; +import { + paymentIdempotencyKey, + parseRelayResponse, + relayHeadersFromRecord, +} from "./contract"; + +describe("paymentIdempotencyKey", () => { + it("is sha256 of method, url, and body, stable for the same triple", () => { + const key = paymentIdempotencyKey("POST", "https://pay.example/s", "{}"); + expect(key).toBe( + paymentIdempotencyKey("POST", "https://pay.example/s", "{}"), + ); + expect(key).not.toBe( + paymentIdempotencyKey("POST", "https://pay.example/s", "{ }"), + ); + expect(key).toMatch(/^[0-9a-f]{64}$/); + }); +}); describe("parseRelayResponse", () => { - it("rejects a payload that is not a success or a refusal", () => { + it("rejects a payload that is not relayed or refused", () => { expect(() => parseRelayResponse(null)).toThrow( "Payment relay returned an unreadable response", ); - expect(() => parseRelayResponse({ ok: false })).toThrow( + expect(() => parseRelayResponse({ kind: "refused" })).toThrow( "Payment relay returned an unreadable response", ); expect(() => - parseRelayResponse({ ok: true, status: 200, body: 1, headers: {} }), + parseRelayResponse({ + kind: "relayed", + status: 200, + body: 1, + headers: {}, + }), ).toThrow("Payment relay returned an unreadable response"); }); it("maps an unknown refusal reason and keeps a known one", () => { expect( parseRelayResponse({ - ok: false, + kind: "refused", reason: "made_up", body: "4242424242424242", }), - ).toEqual({ ok: false, reason: "unknown" }); - expect(parseRelayResponse({ ok: false, reason: "replay" })).toEqual({ - ok: false, - reason: "replay", - }); + ).toEqual({ kind: "refused", reason: "unknown" }); + expect( + parseRelayResponse({ kind: "refused", reason: "already_relayed" }), + ).toEqual({ kind: "refused", reason: "already_relayed" }); + expect( + parseRelayResponse({ kind: "refused", reason: "total_changed" }), + ).toEqual({ kind: "refused", reason: "total_changed" }); }); it("accepts header tuples, repeated Set-Cookie, and a base64 body", () => { + const body = Buffer.from('{"id":"s"}', "utf8").toString("base64"); expect( parseRelayResponse({ - ok: true, + kind: "relayed", status: 200, headers: [ ["content-type", "application/json"], ["set-cookie", "a=1"], ["set-cookie", "b=2"], ], - body: Buffer.from('{"id":"s"}', "utf8").toString("base64"), + body, bodyEncoding: "base64", }), ).toEqual({ - ok: true, + kind: "relayed", status: 200, headers: [ ["content-type", "application/json"], ["set-cookie", "a=1"], ["set-cookie", "b=2"], ], - body: Buffer.from('{"id":"s"}', "utf8").toString("base64"), + body, bodyEncoding: "base64", }); }); - it("accepts a legacy header map and defaults body encoding to utf8", () => { + it("accepts a header map and defaults body encoding to utf8", () => { expect( parseRelayResponse({ - ok: true, + kind: "relayed", status: 201, headers: { "content-type": "application/json", @@ -65,7 +90,7 @@ describe("parseRelayResponse", () => { body: "{}", }), ).toEqual({ - ok: true, + kind: "relayed", status: 201, headers: [ ["content-type", "application/json"], diff --git a/packages/browser-pay/src/contract.ts b/packages/browser-pay/src/contract.ts index ed1a77183..3ee173f79 100644 --- a/packages/browser-pay/src/contract.ts +++ b/packages/browser-pay/src/contract.ts @@ -1,29 +1,34 @@ /** - * T7, the interceptor-to-relay contract (W7 U0). + * T7, the body David's relay accepts on + * `POST /v1/transactions/{transactionId}/payment/relay`. * - * The paused request body may contain the placeholder the agent typed. No field - * on the response is a credential: a success body is the payment provider's - * opaque response, and a refusal names a reason without echoing the request. - * Real card values never travel back to the browser. + * The paused request body may contain the placeholder the agent typed. + * A success body is the payment provider's response. A refusal names a + * reason and does not echo the request. Real card values never travel + * back to the browser. * - * `headers` may include browser cookies. The relay backend must not log or - * persist them. The backend also scrubs provider response bodies and headers - * before they are returned; the interceptor refuses to fulfill a response that - * still contains a card number other than the held placeholder. + * `headers` may include browser cookies. The relay must not log or persist + * them. The backend scrubs provider response bodies before they return. + * This package also refuses to fulfill a response that still contains a + * Luhn-valid card number other than the held placeholder. + * + * Refusal reasons are the ones named on the relay: `already_relayed` and + * `total_changed` (SAP-4071, PR 5792), `key_rotated` and `card_removed` + * (PR 5754), and the checkPlaceholderUse answers `expired`, `closed`, + * `refused`, and `unknown` (PR 5758, updated in 5754). An unrecognized + * reason is reported as `unknown`. */ -export const RELAY_CONTRACT_VERSION = 1; +import { createHash } from "node:crypto"; export const RELAY_REFUSAL_REASONS = [ - "wrong_key", - "unknown_placeholder", - "closed_placeholder", - "expired_placeholder", - "off_profile_destination", - "replay", - "merchant_origin", - "changed_total", - "cancelled", + "already_relayed", + "total_changed", + "key_rotated", + "card_removed", + "expired", + "closed", + "refused", "unknown", ] as const; @@ -35,61 +40,67 @@ export type RelayBodyEncoding = "utf8" | "base64"; export type RelayHeader = [name: string, value: string]; /** - * What the interceptor sends to the relay. - * `merchantOrigin` is the top-level page origin (scheme + host), the payTo - * value bound at authorization. It is never the card iframe's processor host. - * `frameOrigin` is the origin of the frame that issued the request. + * What the interceptor sends. `merchantOrigin` is the top-level page origin + * (scheme + host), the payTo value bound at authorization. + * `idempotencyKey` is sha256(method, url, body). The HTTP client also sends + * it as the `idempotency-key` header. Shopify's card iframe retries share + * one key. */ export interface RelayRequest { - version: typeof RELAY_CONTRACT_VERSION; transactionId: string; injectionRef: string; merchantOrigin: string; - frameOrigin?: string; url: string; method: string; headers: RelayHeader[]; body: string; - bodyEncoding: RelayBodyEncoding; - /** - * CDP request id plus a nonce. The relay uses it to recognize a retry of - * the same paused request (`replay`). - */ idempotencyKey: string; } /** Provider response to fulfill the paused browser request with. */ export interface RelaySuccess { - ok: true; - /** Provider HTTP status. */ + kind: "relayed"; status: number; headers: RelayHeader[]; - /** - * Provider response body. Must not contain a real card value. - * `bodyEncoding` says how `body` is encoded. - */ + /** Provider response body. `bodyEncoding` says how `body` is encoded. */ body: string; bodyEncoding: RelayBodyEncoding; } /** Verification failed. Nothing was forwarded. The request is not echoed. */ export interface RelayRefusal { - ok: false; + kind: "refused"; reason: RelayRefusalReason; } export type RelayResponse = RelaySuccess | RelayRefusal; +/** Stable across retries of the same paused request. */ +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"); +} + export function isRelayRefusalReason( value: string, ): value is RelayRefusalReason { - return (RELAY_REFUSAL_REASONS as readonly string[]).includes(value); + for (const reason of RELAY_REFUSAL_REASONS) { + if (reason === value) return true; + } + return false; } -export function isRelayBodyEncoding( - value: unknown, -): value is RelayBodyEncoding { - return value === "utf8" || value === "base64"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function isRelayHeaderList(value: unknown): value is RelayHeader[] { @@ -124,55 +135,82 @@ export function relayHeadersFromRecord( function headersFromUnknown(value: unknown): RelayHeader[] | undefined { if (isRelayHeaderList(value)) return value; - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) return undefined; + const entries = Object.entries(value); + if (!entries.every(([, entry]) => typeof entry === "string")) { return undefined; } - const record = value as Record; - if (!Object.values(record).every((entry) => typeof entry === "string")) { - return undefined; + const record: Record = {}; + for (const [key, entry] of entries) { + if (typeof entry === "string") record[key] = entry; } - return relayHeadersFromRecord(record as Record); + return relayHeadersFromRecord(record); +} + +function isBodyEncoding(value: unknown): value is RelayBodyEncoding { + return value === "utf8" || value === "base64"; +} + +function readReason(value: unknown): RelayRefusalReason | undefined { + if (typeof value !== "string") return undefined; + return isRelayRefusalReason(value) ? value : "unknown"; } /** - * Accept a success payload or a typed refusal. An unknown refusal reason - * becomes `unknown` so a newer backend does not look like a network error. - * Anything else throws, and the message never includes the payload (it may - * contain a card or a request echo). + * Accept `{ kind: "relayed", status, headers, body }` or + * `{ kind: "refused", reason }`. `bodyEncoding` defaults to utf8. + * The error never includes the payload. */ export function parseRelayResponse(value: unknown): RelayResponse { - if (!value || typeof value !== "object") { + if (!isRecord(value)) { throw new Error("Payment relay returned an unreadable response"); } - const body = value as Record; - if (body.ok === true) { - const headers = headersFromUnknown(body.headers); + if (value.kind === "refused") { + const reason = readReason(value.reason); + if (!reason) { + throw new Error("Payment relay returned an unreadable response"); + } + return { kind: "refused", reason }; + } + if (value.kind === "relayed") { + const headers = headersFromUnknown(value.headers); const bodyEncoding = - body.bodyEncoding === undefined ? "utf8" : body.bodyEncoding; + value.bodyEncoding === undefined ? "utf8" : value.bodyEncoding; if ( - typeof body.status !== "number" || - !Number.isInteger(body.status) || - body.status < 100 || - body.status > 599 || - typeof body.body !== "string" || + typeof value.status !== "number" || + !Number.isInteger(value.status) || + value.status < 100 || + value.status > 599 || + typeof value.body !== "string" || !headers || - !isRelayBodyEncoding(bodyEncoding) + !isBodyEncoding(bodyEncoding) ) { throw new Error("Payment relay returned an unreadable response"); } return { - ok: true, - status: body.status, + kind: "relayed", + status: value.status, headers, - body: body.body, + body: value.body, bodyEncoding, }; } - if (body.ok === false && typeof body.reason === "string") { - return { - ok: false, - reason: isRelayRefusalReason(body.reason) ? body.reason : "unknown", - }; - } throw new Error("Payment relay returned an unreadable response"); } + +export function isRelayRequest(value: unknown): value is RelayRequest { + if (!isRecord(value)) return false; + return ( + typeof value.transactionId === "string" && + value.transactionId.length > 0 && + typeof value.injectionRef === "string" && + value.injectionRef.length > 0 && + typeof value.merchantOrigin === "string" && + typeof value.url === "string" && + typeof value.method === "string" && + typeof value.body === "string" && + typeof value.idempotencyKey === "string" && + value.idempotencyKey.length > 0 && + isRelayHeaderList(value.headers) + ); +} diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts index 6c16c9de5..2a4266ba6 100644 --- a/packages/browser-pay/src/detect.test.ts +++ b/packages/browser-pay/src/detect.test.ts @@ -1,6 +1,5 @@ import { - cardNumberCandidate, - matchHeldPlaceholder, + luhnHeldPan, matchHeldPlaceholderInBody, textContainsForeignCard, textHoldsPlaceholderNumber, @@ -16,190 +15,88 @@ const HELD: HeldPlaceholder = { injectionRef: "inj_test", }; -const json = (card: Record) => - JSON.stringify({ credit_card: card }); +const FOREIGN: HeldPlaceholder = { + ...HELD, + number: "4111111111111111", + injectionRef: "inj_foreign", +}; -describe("cardNumberCandidate", () => { - it("accepts a Luhn-valid number and strips separators", () => { - expect(cardNumberCandidate("4242424242424242")).toBe("4242424242424242"); - expect(cardNumberCandidate("4242-4242-4242-4242")).toBe("4242424242424242"); - expect(cardNumberCandidate("4242 4242 4242 4242")).toBe("4242424242424242"); +describe("luhn gate", () => { + it("accepts a Luhn-valid number and rejects a check-digit failure", () => { + expect(luhnHeldPan("4242 4242 4242 4242")).toBe("4242424242424242"); + expect(luhnHeldPan("4242424242424243")).toBeNull(); + expect(luhnHeldPan("424242424242")).toBeNull(); }); - it("rejects a Luhn failure, a short run, and an unrelated valid number's check", () => { - expect(cardNumberCandidate("4242424242424243")).toBeNull(); - expect(cardNumberCandidate("424242424242")).toBeNull(); - expect(cardNumberCandidate("not a card")).toBeNull(); - }); -}); - -describe("matchHeldPlaceholder", () => { - it("matches the held number when expiry and cvc agree, including a two-digit year", () => { + it("does not match when the held number fails Luhn", () => { + const held: HeldPlaceholder = { ...HELD, number: "4242424242424243" }; expect( - matchHeldPlaceholder( - { number: "4242424242424242", expMonth: 12, expYear: 28, cvc: "123" }, - [HELD], + matchHeldPlaceholderInBody( + JSON.stringify({ number: "4242424242424243" }), + held, ), - ).toEqual({ kind: "match", injectionRef: "inj_test" }); - }); - - it("returns not_held for a different Luhn-valid number", () => { - expect( - matchHeldPlaceholder({ number: "4111111111111111" }, [HELD]), ).toEqual({ kind: "not_held" }); }); - - it("returns not_a_card_number when the value fails Luhn", () => { - expect( - matchHeldPlaceholder({ number: "4242424242424243" }, [HELD]), - ).toEqual({ kind: "not_a_card_number" }); - }); }); describe("matchHeldPlaceholderInBody", () => { - it("matches a JSON body", () => { - const body = json({ - number: "4242424242424242", - month: 12, - year: 2028, - verification_value: "123", - name: "Fixture Buyer", - }); - expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + it("matches the held number anywhere, including grouped digits", () => { + expect( + matchHeldPlaceholderInBody(`note 4242 4242 4242 4242 trailing`, HELD), + ).toEqual({ kind: "match", injectionRef: "inj_test" }); + expect(matchHeldPlaceholderInBody("4242-4242-4242-4242", HELD)).toEqual({ kind: "match", injectionRef: "inj_test", }); }); - it("matches a JSON number written with separators", () => { - const body = json({ - number: "4242-4242-4242-4242", - month: "12", - year: "28", - verification_value: "123", + it("matches a JSON body and a form body without comparing expiry or cvc", () => { + const json = JSON.stringify({ + credit_card: { + number: "4242 4242 4242 4242", + month: 1, + year: 1999, + verification_value: "999", + }, }); - expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + expect(matchHeldPlaceholderInBody(json, HELD)).toEqual({ kind: "match", injectionRef: "inj_test", }); - }); - - it("matches a form body", () => { - const body = - "credit_card[number]=4242+4242+4242+4242&credit_card[month]=12&credit_card[year]=2028&credit_card[verification_value]=123"; - expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + const form = + "credit_card[number]=4242+4242+4242+4242&credit_card[month]=1&credit_card[verification_value]=000"; + expect(matchHeldPlaceholderInBody(form, HELD)).toEqual({ kind: "match", injectionRef: "inj_test", }); }); - it("matches on the number alone when expiry and cvc are absent", () => { - expect( - matchHeldPlaceholderInBody(json({ number: "4242 4242 4242 4242" }), HELD), - ).toEqual({ kind: "match", injectionRef: "inj_test" }); - }); - - it("does not treat a Luhn failure as the placeholder", () => { + it("matches percent-encoded and JSON-escaped separators", () => { expect( - matchHeldPlaceholderInBody( - json({ - number: "4242424242424243", - month: 12, - year: 2028, - verification_value: "123", - }), - HELD, + textHoldsPlaceholderNumber( + encodeURIComponent("4242 4242 4242 4242"), + HELD.number, ), - ).toEqual({ kind: "not_held" }); - }); - - it("still sees the placeholder when more digits follow it", () => { - expect(matchHeldPlaceholderInBody("4242 4242 4242 4242 123", HELD)).toEqual( - { kind: "match", injectionRef: "inj_test" }, - ); - expect(matchHeldPlaceholderInBody("4242 4242 4242 4242 999", HELD)).toEqual( - { kind: "mismatch", injectionRef: "inj_test", mismatch: "cvc" }, - ); + ).toBe(true); expect( - matchHeldPlaceholderInBody( - json({ number: "4242 4242 4242 4242 12/28" }), - HELD, - ), + matchHeldPlaceholderInBody("4242%204242%204242%204242", HELD), ).toEqual({ kind: "match", injectionRef: "inj_test" }); expect( - matchHeldPlaceholderInBody("4242 4242 4242 4242 01/28", HELD), - ).toEqual({ - kind: "mismatch", - injectionRef: "inj_test", - mismatch: "expiry", - }); - const boundary = "----WebKitFormBoundary123"; - const multipart = `--${boundary}\r\nContent-Disposition: form-data; name="number"\r\n\r\n4242424242424242\r\n--${boundary}--`; - expect(matchHeldPlaceholderInBody(multipart, HELD)).toEqual({ - kind: "match", - injectionRef: "inj_test", - }); - }); - - it("mismatches a held number with the wrong cvc", () => { - expect( - matchHeldPlaceholderInBody( - json({ - number: "4242424242424242", - month: 12, - year: 2028, - verification_value: "999", - }), - HELD, - ), - ).toEqual({ kind: "mismatch", injectionRef: "inj_test", mismatch: "cvc" }); + matchHeldPlaceholderInBody("4242\\u00204242\\u00204242\\u00204242", HELD), + ).toEqual({ kind: "match", injectionRef: "inj_test" }); }); - it("mismatches a held number with the wrong expiry", () => { - expect( - matchHeldPlaceholderInBody( - json({ - number: "4242424242424242", - month: 1, - year: 2028, - verification_value: "123", - }), - HELD, - ), - ).toEqual({ - kind: "mismatch", - injectionRef: "inj_test", - mismatch: "expiry", + it("does not match a foreign Luhn-valid number", () => { + const body = JSON.stringify({ + credit_card: { number: "4111111111111111", month: 12, year: 2028 }, }); - expect( - matchHeldPlaceholderInBody( - json({ - number: "4242424242424242", - month: 12, - year: 2030, - verification_value: "123", - }), - HELD, - ), - ).toEqual({ - kind: "mismatch", - injectionRef: "inj_test", - mismatch: "expiry", + expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ + kind: "not_held", + }); + expect(matchHeldPlaceholderInBody(body, FOREIGN)).toEqual({ + kind: "match", + injectionRef: "inj_foreign", }); - }); - - it("returns not_held for an unrelated card number", () => { - expect( - matchHeldPlaceholderInBody( - json({ - number: "4111111111111111", - month: 12, - year: 2028, - verification_value: "123", - }), - HELD, - ), - ).toEqual({ kind: "not_held" }); }); it("returns not_held for an empty body and for digits that are not a card", () => { @@ -209,15 +106,20 @@ describe("matchHeldPlaceholderInBody", () => { }); }); - it("finds a percent-encoded placeholder and ignores a different Luhn card inside a longer run", () => { - const encoded = encodeURIComponent("4242 4242 4242 4242"); - expect(textHoldsPlaceholderNumber(encoded, HELD.number)).toBe(true); - expect(matchHeldPlaceholderInBody(encoded, HELD)).toEqual({ - kind: "match", - injectionRef: "inj_test", - }); + it("still sees the placeholder when more digits follow it", () => { + expect(matchHeldPlaceholderInBody("4242 4242 4242 4242 999", HELD)).toEqual( + { kind: "match", injectionRef: "inj_test" }, + ); + }); +}); + +describe("textContainsForeignCard", () => { + it("flags a different Luhn card and ignores the held number", () => { expect(textContainsForeignCard("4111111111111111", HELD.number)).toBe(true); expect(textContainsForeignCard(HELD.number, HELD.number)).toBe(false); + expect(textContainsForeignCard(HELD.number, "4242 4242 4242 4242")).toBe( + false, + ); expect(textContainsForeignCard('{"id":"west-fixture"}', HELD.number)).toBe( false, ); diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts index f9f19bcb5..15e9d3204 100644 --- a/packages/browser-pay/src/detect.ts +++ b/packages/browser-pay/src/detect.ts @@ -1,13 +1,12 @@ /** - * Recognizing the one placeholder this interceptor was given, inside a paused - * request body. + * Whether a paused request body holds the one placeholder this interceptor + * was given. * - * This is a copy of the backend's placeholder-detection logic (card-number - * candidate, Luhn, expiry/cvc comparison). sapiom-js cannot import that module. - * It should move to a shared package. Do not drift the comparison rules. - * - * A typed number might be a real card. This function only decides whether the - * body contains the placeholder already held; it never sends a number anywhere. + * Luhn gate first: the held number has to be 13–19 digits and pass Luhn, or + * nothing matches. Then the exact digit sequence, anywhere in the raw body. + * Digits may be grouped by spaces or dashes, including the URL-encoded and + * JSON-escaped forms of those separators. Expiry and CVC are not compared. + * A different Luhn-valid number does not match. */ /** A placeholder as the API hands it to the agent. */ @@ -19,62 +18,12 @@ export interface HeldPlaceholder { injectionRef: string; } -/** Card fields found in a request body. Expiry and CVC only when present. */ -export interface TypedCard { - number: string; - expMonth?: number; - /** Two or four digits. */ - expYear?: number; - cvc?: string; -} - -export type HeldPlaceholderMatch = - | { kind: "not_a_card_number" } - | { kind: "not_held" } - | { kind: "mismatch"; injectionRef: string; mismatch: "expiry" | "cvc" } - | { kind: "match"; injectionRef: string }; +export type BodyPlaceholderMatch = + | { kind: "match"; injectionRef: string } + | { kind: "not_held" }; -export type BodyPlaceholderMatch = Exclude< - HeldPlaceholderMatch, - { kind: "not_a_card_number" } ->; - -/** - * A value as a card number worth comparing: separators removed, 13 to 19 - * digits, valid Luhn check digit. Null when it cannot be a card number. - */ -export function cardNumberCandidate(raw: string): string | null { - const number = raw.replace(/[\s-]/g, ""); - if (!/^\d{13,19}$/.test(number)) return null; - return luhnCheckDigit(number.slice(0, -1)) === number.slice(-1) - ? number - : null; -} - -/** Compares one typed card with the placeholders the caller holds. */ -export function matchHeldPlaceholder( - typed: TypedCard, - held: readonly HeldPlaceholder[], -): HeldPlaceholderMatch { - const number = cardNumberCandidate(typed.number); - if (!number) return { kind: "not_a_card_number" }; - const placeholder = held.find((candidate) => candidate.number === number); - if (!placeholder) return { kind: "not_held" }; - const { injectionRef } = placeholder; - if (typed.expMonth !== undefined && typed.expMonth !== placeholder.expMonth) { - return { kind: "mismatch", injectionRef, mismatch: "expiry" }; - } - if ( - typed.expYear !== undefined && - typed.expYear % 100 !== placeholder.expYear % 100 - ) { - return { kind: "mismatch", injectionRef, mismatch: "expiry" }; - } - if (typed.cvc !== undefined && typed.cvc !== placeholder.cvc) { - return { kind: "mismatch", injectionRef, mismatch: "cvc" }; - } - return { kind: "match", injectionRef }; -} +/** Separators the card field may insert between digits, raw or encoded. */ +const SEPARATOR = "(?:[ \\t-]|\\+|%20|%2[Dd]|\\\\u0020|\\\\u002[Dd])?"; export function luhnCheckDigit(body: string): string { let sum = 0; @@ -89,17 +38,48 @@ export function luhnCheckDigit(body: string): string { return String((10 - (sum % 10)) % 10); } +/** Digits of a held number that pass Luhn. Null when the gate is closed. */ +export function luhnHeldPan(number: string): string | null { + const digits = number.replace(/[\s-]/g, ""); + if (!/^\d{13,19}$/.test(digits)) return null; + return luhnCheckDigit(digits.slice(0, -1)) === digits.slice(-1) + ? digits + : null; +} + +function placeholderPattern(pan: string): RegExp { + return new RegExp(pan.split("").join(SEPARATOR)); +} + +function decodeForSearch(text: string): string { + const withUnicode = text.replace( + /\\u([0-9a-fA-F]{4})/g, + (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), + ); + const plus = withUnicode.replace(/\+/g, " "); + try { + return decodeURIComponent(plus); + } catch { + return plus; + } +} + /** - * Whether `text` contains the held number. Separators, `+`, `%20`, percent - * escapes, and JSON `\u` escapes are removed first. One placeholder is held, - * so a longer digit run that still contains that number counts. + * True when `text` contains the held PAN. The PAN must already have passed + * the Luhn gate. Grouped, percent-encoded, and JSON-escaped forms count. */ export function textHoldsPlaceholderNumber( text: string, number: string, ): boolean { - if (!/^\d{13,19}$/.test(number) || text.length === 0) return false; - return normalizeForHeldSearch(text).includes(number); + const pan = luhnHeldPan(number); + if (!pan || text.length === 0) return false; + const pattern = placeholderPattern(pan); + if (pattern.test(text)) return true; + const decoded = decodeForSearch(text); + if (decoded === text) return false; + pattern.lastIndex = 0; + return pattern.test(decoded); } /** @@ -110,7 +90,9 @@ export function textContainsForeignCard( text: string, placeholderNumber: string, ): boolean { - const decoded = decodeForHeldSearch(text); + const held = luhnHeldPan(placeholderNumber); + if (!held) return false; + const decoded = decodeForSearch(text); const runs = decoded.match(/\d(?:[\s+-]*\d)+/g) ?? []; for (const run of runs) { const digits = run.replace(/\D/g, ""); @@ -119,13 +101,8 @@ export function textContainsForeignCard( for (let length = 13; length <= last; length++) { for (let index = 0; index + length <= digits.length; index++) { const candidate = digits.slice(index, index + length); - if ( - candidate === placeholderNumber || - placeholderNumber.includes(candidate) - ) { - continue; - } - if (cardNumberCandidate(candidate)) return true; + if (candidate === held || held.includes(candidate)) continue; + if (luhnHeldPan(candidate)) return true; } } } @@ -134,352 +111,14 @@ export function textContainsForeignCard( /** * Whether `body` contains the single held placeholder. - * `not_held` means the placeholder number is not in the body (other card - * numbers included). `mismatch` means the number is present but the expiry or - * CVC beside it disagrees. `match` carries only the injection ref. - * - * The structured parse decides match versus mismatch. If that parse misses - * the number because more digits follow it, the separator-stripped body is - * searched for the held number and the request is still not `not_held`. + * `not_held` covers a foreign card, a Luhn failure, and an empty body. */ export function matchHeldPlaceholderInBody( body: string, held: HeldPlaceholder, ): BodyPlaceholderMatch { - const cards = extractTypedCards(body); - let mismatch: Extract | null = - null; - for (const card of cards) { - const result = matchHeldPlaceholder(card, [held]); - if (result.kind === "match") { - return { kind: "match", injectionRef: result.injectionRef }; - } - if (result.kind === "mismatch" && !mismatch) mismatch = result; - } - if (mismatch) return mismatch; if (!textHoldsPlaceholderNumber(body, held.number)) { return { kind: "not_held" }; } - return classifyAdjacentHeld(body, held); -} - -/** Card numbers in a body, with expiry and CVC taken from the same object or form group. */ -export function extractTypedCards(body: string): TypedCard[] { - const structured = extractStructured(body); - const covered = new Set( - structured.map((card) => cardNumberCandidate(card.number) ?? card.number), - ); - const extras: TypedCard[] = []; - for (const number of scanRawNumbers(body)) { - if (!covered.has(number)) extras.push({ number }); - } - return [...structured, ...extras]; -} - -function extractStructured(body: string): TypedCard[] { - const trimmed = body.trim(); - if (!trimmed) return []; - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - const cards: TypedCard[] = []; - walk(JSON.parse(trimmed), undefined, undefined, cards); - return cards; - } catch { - // Not JSON. Fall through to form encoding when it looks like a form. - } - } - if (trimmed.includes("=")) return extractForm(trimmed); - return []; -} - -function walk( - value: unknown, - container: Record | undefined, - parent: Record | undefined, - out: TypedCard[], -): void { - if (typeof value === "string") { - const whole = cardNumberCandidate(value); - const numbers = whole ? [whole] : scanRawNumbers(value); - for (const number of numbers) { - out.push(cardFromContext(number, container, parent)); - } - return; - } - if (Array.isArray(value)) { - for (const item of value) walk(item, undefined, undefined, out); - return; - } - if (value && typeof value === "object") { - const obj = value as Record; - for (const child of Object.values(obj)) walk(child, obj, container, out); - } -} - -function cardFromContext( - number: string, - container: Record | undefined, - parent: Record | undefined, -): TypedCard { - const own = readCardFields(container); - const inherited = readCardFields(parent); - return { - number, - ...fill(inherited, own), - }; -} - -function fill( - fallback: Partial, - primary: Partial, -): Partial { - return { - ...(primary.expMonth !== undefined - ? { expMonth: primary.expMonth } - : fallback.expMonth !== undefined - ? { expMonth: fallback.expMonth } - : {}), - ...(primary.expYear !== undefined - ? { expYear: primary.expYear } - : fallback.expYear !== undefined - ? { expYear: fallback.expYear } - : {}), - ...(primary.cvc !== undefined - ? { cvc: primary.cvc } - : fallback.cvc !== undefined - ? { cvc: fallback.cvc } - : {}), - }; -} - -function extractForm(body: string): TypedCard[] { - let params: URLSearchParams; - try { - params = new URLSearchParams(body); - } catch { - return []; - } - const groups = new Map>(); - for (const [key, value] of params) { - const { prefix, field } = splitFormKey(key); - const group = groups.get(prefix) ?? {}; - group[field] = value; - groups.set(prefix, group); - } - const cards: TypedCard[] = []; - for (const group of groups.values()) { - for (const value of Object.values(group)) { - const number = cardNumberCandidate(value); - if (!number) continue; - cards.push({ number, ...readCardFields(group) }); - } - } - return cards; -} - -function splitFormKey(key: string): { prefix: string; field: string } { - const bracket = key.match(/^(.*)\[([^\]]+)\]$/); - if (bracket) return { prefix: bracket[1], field: bracket[2] }; - const dot = key.match(/^(.*)\.([^.]+)$/); - if (dot) return { prefix: dot[1], field: dot[2] }; - return { prefix: "", field: key }; -} - -type FieldKind = "month" | "year" | "expiry" | "cvc" | "other"; - -function fieldKind(key: string): FieldKind { - const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); - if ( - normalized.includes("startmonth") || - normalized.includes("startyear") || - normalized.includes("issuen") - ) { - return "other"; - } - if ( - normalized.includes("cvc") || - normalized.includes("cvv") || - normalized.includes("csc") || - normalized.includes("securitycode") || - normalized.includes("verificationvalue") || - normalized.includes("cardverification") - ) { - return "cvc"; - } - if ( - normalized.includes("expyear") || - normalized.includes("expirationyear") || - normalized.includes("expiryyear") || - normalized === "year" || - normalized.endsWith("year") - ) { - return "year"; - } - if ( - normalized.includes("expmonth") || - normalized.includes("expirationmonth") || - normalized.includes("expirymonth") || - normalized === "month" || - normalized.endsWith("month") - ) { - return "month"; - } - if ( - normalized === "exp" || - normalized === "expiry" || - normalized === "expiration" || - normalized.includes("expdate") || - normalized === "ccexp" - ) { - return "expiry"; - } - return "other"; -} - -function readCardFields( - obj: Record | undefined, -): Partial { - if (!obj) return {}; - const fields: Partial = {}; - for (const [key, value] of Object.entries(obj)) { - const kind = fieldKind(key); - if (kind === "month" && fields.expMonth === undefined) { - const month = readPresentMonth(value); - if (month !== undefined) fields.expMonth = month; - } else if (kind === "year" && fields.expYear === undefined) { - const year = readPresentYear(value); - if (year !== undefined) fields.expYear = year; - } else if (kind === "cvc" && fields.cvc === undefined) { - const cvc = readPresentCvc(value); - if (cvc !== undefined) fields.cvc = cvc; - } else if (kind === "expiry" && fields.expMonth === undefined) { - const expiry = readExpiry(value); - if (expiry) { - fields.expMonth = expiry.expMonth; - if (fields.expYear === undefined) fields.expYear = expiry.expYear; - } else if (value !== null && value !== undefined && value !== "") { - fields.expMonth = Number.NaN; - } - } - } - return fields; -} - -function readPresentMonth(value: unknown): number | undefined { - if (value === null || value === undefined || value === "") return undefined; - const month = readMonth(value); - return month === undefined ? Number.NaN : month; -} - -function readPresentYear(value: unknown): number | undefined { - if (value === null || value === undefined || value === "") return undefined; - const year = readYear(value); - return year === undefined ? Number.NaN : year; -} - -function readPresentCvc(value: unknown): string | undefined { - if (typeof value === "number" && Number.isFinite(value)) return String(value); - if (typeof value === "string" && value.trim() !== "") return value.trim(); - return undefined; -} - -function readMonth(value: unknown): number | undefined { - const raw = - typeof value === "number" - ? String(value) - : typeof value === "string" - ? value.trim() - : ""; - if (!/^\d{1,2}$/.test(raw)) return undefined; - const month = Number(raw); - if (month < 1 || month > 12) return undefined; - return month; -} - -function readYear(value: unknown): number | undefined { - const raw = - typeof value === "number" - ? String(value) - : typeof value === "string" - ? value.trim() - : ""; - if (!/^\d{2}$|^\d{4}$/.test(raw)) return undefined; - return Number(raw); -} - -function readExpiry( - value: unknown, -): { expMonth: number; expYear: number } | undefined { - if (typeof value !== "string") return undefined; - const match = value.trim().match(/^(0?[1-9]|1[0-2])\s*[/-]\s*(\d{2}|\d{4})$/); - if (!match) return undefined; - return { expMonth: Number(match[1]), expYear: Number(match[2]) }; -} - -function classifyAdjacentHeld( - body: string, - held: HeldPlaceholder, -): BodyPlaceholderMatch { - const sources = [body]; - const decoded = decodeForHeldSearch(body); - if (decoded !== body) sources.push(decoded); - const pattern = new RegExp(held.number.split("").join("[\\s+-]*"), "g"); - for (const source of sources) { - for (const match of source.matchAll(pattern)) { - const after = source.slice((match.index ?? 0) + match[0].length); - const expiry = after.match( - /^\s*(0?[1-9]|1[0-2])\s*[/-]\s*(\d{2}|\d{4})(?!\d)/, - ); - if (expiry) { - const expMonth = Number(expiry[1]); - const expYear = Number(expiry[2]); - if ( - expMonth !== held.expMonth || - expYear % 100 !== held.expYear % 100 - ) { - return { - kind: "mismatch", - injectionRef: held.injectionRef, - mismatch: "expiry", - }; - } - continue; - } - const cvc = after.match(/^\s*(\d{3,4})(?!\d)/); - if (cvc && cvc[1] !== held.cvc) { - return { - kind: "mismatch", - injectionRef: held.injectionRef, - mismatch: "cvc", - }; - } - } - } return { kind: "match", injectionRef: held.injectionRef }; } - -function decodeForHeldSearch(text: string): string { - const withUnicode = text.replace( - /\\u([0-9a-fA-F]{4})/g, - (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), - ); - const plus = withUnicode.replace(/\+/g, " "); - try { - return decodeURIComponent(plus); - } catch { - return plus; - } -} - -function normalizeForHeldSearch(text: string): string { - return decodeForHeldSearch(text).replace(/[\s+-]|%20/gi, ""); -} - -function scanRawNumbers(body: string): string[] { - const found = new Set(); - for (const match of body.matchAll(/\d(?:[\s-]*\d){12,18}/g)) { - const candidate = cardNumberCandidate(match[0]); - if (candidate) found.add(candidate); - } - return [...found]; -} diff --git a/packages/browser-pay/src/fixtures.ts b/packages/browser-pay/src/fixtures.ts index ff5fab420..2b81c0732 100644 --- a/packages/browser-pay/src/fixtures.ts +++ b/packages/browser-pay/src/fixtures.ts @@ -1,24 +1,67 @@ import shopifyCardSessionJson from "./fixtures/shopify-card-session.json"; import refusalJson from "./fixtures/refusals.json"; -import type { - RelayRefusal, - RelayRefusalReason, - RelayRequest, - RelaySuccess, +import { + isRelayRequest, + parseRelayResponse, + RELAY_REFUSAL_REASONS, + type RelayRefusal, + type RelayRefusalReason, + type RelayRequest, + type RelaySuccess, } from "./contract.js"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function loadRoundTrip(value: unknown): { + request: RelayRequest; + response: RelaySuccess; +} { + if (!isRecord(value) || !isRelayRequest(value.request)) { + throw new Error("Shopify card-session fixture request is not a T7 body"); + } + const response = parseRelayResponse(value.response); + if (response.kind !== "relayed") { + throw new Error("Shopify card-session fixture response is not relayed"); + } + return { request: value.request, response }; +} + +function loadRefusals( + value: unknown, +): Record { + if (!isRecord(value)) { + throw new Error("Refusal fixtures are unreadable"); + } + const out: Partial> = {}; + for (const reason of RELAY_REFUSAL_REASONS) { + const parsed = parseRelayResponse(value[reason]); + if (parsed.kind !== "refused" || parsed.reason !== reason) { + throw new Error("Refusal fixture does not match its reason"); + } + out[reason] = parsed; + } + if (!refusalsComplete(out)) { + throw new Error("Refusal fixtures are missing a reason"); + } + return out; +} + +function refusalsComplete( + value: Partial>, +): value is Record { + return RELAY_REFUSAL_REASONS.every((reason) => value[reason] !== undefined); +} + /** * One Shopify card-session round trip. The number in the request body is a * placeholder stand-in (a published test PAN), not a credential. The response * is only the provider session id. */ -export const shopifyCardSessionRoundTrip = shopifyCardSessionJson as { - request: RelayRequest; - response: RelaySuccess; -}; +export const shopifyCardSessionRoundTrip = loadRoundTrip( + shopifyCardSessionJson, +); -/** One refusal per reason W3's relay can return. None of them echo the request. */ -export const relayRefusalFixtures = refusalJson as Record< - RelayRefusalReason, - RelayRefusal ->; +/** One refusal per reason the relay can return. None of them echo the request. */ +export const relayRefusalFixtures = loadRefusals(refusalJson); diff --git a/packages/browser-pay/src/fixtures/refusals.json b/packages/browser-pay/src/fixtures/refusals.json index 7b827d0e7..8b69411c1 100644 --- a/packages/browser-pay/src/fixtures/refusals.json +++ b/packages/browser-pay/src/fixtures/refusals.json @@ -1,15 +1,10 @@ { - "wrong_key": { "ok": false, "reason": "wrong_key" }, - "unknown_placeholder": { "ok": false, "reason": "unknown_placeholder" }, - "closed_placeholder": { "ok": false, "reason": "closed_placeholder" }, - "expired_placeholder": { "ok": false, "reason": "expired_placeholder" }, - "off_profile_destination": { - "ok": false, - "reason": "off_profile_destination" - }, - "replay": { "ok": false, "reason": "replay" }, - "merchant_origin": { "ok": false, "reason": "merchant_origin" }, - "changed_total": { "ok": false, "reason": "changed_total" }, - "cancelled": { "ok": false, "reason": "cancelled" }, - "unknown": { "ok": false, "reason": "unknown" } + "already_relayed": { "kind": "refused", "reason": "already_relayed" }, + "total_changed": { "kind": "refused", "reason": "total_changed" }, + "key_rotated": { "kind": "refused", "reason": "key_rotated" }, + "card_removed": { "kind": "refused", "reason": "card_removed" }, + "expired": { "kind": "refused", "reason": "expired" }, + "closed": { "kind": "refused", "reason": "closed" }, + "refused": { "kind": "refused", "reason": "refused" }, + "unknown": { "kind": "refused", "reason": "unknown" } } diff --git a/packages/browser-pay/src/fixtures/shopify-card-session.json b/packages/browser-pay/src/fixtures/shopify-card-session.json index a5cc490ed..76a6607ea 100644 --- a/packages/browser-pay/src/fixtures/shopify-card-session.json +++ b/packages/browser-pay/src/fixtures/shopify-card-session.json @@ -1,10 +1,8 @@ { "request": { - "version": 1, "transactionId": "tx_fixture_shopify", "injectionRef": "inj_fixture_shopify", "merchantOrigin": "https://shop.example", - "frameOrigin": "https://checkout.pci.shopifyinc.com", "url": "https://checkout.pci.shopifyinc.com/sessions", "method": "POST", "headers": [ @@ -17,11 +15,10 @@ ["shopify-identification-signature", "fixture-signature"] ], "body": "{\"credit_card\":{\"number\":\"4242424242424242\",\"month\":12,\"year\":2028,\"verification_value\":\"123\",\"name\":\"Fixture Buyer\"},\"payment_session_scope\":\"fixture_scope\"}", - "bodyEncoding": "utf8", "idempotencyKey": "req_fixture_shopify" }, "response": { - "ok": true, + "kind": "relayed", "status": 200, "headers": [ ["content-type", "application/json"], diff --git a/packages/browser-pay/src/headers.test.ts b/packages/browser-pay/src/headers.test.ts new file mode 100644 index 000000000..70ae7d5f1 --- /dev/null +++ b/packages/browser-pay/src/headers.test.ts @@ -0,0 +1,42 @@ +import { acceptLanguageHeader, secFetchDest, secFetchSite } from "./headers"; + +describe("secFetchSite", () => { + it("reports same-origin, same-site, and cross-site", () => { + expect( + secFetchSite( + "https://checkout.pci.shopifyinc.com/sessions", + "https://checkout.pci.shopifyinc.com/blank", + ), + ).toBe("same-origin"); + expect( + secFetchSite( + "https://api.shop.example/pay", + "https://shop.example/checkout", + ), + ).toBe("same-site"); + expect( + secFetchSite( + "https://checkout.pci.shopifyinc.com/sessions", + "https://shop.example/checkout", + ), + ).toBe("cross-site"); + }); + + it("returns undefined for a URL that cannot be parsed", () => { + expect(secFetchSite("not a url", "https://shop.example/")).toBeUndefined(); + }); +}); + +describe("acceptLanguageHeader", () => { + it("uses Chrome q-values after the first language", () => { + expect(acceptLanguageHeader(["en-US", "en"])).toBe("en-US,en;q=0.9"); + }); +}); + +describe("secFetchDest", () => { + it("is empty for fetch and xhr", () => { + expect(secFetchDest("Fetch")).toBe("empty"); + expect(secFetchDest("XHR")).toBe("empty"); + expect(secFetchDest("Document")).toBeUndefined(); + }); +}); diff --git a/packages/browser-pay/src/headers.ts b/packages/browser-pay/src/headers.ts new file mode 100644 index 000000000..ee84f0cd6 --- /dev/null +++ b/packages/browser-pay/src/headers.ts @@ -0,0 +1,65 @@ +/** + * Headers Chrome's network stack adds after the Fetch request stage. + * Missing ones are filled. A header the request already has is left alone. + * + * Sec-Fetch-Site follows the schemeful same-site check. The registrable + * domain is the last two hostname labels. This repo has no public suffix + * list, so a multi-label public suffix such as `co.uk` is treated as the + * site itself: `a.co.uk` and `b.co.uk` are reported same-site. Shopify's + * card hosts are not in that set. + */ + +export type SecFetchSite = "same-origin" | "same-site" | "cross-site"; + +function registrableDomain(hostname: string): string { + const labels = hostname.toLowerCase().split(".").filter(Boolean); + if (labels.length <= 2) return labels.join("."); + return labels.slice(-2).join("."); +} + +/** Schemeful same-site of `requestUrl` relative to the frame that sent it. */ +export function secFetchSite( + requestUrl: string, + frameUrl: string, +): SecFetchSite | undefined { + let target: URL; + let frame: URL; + try { + target = new URL(requestUrl); + frame = new URL(frameUrl); + } catch { + return undefined; + } + if (target.origin === frame.origin) return "same-origin"; + if ( + target.protocol === frame.protocol && + registrableDomain(target.hostname) === registrableDomain(frame.hostname) + ) { + return "same-site"; + } + return "cross-site"; +} + +/** Chrome's Accept-Language q-values from `navigator.languages`. */ +export function acceptLanguageHeader(languages: readonly string[]): string { + return languages + .map((language, index) => + index === 0 + ? language + : `${language};q=${Math.max(0.1, 1 - index / 10).toFixed(1)}`, + ) + .join(","); +} + +export function cookieHeader( + cookies: readonly { name: string; value: string }[], +): string { + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); +} + +/** `empty` for fetch and XHR. Other resource types are left unset. */ +export function secFetchDest(resourceType: string): "empty" | undefined { + return resourceType === "Fetch" || resourceType === "XHR" + ? "empty" + : undefined; +} diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts index 3ed1fe534..abaea4a4a 100644 --- a/packages/browser-pay/src/index.ts +++ b/packages/browser-pay/src/index.ts @@ -14,10 +14,10 @@ export type { } from "./relay.js"; export { - RELAY_CONTRACT_VERSION, RELAY_REFUSAL_REASONS, isRelayRefusalReason, parseRelayResponse, + paymentIdempotencyKey, relayHeadersFromRecord, } from "./contract.js"; export type { @@ -31,17 +31,16 @@ export type { } from "./contract.js"; export { - cardNumberCandidate, - extractTypedCards, - luhnCheckDigit, - matchHeldPlaceholder, + luhnHeldPan, matchHeldPlaceholderInBody, + textHoldsPlaceholderNumber, } from "./detect.js"; -export type { - BodyPlaceholderMatch, - HeldPlaceholder, - HeldPlaceholderMatch, - TypedCard, -} from "./detect.js"; +export type { BodyPlaceholderMatch, HeldPlaceholder } from "./detect.js"; + +export type { PayLogger } from "./logger.js"; +export { silentLogger } from "./logger.js"; export { maskCardNumber, redact } from "./redact.js"; + +export { secFetchSite } from "./headers.js"; +export type { SecFetchSite } from "./headers.js"; diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 844155d82..95a91965e 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -312,7 +312,7 @@ describe("attachInterceptor on headless Chromium", () => { it("relays only the placeholder request while a second CDP client drives the page", async () => { const { events, calls, result, merchantOrigin } = await drive( stubPaymentRelay(async () => ({ - ok: true, + kind: "relayed", status: 201, headers: [["content-type", "application/json"]], body: '{"id":"sess_test"}', @@ -327,23 +327,22 @@ describe("attachInterceptor on headless Chromium", () => { expect(hits.find((hit) => hit.path === "/other")?.body).toBe(UNRELATED); expect(paths.filter((item) => item === "/sessions")).toEqual([]); - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ - version: 1, + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ transactionId: "tx_integration", injectionRef: "inj_integration", merchantOrigin, - frameOrigin: `http://127.0.0.1:${card.port}`, method: "POST", url: `http://127.0.0.1:${card.port}/sessions`, body: CARD, - bodyEncoding: "utf8", }); - expect(calls[0].idempotencyKey).toMatch(/:[0-9a-f]{16}$/); - expect(calls[0].merchantOrigin).not.toContain(String(card.port)); + expect(calls[0].body).toBe(MISMATCH); + expect(calls[0].idempotencyKey).not.toBe(calls[1].idempotencyKey); + expect(calls[1].merchantOrigin).not.toContain(String(card.port)); expect(header(calls[0].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({ @@ -352,21 +351,25 @@ describe("attachInterceptor on headless Chromium", () => { status: 200, text: JSON.stringify({ ok: true, via: "server" }), }, - mismatch: { error: "TypeError" }, + mismatch: { status: 201, text: '{"id":"sess_test"}' }, card: { status: 201, text: '{"id":"sess_test"}' }, }); expect(events.map((event) => event.type)).toEqual([ - "failed", + "paused", + "relayed", "paused", "relayed", ]); - expect(events[2]).toMatchObject({ type: "relayed", status: 201 }); + 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 () => ({ ok: false, reason: "cancelled" })), + stubPaymentRelay(async () => ({ + kind: "refused", + reason: "already_relayed", + })), { ping: PING, other: PING, mismatch: PING, card: CARD }, ); expect(hits.filter((hit) => hit.body.includes(PLACEHOLDER.number))).toEqual( @@ -379,7 +382,7 @@ describe("attachInterceptor on headless Chromium", () => { expect(events[1]).toEqual({ type: "refused", url: `http://127.0.0.1:${card.port}/sessions`, - reason: "cancelled", + reason: "already_relayed", }); expect(JSON.stringify(events)).not.toContain(PLACEHOLDER.number); }, 60_000); @@ -430,11 +433,11 @@ describe("attachInterceptor on headless Chromium", () => { relay: async (request) => { calls.push(request); return { - ok: true as const, + kind: "relayed", status: 201, - headers: [["content-type", "text/plain"] as RelayHeader], + headers: [["content-type", "text/plain"] satisfies RelayHeader], body: "ok", - bodyEncoding: "utf8" as const, + bodyEncoding: "utf8", }; }, }, diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 2cac7c46b..632d42dc3 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -2,7 +2,9 @@ import crypto from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; +import { paymentIdempotencyKey } from "./contract"; import { attachInterceptor, type InterceptorEvent } from "./interceptor"; +import type { PayLogger } from "./logger"; import { stubPaymentRelay, type RelayCallOptions } from "./relay"; import type { HeldPlaceholder, RelayRequest, RelayResponse } from "./index"; @@ -34,6 +36,10 @@ interface StubOptions { failFulfill?: boolean; postData?: string; postDataError?: boolean; + targets?: { targetId: string; type: string; url: string }[]; + attachedSessionId?: string; + languages?: string[]; + cookies?: { name: string; value: string }[]; } function serverFrame(text: string): Buffer { @@ -117,6 +123,31 @@ async function startStub(options: StubOptions = {}): Promise<{ 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") { + const expression = message.params?.expression; + if (expression === "navigator.languages") { + reply(message.id, { + result: { value: options.languages ?? ["en-US", "en"] }, + }); + return; + } + reply(message.id, {}); + return; + } + if (message.method === "Network.getCookies") { + reply(message.id, { cookies: options.cookies ?? [] }); + return; + } if (message.method === "Fetch.getRequestPostData") { if (options.postDataError) { reply(message.id, undefined, { message: "no body" }); @@ -236,13 +267,20 @@ function attachPage( } const success = (): RelayResponse => ({ - ok: true, + kind: "relayed", status: 201, headers: [["content-type", "application/json"]], body: '{"id":"sess"}', bodyEncoding: "utf8", }); +function header( + headers: RelayRequest["headers"], + name: string, +): string | undefined { + return headers.find(([key]) => key.toLowerCase() === name)?.[1]; +} + describe("interceptor CDP decisions", () => { it("does not resume a target whose Fetch.enable failed", async () => { const stub = await startStub({ failFetch: true }); @@ -328,10 +366,15 @@ describe("interceptor CDP decisions", () => { ).toBe(true); expect(calls).toHaveLength(1); expect(calls[0].body).toBe(CARD); - expect(calls[0].version).toBe(1); - expect(calls[0].bodyEncoding).toBe("utf8"); - expect(calls[0].idempotencyKey).toMatch(/^req-1:[0-9a-f]{16}$/); - expect(calls[0].headers).toEqual([["content-type", "application/json"]]); + expect(calls[0].idempotencyKey).toBe( + paymentIdempotencyKey("POST", calls[0].url, CARD), + ); + expect(header(calls[0].headers, "content-type")).toBe("application/json"); + expect(header(calls[0].headers, "sec-fetch-site")).toBe("cross-site"); + expect(header(calls[0].headers, "sec-fetch-dest")).toBe("empty"); + expect(header(calls[0].headers, "accept-language")).toBe( + "en-US,en;q=0.9", + ); await handle.detach(); } finally { await stub.close(); @@ -584,6 +627,9 @@ describe("interceptor CDP decisions", () => { type: "relayed_unfulfilled", status: 201, }); + expect( + stub.commands.some((command) => command.method === "Fetch.failRequest"), + ).toBe(true); await handle.detach(); } finally { await stub.close(); @@ -596,7 +642,7 @@ describe("interceptor CDP decisions", () => { try { const handle = await attachInterceptor(stub.url, { relay: stubPaymentRelay(async () => ({ - ok: true, + kind: "relayed", status: 200, headers: [["content-type", "text/plain"]], body: "4111111111111111", @@ -706,4 +752,208 @@ describe("interceptor CDP decisions", () => { 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, + transactionId: "tx", + pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], + }); + 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, + transactionId: "tx", + }); + 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: PayLogger = { + info() {}, + warn(message) { + warnings.push(message); + }, + }; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async () => { + calls += 1; + if (calls === 1) await gate; + return { kind: "refused", reason: "already_relayed" }; + }), + placeholder: HELD, + transactionId: "tx", + 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("copies a missing Cookie the way Chrome would", async () => { + const stub = await startStub({ + cookies: [{ name: "cart", value: "1" }], + }); + const calls: RelayRequest[] = []; + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor(stub.url, { + relay: stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + placeholder: HELD, + transactionId: "tx", + }); + 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: { "Sec-Fetch-Site": "same-origin" }, + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "relayed"), + "relayed", + ); + expect(header(calls[0].headers, "sec-fetch-site")).toBe("same-origin"); + expect(header(calls[0].headers, "cookie")).toBe("cart=1"); + expect(header(calls[0].headers, "sec-fetch-dest")).toBe("empty"); + await handle.detach(); + } finally { + await stub.close(); + } + }); }); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 4a7138d00..71ef5a204 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -1,13 +1,25 @@ -import crypto from "node:crypto"; import { CdpConnection, type CdpEvent } from "./cdp.js"; -import { RELAY_CONTRACT_VERSION, type RelayHeader } from "./contract.js"; import { + parseRelayResponse, + paymentIdempotencyKey, + type RelayHeader, + type RelayResponse, +} from "./contract.js"; +import { + luhnHeldPan, matchHeldPlaceholderInBody, textContainsForeignCard, textHoldsPlaceholderNumber, type HeldPlaceholder, } from "./detect.js"; -import type { PaymentRelay, RelayResponse } from "./relay.js"; +import { + acceptLanguageHeader, + cookieHeader, + secFetchDest, + secFetchSite, +} from "./headers.js"; +import { silentLogger, type PayLogger } from "./logger.js"; +import type { PaymentRelay } from "./relay.js"; import { redact } from "./redact.js"; export interface AttachOptions { @@ -21,18 +33,13 @@ export interface AttachOptions { */ timeoutMs?: number; /** - * When set, only targets whose URL contains one of these strings are paused. - * Other pages keep running, so a captcha solver is not held on their traffic. - * Omit it to pause every page and iframe. + * When set, Fetch pauses only URLs containing one of these strings. + * Other requests are not paused, so a captcha solver keeps running. + * Omit it to pause every page and iframe request. */ pauseOnlyUrls?: string[]; - /** - * Copy Cookie, Accept-Language, and Sec-Fetch-* from the browser onto the - * relay request. Off by default. Those headers can include HttpOnly cookies - * the relay backend must not log or persist. Shopify's card session needs - * them, so the dev-store script turns this on. - */ - synthesizeBrowserHeaders?: boolean; + /** Defaults to a logger that discards every line. */ + logger?: PayLogger; } export type InterceptorEvent = @@ -72,17 +79,7 @@ export function canContinueWithoutReading( const verb = method.toUpperCase(); if (verb !== "GET" && verb !== "HEAD") return false; if (hasBody) return false; - return !urlDigits(url).includes(placeholderNumber); -} - -function urlDigits(url: string): string { - let decoded = url; - try { - decoded = decodeURIComponent(url); - } catch { - // Keep the raw URL when it is not valid percent-encoding. - } - return decoded.replace(/\D/g, ""); + return !textHoldsPlaceholderNumber(url, placeholderNumber); } /** @@ -112,6 +109,8 @@ export function isSolverTraffic(url: string): boolean { 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", @@ -175,15 +174,20 @@ export async function attachInterceptor( target: InterceptorTarget, options: AttachOptions, ): Promise { + assertAttachOptions(options); const cdpUrl = resolveCdpUrl(target); const connection = await CdpConnection.connect(cdpUrl); const sessions = new Map(); const seen = new Set(); const listeners = new Set<(event: InterceptorEvent) => void>(); const inflight = new Map(); + const relayFlights = new Map>(); const relayAborts = new Set(); + const loggedRefusals = new Set(); + const pendingArms: Promise[] = []; let closed = false; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const logger = options.logger ?? silentLogger; const emit = (event: InterceptorEvent) => { for (const listener of listeners) { @@ -209,21 +213,14 @@ export async function attachInterceptor( } seen.add(sessionId); const session = sessions.get(sessionId); - // A blank frame has not navigated yet. Holding it for Fetch.enable freezes - // a captcha widget that is about to load. Arm Fetch only once the URL is a - // page we might need to pause. - if (!session || !shouldPauseTarget(session, options.pauseOnlyUrls)) { - if (session?.fetchEnabled) { - session.fetchEnabled = false; - await connection.send("Fetch.disable", {}, sessionId).catch(() => {}); - } + if (!session || isHandsOffTarget(session)) { await releaseTarget(connection, sessionId, waitingForDebugger); return; } // Fetch has to be on before the target runs, or the card iframe can post // before we are listening. Network has to be enabled before // Network.getCookies will return the jar the card session needs. - const armed = await armFetch(connection, session); + const armed = await armFetch(connection, session, options.pauseOnlyUrls); if (!armed) { emit({ type: "failed", @@ -239,14 +236,10 @@ export async function attachInterceptor( }; const onAttached = (event: CdpEvent) => { - const params = event.params as { - sessionId?: string; - waitingForDebugger?: boolean; - targetInfo?: { targetId?: string; type?: string; url?: string }; - }; + const params = parseAttachedTarget(event.params); + if (!params) return; const sessionId = params.sessionId; const info = params.targetInfo; - if (!sessionId || !info?.type || !info.targetId) return; const duplicate = [...sessions.values()].some( (session) => session.targetId === info.targetId, ); @@ -271,34 +264,44 @@ export async function attachInterceptor( url: info.url ?? "", ...(event.sessionId ? { parentSessionId: event.sessionId } : {}), }); - // Browser-level auto-attach covers pages. Child iframes attach only when - // the page (or parent frame) session asks for them. A second session for - // the same target is detached in onAttached. - if (info.type === "page" || info.type === "iframe") { - void connection - .send( - "Target.setAutoAttach", - { - autoAttach: true, - waitForDebuggerOnStart: false, - flatten: true, - filter: [{ type: "iframe" }], - }, - sessionId, - ) - .catch(() => {}); - } - void enableSession(sessionId, params.waitingForDebugger === true); + const arm = (async () => { + // Child iframes pause on attach so Fetch.enable runs before they resume. + if (info.type === "page" || info.type === "iframe") { + await connection + .send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: [{ type: "iframe" }], + }, + sessionId, + ) + .catch(() => {}); + } + await enableSession(sessionId, params.waitingForDebugger === true); + })(); + pendingArms.push(arm); + void arm.finally(() => { + const index = pendingArms.indexOf(arm); + if (index >= 0) pendingArms.splice(index, 1); + }); }; const onPaused = (event: CdpEvent) => { const sessionId = event.sessionId; - const params = event.params as { - requestId?: string; - resourceType?: string; - request?: PausedRequest; - }; - if (!sessionId || !params.requestId || !params.request) return; + const params = parsePaused(event.params); + if (!sessionId || !params) return; + if (!sessions.has(sessionId)) { + void failRequest(connection, sessionId, params.requestId); + emit({ + type: "failed", + url: redact(params.request.url ?? ""), + reason: "unknown_session", + }); + return; + } const url = params.request.url ?? ""; const method = params.request.method ?? "GET"; const hasBody = requestHasBody(params.request); @@ -318,6 +321,9 @@ export async function attachInterceptor( timeoutMs, emit, inflight, + relayFlights, + loggedRefusals, + logger, relayAborts, isClosed: () => closed, }; @@ -372,13 +378,11 @@ export async function attachInterceptor( if (closed) return; if (event.method === "Target.attachedToTarget") onAttached(event); else if (event.method === "Target.detachedFromTarget") { - const sessionId = event.params.sessionId as string | undefined; + const sessionId = readStringField(event.params, "sessionId"); if (sessionId) sessions.delete(sessionId); } else if (event.method === "Target.targetInfoChanged") { - const info = event.params.targetInfo as - | { targetId?: string; url?: string } - | undefined; - if (!info?.targetId || !info.url) return; + const info = parseTargetInfoChange(event.params); + if (!info) return; for (const session of sessions.values()) { if (session.targetId !== info.targetId) continue; session.url = info.url; @@ -395,17 +399,19 @@ export async function attachInterceptor( continue; } if (!session.fetchEnabled) { - void armFetch(connection, session).then((armed) => { - if (armed || closed) return; - emit({ - type: "failed", - url: redact(session.url || session.targetId), - reason: "intercept_unavailable", - }); - void connection - .send("Target.closeTarget", { targetId: session.targetId }) - .catch(() => {}); - }); + void armFetch(connection, session, options.pauseOnlyUrls).then( + (armed) => { + if (armed || closed) return; + emit({ + type: "failed", + url: redact(session.url || session.targetId), + reason: "intercept_unavailable", + }); + void connection + .send("Target.closeTarget", { targetId: session.targetId }) + .catch(() => {}); + }, + ); } } } @@ -413,13 +419,17 @@ export async function attachInterceptor( await connection.send("Target.setAutoAttach", { autoAttach: true, - waitForDebuggerOnStart: false, + waitForDebuggerOnStart: true, flatten: true, filter: TARGET_FILTER, }); await connection .send("Target.setDiscoverTargets", { discover: true }) .catch(() => {}); + await attachExistingPaymentFrames(connection, sessions, options, (session) => + enableSession(session.sessionId, false), + ); + await Promise.all([...pendingArms]); return { on(listener) { @@ -473,6 +483,9 @@ interface PauseContext { timeoutMs: number; emit: (event: InterceptorEvent) => void; inflight: Map; + relayFlights: Map>; + loggedRefusals: Set; + logger: PayLogger; relayAborts: Set; isClosed: () => boolean; } @@ -564,10 +577,6 @@ async function handlePaused( ctx.options.placeholder, ); if (match.kind === "not_held" && !urlHolds) { - if (ctx.isClosed()) { - await finishContinue(ctx, entry); - return; - } await finishContinue(ctx, entry); return; } @@ -595,37 +604,36 @@ async function handlePaused( }); return; } - const headers = ctx.options.synthesizeBrowserHeaders - ? await completeHeaders( - ctx.connection, - ctx.sessions, - entry.sessionId, - url, - resourceType, - request.headers, - ) - : headerList(request.headers); - const frameOrigin = readFrameOrigin(ctx.sessions, entry.sessionId, url); + const headers = await completeHeaders( + ctx.connection, + ctx.sessions, + entry.sessionId, + url, + resourceType, + request.headers, + ); + const idempotencyKey = paymentIdempotencyKey(method, url, read.body); const controller = new AbortController(); ctx.relayAborts.add(controller); const timer = setTimeout(() => controller.abort(), ctx.timeoutMs); let response: RelayResponse; try { - response = await ctx.options.relay.relay( - { - version: RELAY_CONTRACT_VERSION, - transactionId: ctx.options.transactionId, - injectionRef: match.injectionRef, - merchantOrigin, - ...(frameOrigin ? { frameOrigin } : {}), - url, - method, - headers, - body: read.body, - bodyEncoding: "utf8", - idempotencyKey: `${entry.requestId}:${crypto.randomBytes(8).toString("hex")}`, - }, - { signal: controller.signal }, + response = parseRelayResponse( + await relayOnce(ctx, idempotencyKey, () => + ctx.options.relay.relay( + { + transactionId: ctx.options.transactionId, + injectionRef: match.injectionRef, + merchantOrigin, + url, + method, + headers, + body: read.body, + idempotencyKey, + }, + { signal: controller.signal }, + ), + ), ); } catch (error) { await finish(ctx, entry, "fail"); @@ -639,7 +647,13 @@ async function handlePaused( clearTimeout(timer); ctx.relayAborts.delete(controller); } - if (!response.ok) { + if (response.kind === "refused") { + if (!ctx.loggedRefusals.has(idempotencyKey)) { + ctx.loggedRefusals.add(idempotencyKey); + ctx.logger.warn("payment relay refused the paused request", { + reason: response.reason, + }); + } await finish(ctx, entry, "fail"); ctx.emit({ type: "refused", url: safeUrl, reason: response.reason }); return; @@ -667,6 +681,11 @@ async function handlePaused( } catch { fulfilled = false; } + // A failed fulfill must not leave the pause open. Chrome continues an + // open pause when the socket drops, which would deliver the placeholder. + if (!fulfilled && !entry.settled) { + await failRequest(ctx.connection, entry.sessionId, entry.requestId); + } if (!entry.settled) { entry.settled = true; ctx.inflight.delete(entry.key); @@ -763,15 +782,14 @@ async function readBodyResult( return { body, unreadable: false }; } try { - const result = await connection.send<{ postData?: string }>( + const result = await connection.send( "Fetch.getRequestPostData", { requestId }, sessionId, ); - if (typeof result.postData !== "string" || result.postData.length === 0) { - return { body: "", unreadable: true }; - } - return { body: result.postData, unreadable: false }; + const postData = readStringField(result, "postData"); + if (!postData) return { body: "", unreadable: true }; + return { body: postData, unreadable: false }; } catch { return { body: "", unreadable: true }; } @@ -792,7 +810,7 @@ async function readMerchantOrigin( if (fromPage) return fromPage; } const ancestors = await connection - .send<{ result?: { value?: unknown } }>( + .send( "Runtime.evaluate", { expression: @@ -802,8 +820,8 @@ async function readMerchantOrigin( sessionId, ) .catch(() => null); - const value = ancestors?.result?.value; - if (typeof value === "string" && /^https?:\/\//.test(value)) return value; + const value = evaluatedString(ancestors); + if (value && /^https?:\/\//.test(value)) return value; return undefined; } @@ -813,12 +831,14 @@ async function originFromSession( sessionId: string, ): Promise { const evaluated = await connection - .send<{ - result?: { value?: unknown }; - }>("Runtime.evaluate", { expression: "location.origin", returnByValue: true }, sessionId) + .send( + "Runtime.evaluate", + { expression: "location.origin", returnByValue: true }, + sessionId, + ) .catch(() => null); - const value = evaluated?.result?.value; - if (typeof value === "string" && /^https?:\/\//.test(value)) return value; + const value = evaluatedString(evaluated); + if (value && /^https?:\/\//.test(value)) return value; const url = sessions.get(sessionId)?.url; if (!url) return undefined; try { @@ -829,20 +849,6 @@ async function originFromSession( } } -function readFrameOrigin( - sessions: Map, - sessionId: string, - requestUrl: string, -): string | undefined { - const raw = sessions.get(sessionId)?.url || requestUrl; - try { - const origin = new URL(raw).origin; - return /^https?:\/\//.test(origin) ? origin : undefined; - } catch { - return undefined; - } -} - function topPageSession( sessions: Map, sessionId: string, @@ -866,9 +872,8 @@ function topPageSession( /** * At the Fetch Request stage Chrome has not yet added the headers its network - * stack appends (Cookie, Sec-Fetch-*, Accept-Language). Shopify's card session - * needs those. Never override a header the request already has. Only called - * when `synthesizeBrowserHeaders` is set. + * stack appends. Fill the ones that are missing. Never override a header the + * request already has. Cookie values are not logged. */ async function completeHeaders( connection: CdpConnection, @@ -882,93 +887,99 @@ async function completeHeaders( const has = (name: string) => headers.some(([key]) => key.toLowerCase() === name); const frameUrl = sessions.get(sessionId)?.url || requestUrl; - try { - const target = new URL(requestUrl); - const frame = new URL(frameUrl); - if (!has("sec-fetch-site")) { - const site = (value: URL) => - value.hostname.split(".").slice(-2).join("."); - headers.push([ - "Sec-Fetch-Site", - target.origin === frame.origin - ? "same-origin" - : site(target) === site(frame) && target.protocol === frame.protocol - ? "same-site" - : "cross-site", - ]); - } - if (!has("sec-fetch-dest") && /^(Fetch|XHR)$/.test(resourceType)) { - headers.push(["Sec-Fetch-Dest", "empty"]); - } - if (!has("accept-language")) { - const languages = await connection - .send<{ - result?: { value?: unknown }; - }>("Runtime.evaluate", { expression: "navigator.languages", returnByValue: true }, sessionId) - .catch(() => null); - const list = languages?.result?.value; - if ( - Array.isArray(list) && - list.every((entry) => typeof entry === "string") - ) { - headers.push([ - "Accept-Language", - list - .map((language, index) => - index === 0 - ? language - : `${language};q=${Math.max(0.1, 1 - index / 10).toFixed(1)}`, - ) - .join(","), - ]); - } + const site = secFetchSite(requestUrl, frameUrl); + if (!has("sec-fetch-site") && site) { + headers.push(["Sec-Fetch-Site", site]); + } + const dest = secFetchDest(resourceType); + if (!has("sec-fetch-dest") && dest) { + headers.push(["Sec-Fetch-Dest", dest]); + } + if (!has("accept-language")) { + const languages = await readLanguages(connection, sessionId); + if (languages.length > 0) { + headers.push(["Accept-Language", acceptLanguageHeader(languages)]); } - if (!has("cookie")) { - const cookies = await connection - .send<{ - cookies?: { name: string; value: string }[]; - }>("Network.getCookies", { urls: [requestUrl] }, sessionId) - .catch(() => null); - if (cookies?.cookies?.length) { - headers.push([ - "Cookie", - cookies.cookies - .map((cookie) => `${cookie.name}=${cookie.value}`) - .join("; "), - ]); - } + } + if (!has("cookie")) { + const cookies = await readCookies(connection, sessionId, requestUrl); + if (cookies.length > 0) { + headers.push(["Cookie", cookieHeader(cookies)]); } - } catch { - // Keep the headers Chrome already gave us. } return headers; } +async function readLanguages( + connection: CdpConnection, + sessionId: string, +): Promise { + const evaluated = await connection + .send( + "Runtime.evaluate", + { expression: "navigator.languages", returnByValue: true }, + sessionId, + ) + .catch(() => undefined); + return stringList(evaluated); +} + +async function readCookies( + connection: CdpConnection, + sessionId: string, + requestUrl: string, +): Promise<{ name: string; value: string }[]> { + const result = await connection + .send("Network.getCookies", { urls: [requestUrl] }, sessionId) + .catch(() => undefined); + if (!isRecord(result) || !Array.isArray(result.cookies)) return []; + const cookies: { name: string; value: string }[] = []; + for (const entry of result.cookies) { + if (!isRecord(entry)) continue; + if (typeof entry.name !== "string" || typeof entry.value !== "string") { + continue; + } + cookies.push({ name: entry.name, value: entry.value }); + } + return cookies; +} + +function stringList(value: unknown): string[] { + if (!isRecord(value) || !isRecord(value.result)) return []; + const list = value.result.value; + if ( + !Array.isArray(list) || + !list.every((entry) => typeof entry === "string") + ) { + return []; + } + return list.filter((entry): entry is string => typeof entry === "string"); +} + function headerList(headers: unknown): RelayHeader[] { if (!headers) return []; if (Array.isArray(headers)) { const out: RelayHeader[] = []; for (const entry of headers) { - if (Array.isArray(entry) && entry.length === 2) { - out.push([String(entry[0]), String(entry[1])]); - continue; - } if ( - entry && - typeof entry === "object" && - typeof (entry as { name?: unknown }).name === "string" + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string" ) { - const named = entry as { name: string; value?: unknown }; - out.push([named.name, String(named.value ?? "")]); + out.push([entry[0], entry[1]]); + continue; + } + if (isRecord(entry) && typeof entry.name === "string") { + const value = entry.value; + out.push([entry.name, typeof value === "string" ? value : ""]); } } return out; } - if (typeof headers === "object") { + if (isRecord(headers)) { const out: RelayHeader[] = []; - for (const [key, value] of Object.entries( - headers as Record, - )) { + for (const [key, value] of Object.entries(headers)) { if (typeof value === "string") out.push([key, value]); } return out; @@ -976,6 +987,93 @@ function headerList(headers: unknown): RelayHeader[] { return []; } +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; resourceType: 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, + resourceType: + typeof value.resourceType === "string" ? value.resourceType : "", + 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), + }, + }; +} + +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 requestHasBody(request: PausedRequest): boolean { if (request.hasPostData) return true; if (typeof request.postData === "string" && request.postData.length > 0) { @@ -987,6 +1085,7 @@ function requestHasBody(request: PausedRequest): boolean { async function armFetch( connection: CdpConnection, session: Session | undefined, + pauseOnlyUrls: string[] | undefined, ): Promise { if (!session || isHandsOffTarget(session)) return false; if (session.fetchEnabled) return true; @@ -996,7 +1095,7 @@ async function armFetch( try { await connection.send( "Fetch.enable", - { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, + { patterns: fetchPatterns(pauseOnlyUrls) }, session.sessionId, ); } catch { @@ -1006,6 +1105,19 @@ async function armFetch( return true; } +function fetchPatterns( + pauseOnlyUrls: string[] | undefined, +): { urlPattern: string; requestStage: "Request" }[] { + if (!pauseOnlyUrls || pauseOnlyUrls.length === 0) { + return [{ urlPattern: "*", requestStage: "Request" }]; + } + const patterns: { urlPattern: string; requestStage: "Request" }[] = []; + for (const part of pauseOnlyUrls) { + patterns.push({ urlPattern: `*${part}*`, requestStage: "Request" }); + } + return patterns; +} + async function releaseTarget( connection: CdpConnection, sessionId: string, @@ -1088,7 +1200,7 @@ async function fulfillRequest( connection: CdpConnection, sessionId: string, requestId: string, - response: Extract, + response: Extract, ): Promise { const responseHeaders = response.headers.flatMap(([name, value]) => { if (RESPONSE_HOP.has(name.toLowerCase())) return []; @@ -1125,35 +1237,151 @@ function failureReason(error: unknown): string { return "network"; } -export function resolveCdpUrl(target: InterceptorTarget): string { - if (typeof target === "string") { - if (!target) throw new Error("A CDP websocket URL is required"); - return target; +function relayOnce( + ctx: PauseContext, + key: string, + call: () => Promise, +): Promise { + const existing = ctx.relayFlights.get(key); + if (existing) return existing; + const flight = call().finally(() => { + if (ctx.relayFlights.get(key) === flight) ctx.relayFlights.delete(key); + }); + ctx.relayFlights.set(key, flight); + return flight; +} + +async function attachExistingPaymentFrames( + connection: CdpConnection, + sessions: Map, + options: AttachOptions, + enable: (session: Session) => Promise, +): Promise { + const listed = await connection + .send("Target.getTargets") + .catch(() => undefined); + for (const info of parseTargetInfos(listed)) { + if (info.type !== "iframe") continue; + if (!urlMatchesPause(info.url, options.pauseOnlyUrls)) continue; + const known = [...sessions.values()].some( + (session) => session.targetId === info.targetId, + ); + if (known) continue; + const attached = await connection + .send("Target.attachToTarget", { targetId: info.targetId, flatten: true }) + .catch(() => undefined); + const sessionId = readAttachedSessionId(attached); + if (!sessionId || sessions.has(sessionId)) continue; + const session: Session = { + sessionId, + targetId: info.targetId, + type: "iframe", + url: info.url, + }; + sessions.set(sessionId, session); + await connection + .send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: [{ type: "iframe" }], + }, + sessionId, + ) + .catch(() => {}); + await enable(session); + } +} + +function urlMatchesPause(url: string, pauseOnlyUrls?: string[]): boolean { + if (!pauseOnlyUrls || pauseOnlyUrls.length === 0) return true; + return pauseOnlyUrls.some((part) => url.includes(part)); +} + +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.transactionId) { + throw new Error("transactionId is required"); } if ( - target && - typeof (target as { wsEndpoint?: unknown }).wsEndpoint === "function" + !options.placeholder?.injectionRef || + !luhnHeldPan(options.placeholder.number) ) { - const url = (target as { wsEndpoint(): string }).wsEndpoint(); - if (url) return url; + throw new Error( + "placeholder.injectionRef and a Luhn-valid placeholder number are required", + ); } - const browserFromBrowser = ( - target as { browser?: () => { wsEndpoint(): string } | null } - ).browser; - if (typeof browserFromBrowser === "function") { - const url = browserFromBrowser.call(target)?.wsEndpoint(); - if (url) return url; + const patterns = options.pauseOnlyUrls; + if ( + patterns !== undefined && + (!Array.isArray(patterns) || + patterns.some((part) => typeof part !== "string")) + ) { + throw new Error("pauseOnlyUrls must be an array of strings"); } - const context = ( - target as { - context?: () => { browser(): { wsEndpoint(): string } | null } | null; - } - ).context; - if (typeof context === "function") { - const url = context.call(target)?.browser()?.wsEndpoint(); - if (url) return url; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export 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..41b77c14c --- /dev/null +++ b/packages/browser-pay/src/logger.ts @@ -0,0 +1,20 @@ +/** + * Injectable log sink. The package never writes to the console. Fields are + * short strings the caller already considers safe to record. Card numbers + * are masked before they reach a logger. + */ +export interface PayLogger { + info( + message: string, + fields?: Readonly>, + ): void; + warn( + message: string, + fields?: Readonly>, + ): void; +} + +export const silentLogger: PayLogger = { + info() {}, + warn() {}, +}; diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index cac47c131..2cc896ed9 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -32,7 +32,7 @@ describe("T7 fixtures", () => { "body", "bodyEncoding", "headers", - "ok", + "kind", "status", ]); expect(response.headers.filter(([name]) => name === "set-cookie")).toEqual([ @@ -56,9 +56,12 @@ describe("T7 fixtures", () => { [...RELAY_REFUSAL_REASONS].sort(), ); for (const reason of RELAY_REFUSAL_REASONS) { - expect(relayRefusalFixtures[reason]).toEqual({ ok: false, reason }); + expect(relayRefusalFixtures[reason]).toEqual({ + kind: "refused", + reason, + }); expect(parseRelayResponse(relayRefusalFixtures[reason])).toEqual({ - ok: false, + kind: "refused", reason, }); } @@ -111,11 +114,11 @@ describe("httpPaymentRelay", () => { it("returns a provider response and a typed refusal", async () => { const payloads = [ shopifyCardSessionRoundTrip.response, - relayRefusalFixtures.cancelled, + relayRefusalFixtures.already_relayed, ]; const server = await listen((_req, res) => { const payload = payloads.shift(); - res.writeHead(payload && "ok" in payload && payload.ok ? 200 : 403, { + res.writeHead(payload && payload.kind === "relayed" ? 200 : 403, { "content-type": "application/json", }); res.end(JSON.stringify(payload)); @@ -129,8 +132,8 @@ describe("httpPaymentRelay", () => { shopifyCardSessionRoundTrip.response, ); await expect(relay.relay(REQUEST)).resolves.toEqual({ - ok: false, - reason: "cancelled", + kind: "refused", + reason: "already_relayed", }); } finally { await new Promise((resolve) => server.close(resolve)); @@ -187,10 +190,12 @@ describe("httpPaymentRelay", () => { describe("stubPaymentRelay", () => { it("returns the handler's response", async () => { - const relay = stubPaymentRelay(async () => relayRefusalFixtures.replay); + const relay = stubPaymentRelay( + async () => relayRefusalFixtures.already_relayed, + ); await expect(relay.relay(REQUEST)).resolves.toEqual({ - ok: false, - reason: "replay", + kind: "refused", + reason: "already_relayed", }); }); }); diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index bbf47c86e..c7267be11 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -1,4 +1,5 @@ import { + isRelayRequest, parseRelayResponse, type RelayRequest, type RelayResponse, @@ -11,7 +12,10 @@ export interface RelayCallOptions { signal?: AbortSignal; } -/** Pluggable relay. The HTTP client and the in-memory stub both implement this. */ +/** + * Pluggable relay. `httpPaymentRelay` is the default HTTP client for David's + * route. A test double implements the same interface. + */ export interface PaymentRelay { relay( request: RelayRequest, @@ -31,35 +35,57 @@ export interface HttpRelayOptions { const DEFAULT_TIMEOUT_MS = 20_000; -/** - * David's route: `POST /v1/transactions/:id/payment/relay`. - * The route may not be deployed yet. One attempt, no retry. - */ -export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { +function requireHttpOptions(options: HttpRelayOptions): { + baseUrl: string; + apiKey: string; + timeoutMs: number; + fetchImpl: typeof fetch; +} { + if (!options || typeof options.baseUrl !== "string" || !options.baseUrl) { + throw new Error("Payment relay baseUrl 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", ); } - const baseUrl = options.baseUrl.replace(/\/$/, ""); - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const fetchImpl = options.fetchImpl ?? fetch; + if ( + options.timeoutMs !== undefined && + (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) + ) { + throw new Error("Payment relay timeoutMs must be a positive number"); + } return { - async relay(request, options) { - const url = `${baseUrl}/v1/transactions/${encodeURIComponent(request.transactionId)}/payment/relay`; - // The caller owns the deadline when it passes a signal. Otherwise this - // client is the only timeout. - const signal = options?.signal ?? AbortSignal.timeout(timeoutMs); + baseUrl: options.baseUrl.replace(/\/$/, ""), + apiKey, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + fetchImpl: options.fetchImpl ?? fetch, + }; +} + +/** + * David's route: `POST /v1/transactions/{id}/payment/relay`. + * One attempt, no retry. A redirect fails closed. + */ +export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { + const config = requireHttpOptions(options); + return { + async relay(request, call) { + if (!isRelayRequest(request)) { + throw new Error("Payment relay request is not a T7 body"); + } + const url = `${config.baseUrl}/v1/transactions/${encodeURIComponent(request.transactionId)}/payment/relay`; + const signal = call?.signal ?? AbortSignal.timeout(config.timeoutMs); let response: Response; try { - response = await fetchImpl(url, { + response = await config.fetchImpl(url, { method: "POST", redirect: "manual", headers: { accept: "application/json", "content-type": "application/json", - "x-api-key": apiKey, + "x-api-key": config.apiKey, "idempotency-key": request.idempotencyKey, }, body: JSON.stringify(request), @@ -71,8 +97,6 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { } throw new Error("Payment relay failed"); } - // A redirect would be the provider (or an attacker) pointing the real - // card somewhere else. Fail closed without reading a Location header. if (response.status >= 300 && response.status < 400) { throw new Error("Payment relay refused a redirect"); } @@ -87,7 +111,7 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { try { return parseRelayResponse(parsed); } catch { - // Fall through to a status-only error. Never include `text`. + // Fall through. Never include `text`. } } throw new Error(`Payment relay failed (HTTP ${response.status})`); @@ -95,7 +119,7 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { }; } -/** In-memory relay for tests and local demos. */ +/** In-memory relay for tests. */ export function stubPaymentRelay( handler: ( request: RelayRequest, @@ -110,8 +134,6 @@ export function stubPaymentRelay( } function isTimeout(error: unknown): boolean { - // Jest runs this file in a vm, so a DOMException from Node's fetch is not - // an `instanceof Error` here. Read the fields instead. if (!error || typeof error !== "object") return false; const name = "name" in error ? String(error.name) : ""; const message = "message" in error ? String(error.message) : ""; From b7f360fbcdc8f9ae04cda3c498b3033949ff1f5e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 21:10:23 +0000 Subject: [PATCH 18/26] fix(browser-pay): speak David's relay contract The relay body is injectionRef, profileId, pageOrigin, request, and idempotencyKey. Responses are a union on outcome, including unknown, which fails the page and is not retried. Drop Luhn, header synthesis, pauseOnlyUrls, tuple headers, bodyEncoding, and the invented refusal map. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-interceptor.md | 2 +- packages/browser-pay/README.md | 67 +- packages/browser-pay/examples/attach.ts | 14 +- packages/browser-pay/package.json | 5 - packages/browser-pay/src/cdp.test.ts | 16 + packages/browser-pay/src/cdp.ts | 49 +- packages/browser-pay/src/contract.test.ts | 107 --- packages/browser-pay/src/contract.ts | 217 ++---- packages/browser-pay/src/detect.test.ts | 135 ---- packages/browser-pay/src/detect.ts | 139 ++-- packages/browser-pay/src/fixtures.ts | 67 -- .../browser-pay/src/fixtures/refusals.json | 10 - .../src/fixtures/shopify-card-session.json | 32 - packages/browser-pay/src/headers.test.ts | 42 -- packages/browser-pay/src/headers.ts | 65 -- packages/browser-pay/src/index.ts | 34 +- .../src/interceptor.integration.test.ts | 673 +++++++++--------- .../src/interceptor.protocol.test.ts | 485 +++++++++++-- packages/browser-pay/src/interceptor.test.ts | 102 --- packages/browser-pay/src/interceptor.ts | 549 +++++--------- packages/browser-pay/src/logger.ts | 5 - packages/browser-pay/src/redact.ts | 19 - packages/browser-pay/src/relay.test.ts | 152 ++-- packages/browser-pay/src/relay.ts | 53 +- packages/browser-pay/src/testing.ts | 8 - 25 files changed, 1239 insertions(+), 1808 deletions(-) delete mode 100644 packages/browser-pay/src/contract.test.ts delete mode 100644 packages/browser-pay/src/detect.test.ts delete mode 100644 packages/browser-pay/src/fixtures.ts delete mode 100644 packages/browser-pay/src/fixtures/refusals.json delete mode 100644 packages/browser-pay/src/fixtures/shopify-card-session.json delete mode 100644 packages/browser-pay/src/headers.test.ts delete mode 100644 packages/browser-pay/src/headers.ts delete mode 100644 packages/browser-pay/src/interceptor.test.ts delete mode 100644 packages/browser-pay/src/redact.ts delete mode 100644 packages/browser-pay/src/testing.ts diff --git a/.changeset/browser-pay-interceptor.md b/.changeset/browser-pay-interceptor.md index 2e2c030f9..a1bc92226 100644 --- a/.changeset/browser-pay-interceptor.md +++ b/.changeset/browser-pay-interceptor.md @@ -2,4 +2,4 @@ "@sapiom/browser-pay": minor --- -Add `@sapiom/browser-pay`. It pauses a held placeholder card over CDP and posts the T7 body to `POST /v1/transactions/{id}/payment/relay`. Matching is a Luhn gate plus the exact number anywhere in the raw body. The idempotency key is sha256 of method, URL, and body. +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. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 6df4be5c0..1b3c141f3 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -1,6 +1,6 @@ # @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 the relay. The browser is fulfilled with the processor's response, or the request fails. The placeholder is never continued. +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 @@ -9,54 +9,61 @@ Browser-side payment interceptor. The agent types a governance placeholder into 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 or a relay error fails the paused request. +6. The page receives the processor status, headers, and body. A refusal, an unknown outcome, or a relay error fails the paused request. -## What David implements +## Relay contract -`POST /v1/transactions/{transactionId}/payment/relay` with `x-api-key` and `idempotency-key`. - -Request JSON: +`POST /v1/transactions/{transactionId}/payment/relay` with `x-api-key`. `transactionId` is only in the path. The JSON body is: ```json { - "transactionId": "tx_…", "injectionRef": "inj_…", - "merchantOrigin": "https://shop.example", - "url": "https://checkout.pci.shopifyinc.com/sessions", - "method": "POST", - "headers": [["content-type", "application/json"], ["cookie", "…"]], - "body": "", + "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": "" } ``` -`merchantOrigin` is the top-level page origin. `headers` is `[name, value][]` so repeated `Set-Cookie` values on the way back survive. `idempotencyKey` is also the `idempotency-key` header. Shopify's card iframe retries share one key. The body can contain the placeholder number. Do not log or persist `headers` (they can include HttpOnly cookies) or the body. +`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 { - "kind": "relayed", - "status": 200, - "headers": [["content-type", "application/json"], ["set-cookie", "a=1"]], - "body": "", - "bodyEncoding": "utf8" + "outcome": "relayed", + "response": { + "status": 200, + "headers": { "content-type": "application/json" }, + "body": "" + } } ``` -`bodyEncoding` is optional and defaults to `utf8`. Use `base64` when the processor body is not UTF-8 text. +`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 -{ "kind": "refused", "reason": "already_relayed" } +{ "outcome": "refused", "reason": "already_relayed" } ``` -Reasons this client accepts: `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `expired`, `closed`, `refused`, `unknown`. Any other string is reported as `unknown`. A transport error, a redirect, or an unreadable body fails the paused browser request. It is not fulfilled with the refusal JSON. +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 same `idempotencyKey` after a successful forward should return the stored processor response. A new key after the placeholder was consumed is `already_relayed`. +The injection is consumed. The paused request fails and the same body is not sent again. -The page is fulfilled with `status`, `headers`, and `body`. `content-length`, `content-encoding`, and other hop-by-hop headers are dropped because CDP sends the body bytes itself. Return the body the page should read, not a gzip wrapper. If that fulfill fails, the paused request is failed and the placeholder is not continued. The agent event is `relayed_unfulfilled`. +A transport error, a redirect, or an unreadable body fails the paused browser request. 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 @@ -67,22 +74,22 @@ const handle = await attachInterceptor(cdpUrl, { relay: httpPaymentRelay({ baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", apiKey: process.env.SAPIOM_API_KEY, + transactionId, }), - transactionId, + profileId: "shopify-card-vault", placeholder, // number, expMonth, expYear, cvc, injectionRef - pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], }); ``` -`pauseOnlyUrls` limits Fetch to the payment host so a captcha solver on the checkout page is not paused. Omit it to pause every page and iframe. +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. Expiry and CVC are not compared. The backend's `checkPlaceholderUse` decides whether the number is valid. A paused request that does not hold the placeholder is continued. -Matching is a Luhn check on the held number, then an exact search for that digit sequence anywhere in the raw body. Spaces, dashes, and their URL-encoded or JSON-escaped forms count. Expiry and CVC are not compared. A different Luhn-valid number is continued, not relayed. +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. A refusal is logged once. A transport failure drops the key so a later retry can resend. +Identical in-flight retries (same method, URL, and body) share one relay call. A refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. -Missing `Sec-Fetch-Site`, `Sec-Fetch-Dest`, `Accept-Language`, and `Cookie` are filled the way Chrome would, without overriding a header the request already has. `Sec-Fetch-Site` uses the schemeful same-site check with the last two hostname labels as the registrable domain. This package has no public suffix list, so a multi-label suffix such as `co.uk` is classified too broadly. Shopify's card hosts are not in that set. +Captcha widgets, extension pages, and workers are not paused. 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 already on the payment host is attached before `attachInterceptor` resolves. A paused request whose CDP session was never attached is failed. +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. diff --git a/packages/browser-pay/examples/attach.ts b/packages/browser-pay/examples/attach.ts index 10db4d919..42d3de4c3 100644 --- a/packages/browser-pay/examples/attach.ts +++ b/packages/browser-pay/examples/attach.ts @@ -5,7 +5,7 @@ * * A raw CDP URL works the same way: `attachInterceptor(process.env.CDP_URL, …)`. * - * SAPIOM_API_KEY=… TRANSACTION_ID=… INJECTION_REF=… \ + * SAPIOM_API_KEY=… TRANSACTION_ID=… INJECTION_REF=… PROFILE_ID=shopify-card-vault \ * PLACEHOLDER_NUMBER=… PLACEHOLDER_EXP_MONTH=… PLACEHOLDER_EXP_YEAR=… PLACEHOLDER_CVC=… \ * npx tsx examples/attach.ts */ @@ -14,7 +14,6 @@ import { createClient } from "@sapiom/tools"; import { attachInterceptor, httpPaymentRelay, - maskCardNumber, type PayLogger, } from "../src/index.js"; @@ -33,9 +32,10 @@ async function main(): Promise { const transactionId = process.env.TRANSACTION_ID; const injectionRef = process.env.INJECTION_REF; const number = process.env.PLACEHOLDER_NUMBER; - if (!transactionId || !injectionRef || !number) { + const profileId = process.env.PROFILE_ID; + if (!transactionId || !injectionRef || !number || !profileId) { throw new Error( - "TRANSACTION_ID, INJECTION_REF, and PLACEHOLDER_NUMBER are required", + "TRANSACTION_ID, INJECTION_REF, PROFILE_ID, and PLACEHOLDER_NUMBER are required", ); } @@ -52,8 +52,9 @@ async function main(): Promise { relay: httpPaymentRelay({ baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", apiKey, + transactionId, }), - transactionId, + profileId, placeholder: { number, expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), @@ -63,7 +64,8 @@ async function main(): Promise { }, logger: log, }); - log.info("interceptor attached", { placeholder: maskCardNumber(number) }); + 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") { diff --git a/packages/browser-pay/package.json b/packages/browser-pay/package.json index 307badec7..2b5699236 100644 --- a/packages/browser-pay/package.json +++ b/packages/browser-pay/package.json @@ -28,11 +28,6 @@ "types": "./dist/cjs/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" - }, - "./testing": { - "types": "./dist/cjs/testing.d.ts", - "import": "./dist/esm/testing.js", - "require": "./dist/cjs/testing.js" } }, "files": [ diff --git a/packages/browser-pay/src/cdp.test.ts b/packages/browser-pay/src/cdp.test.ts index 1774d0728..ab4b7d331 100644 --- a/packages/browser-pay/src/cdp.test.ts +++ b/packages/browser-pay/src/cdp.test.ts @@ -80,6 +80,22 @@ describe("CdpConnection", () => { } }); + 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 { diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index 987ba2378..2e39fe5f1 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -71,15 +71,15 @@ class WebSocketClient { 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", () => { - if (this.closed) return; this.closed = true; - this.onclose(); + this.notifyClose(); }); socket.on("error", (error: Error) => { this.fail( @@ -143,6 +143,12 @@ class WebSocketClient { this.closed = true; this.onerror(error); this.socket.destroy(); + this.notifyClose(); + } + + private notifyClose(): void { + if (this.notified) return; + this.notified = true; this.onclose(); } @@ -242,7 +248,9 @@ class WebSocketClient { const opcode = first & 0x0f; const fin = (first & 0x80) !== 0; if (opcode === 0x8) { - this.close(); + this.closed = true; + this.notifyClose(); + this.socket.end(); return; } if (opcode === 0x9) { @@ -267,7 +275,6 @@ class WebSocketClient { } private frame(opcode: number, data: Buffer): void { - if (this.closed) return; const mask = crypto.randomBytes(4); const length = data.length; let header: Buffer; @@ -294,17 +301,19 @@ class WebSocketClient { } 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; - this.closed = true; 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); } @@ -312,6 +321,7 @@ class WebSocketClient { export class CdpConnection { private nextId = 0; + private closed = false; private readonly pending = new Map(); private readonly listeners = new Set<(event: CdpEvent) => void>(); @@ -356,6 +366,7 @@ export class CdpConnection { rejectAll(error); }; socket.onclose = () => { + this.closed = true; rejectAll(new Error("CDP connection closed")); }; } @@ -375,6 +386,10 @@ export class CdpConnection { ): 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`)); @@ -384,14 +399,22 @@ export class CdpConnection { reject, timer, }); - this.socket.send( - JSON.stringify({ - id, - method, - params, - ...(sessionId ? { sessionId } : {}), - }), - ); + 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"), + ); + } }); } diff --git a/packages/browser-pay/src/contract.test.ts b/packages/browser-pay/src/contract.test.ts deleted file mode 100644 index 6cf3fdda8..000000000 --- a/packages/browser-pay/src/contract.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - paymentIdempotencyKey, - parseRelayResponse, - relayHeadersFromRecord, -} from "./contract"; - -describe("paymentIdempotencyKey", () => { - it("is sha256 of method, url, and body, stable for the same triple", () => { - const key = paymentIdempotencyKey("POST", "https://pay.example/s", "{}"); - expect(key).toBe( - paymentIdempotencyKey("POST", "https://pay.example/s", "{}"), - ); - expect(key).not.toBe( - paymentIdempotencyKey("POST", "https://pay.example/s", "{ }"), - ); - expect(key).toMatch(/^[0-9a-f]{64}$/); - }); -}); - -describe("parseRelayResponse", () => { - it("rejects a payload that is not relayed or refused", () => { - expect(() => parseRelayResponse(null)).toThrow( - "Payment relay returned an unreadable response", - ); - expect(() => parseRelayResponse({ kind: "refused" })).toThrow( - "Payment relay returned an unreadable response", - ); - expect(() => - parseRelayResponse({ - kind: "relayed", - status: 200, - body: 1, - headers: {}, - }), - ).toThrow("Payment relay returned an unreadable response"); - }); - - it("maps an unknown refusal reason and keeps a known one", () => { - expect( - parseRelayResponse({ - kind: "refused", - reason: "made_up", - body: "4242424242424242", - }), - ).toEqual({ kind: "refused", reason: "unknown" }); - expect( - parseRelayResponse({ kind: "refused", reason: "already_relayed" }), - ).toEqual({ kind: "refused", reason: "already_relayed" }); - expect( - parseRelayResponse({ kind: "refused", reason: "total_changed" }), - ).toEqual({ kind: "refused", reason: "total_changed" }); - }); - - it("accepts header tuples, repeated Set-Cookie, and a base64 body", () => { - const body = Buffer.from('{"id":"s"}', "utf8").toString("base64"); - expect( - parseRelayResponse({ - kind: "relayed", - status: 200, - headers: [ - ["content-type", "application/json"], - ["set-cookie", "a=1"], - ["set-cookie", "b=2"], - ], - body, - bodyEncoding: "base64", - }), - ).toEqual({ - kind: "relayed", - status: 200, - headers: [ - ["content-type", "application/json"], - ["set-cookie", "a=1"], - ["set-cookie", "b=2"], - ], - body, - bodyEncoding: "base64", - }); - }); - - it("accepts a header map and defaults body encoding to utf8", () => { - expect( - parseRelayResponse({ - kind: "relayed", - status: 201, - headers: { - "content-type": "application/json", - "set-cookie": "a=1\nb=2", - }, - body: "{}", - }), - ).toEqual({ - kind: "relayed", - status: 201, - headers: [ - ["content-type", "application/json"], - ["set-cookie", "a=1"], - ["set-cookie", "b=2"], - ], - body: "{}", - bodyEncoding: "utf8", - }); - expect(relayHeadersFromRecord({ accept: "application/json" })).toEqual([ - ["accept", "application/json"], - ]); - }); -}); diff --git a/packages/browser-pay/src/contract.ts b/packages/browser-pay/src/contract.ts index 3ee173f79..6f255bedd 100644 --- a/packages/browser-pay/src/contract.ts +++ b/packages/browser-pay/src/contract.ts @@ -1,81 +1,31 @@ -/** - * T7, the body David's relay accepts on - * `POST /v1/transactions/{transactionId}/payment/relay`. - * - * The paused request body may contain the placeholder the agent typed. - * A success body is the payment provider's response. A refusal names a - * reason and does not echo the request. Real card values never travel - * back to the browser. - * - * `headers` may include browser cookies. The relay must not log or persist - * them. The backend scrubs provider response bodies before they return. - * This package also refuses to fulfill a response that still contains a - * Luhn-valid card number other than the held placeholder. - * - * Refusal reasons are the ones named on the relay: `already_relayed` and - * `total_changed` (SAP-4071, PR 5792), `key_rotated` and `card_removed` - * (PR 5754), and the checkPlaceholderUse answers `expired`, `closed`, - * `refused`, and `unknown` (PR 5758, updated in 5754). An unrecognized - * reason is reported as `unknown`. - */ - import { createHash } from "node:crypto"; -export const RELAY_REFUSAL_REASONS = [ - "already_relayed", - "total_changed", - "key_rotated", - "card_removed", - "expired", - "closed", - "refused", - "unknown", -] as const; - -export type RelayRefusalReason = (typeof RELAY_REFUSAL_REASONS)[number]; - -export type RelayBodyEncoding = "utf8" | "base64"; - -/** One header. The same name may appear more than once (Set-Cookie). */ -export type RelayHeader = [name: string, value: string]; +export interface RelayHttpRequest { + method: string; + url: string; + headers: Record; + body: string; +} -/** - * What the interceptor sends. `merchantOrigin` is the top-level page origin - * (scheme + host), the payTo value bound at authorization. - * `idempotencyKey` is sha256(method, url, body). The HTTP client also sends - * it as the `idempotency-key` header. Shopify's card iframe retries share - * one key. - */ export interface RelayRequest { - transactionId: string; injectionRef: string; - merchantOrigin: string; - url: string; - method: string; - headers: RelayHeader[]; - body: string; + profileId: string; + pageOrigin: string; + request: RelayHttpRequest; idempotencyKey: string; } -/** Provider response to fulfill the paused browser request with. */ -export interface RelaySuccess { - kind: "relayed"; +export interface RelayedHttpResponse { status: number; - headers: RelayHeader[]; - /** Provider response body. `bodyEncoding` says how `body` is encoded. */ + headers: Record; body: string; - bodyEncoding: RelayBodyEncoding; } -/** Verification failed. Nothing was forwarded. The request is not echoed. */ -export interface RelayRefusal { - kind: "refused"; - reason: RelayRefusalReason; -} +export type RelayResponse = + | { outcome: "relayed"; response: RelayedHttpResponse } + | { outcome: "refused"; reason: string } + | { outcome: "unknown" }; -export type RelayResponse = RelaySuccess | RelayRefusal; - -/** Stable across retries of the same paused request. */ export function paymentIdempotencyKey( method: string, url: string, @@ -90,127 +40,48 @@ export function paymentIdempotencyKey( .digest("hex"); } -export function isRelayRefusalReason( - value: string, -): value is RelayRefusalReason { - for (const reason of RELAY_REFUSAL_REASONS) { - if (reason === value) return true; - } - return false; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isRelayHeaderList(value: unknown): value is RelayHeader[] { - return ( - Array.isArray(value) && - value.every( - (entry) => - Array.isArray(entry) && - entry.length === 2 && - typeof entry[0] === "string" && - typeof entry[1] === "string", - ) - ); -} - -/** A string map, with multiple Set-Cookie values split on newlines. */ -export function relayHeadersFromRecord( - headers: Record, -): RelayHeader[] { - const out: RelayHeader[] = []; - for (const [name, value] of Object.entries(headers)) { - if (name.toLowerCase() === "set-cookie") { - for (const part of value.split("\n")) { - if (part.length > 0) out.push([name, part]); - } - continue; - } - out.push([name, value]); - } - return out; -} - -function headersFromUnknown(value: unknown): RelayHeader[] | undefined { - if (isRelayHeaderList(value)) return value; +function stringRecord(value: unknown): Record | undefined { if (!isRecord(value)) return undefined; - const entries = Object.entries(value); - if (!entries.every(([, entry]) => typeof entry === "string")) { - return undefined; + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") return undefined; + out[key] = entry; } - const record: Record = {}; - for (const [key, entry] of entries) { - if (typeof entry === "string") record[key] = entry; - } - return relayHeadersFromRecord(record); -} - -function isBodyEncoding(value: unknown): value is RelayBodyEncoding { - return value === "utf8" || value === "base64"; -} - -function readReason(value: unknown): RelayRefusalReason | undefined { - if (typeof value !== "string") return undefined; - return isRelayRefusalReason(value) ? value : "unknown"; + return out; } -/** - * Accept `{ kind: "relayed", status, headers, body }` or - * `{ kind: "refused", reason }`. `bodyEncoding` defaults to utf8. - * The error never includes the payload. - */ export function parseRelayResponse(value: unknown): RelayResponse { - if (!isRecord(value)) { + if (!isRecord(value) || typeof value.outcome !== "string") { throw new Error("Payment relay returned an unreadable response"); } - if (value.kind === "refused") { - const reason = readReason(value.reason); - if (!reason) { + 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 { kind: "refused", reason }; + return { outcome: "refused", reason: value.reason }; } - if (value.kind === "relayed") { - const headers = headersFromUnknown(value.headers); - const bodyEncoding = - value.bodyEncoding === undefined ? "utf8" : value.bodyEncoding; - if ( - typeof value.status !== "number" || - !Number.isInteger(value.status) || - value.status < 100 || - value.status > 599 || - typeof value.body !== "string" || - !headers || - !isBodyEncoding(bodyEncoding) - ) { - throw new Error("Payment relay returned an unreadable response"); - } - return { - kind: "relayed", - status: value.status, - headers, - body: value.body, - bodyEncoding, - }; + if (value.outcome !== "relayed" || !isRecord(value.response)) { + throw new Error("Payment relay returned an unreadable response"); } - throw new Error("Payment relay returned an unreadable response"); -} - -export function isRelayRequest(value: unknown): value is RelayRequest { - if (!isRecord(value)) return false; - return ( - typeof value.transactionId === "string" && - value.transactionId.length > 0 && - typeof value.injectionRef === "string" && - value.injectionRef.length > 0 && - typeof value.merchantOrigin === "string" && - typeof value.url === "string" && - typeof value.method === "string" && - typeof value.body === "string" && - typeof value.idempotencyKey === "string" && - value.idempotencyKey.length > 0 && - isRelayHeaderList(value.headers) - ); + 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 deleted file mode 100644 index 2a4266ba6..000000000 --- a/packages/browser-pay/src/detect.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - luhnHeldPan, - matchHeldPlaceholderInBody, - textContainsForeignCard, - textHoldsPlaceholderNumber, - type HeldPlaceholder, -} from "./detect"; -import { maskCardNumber, redact } from "./redact"; - -const HELD: HeldPlaceholder = { - number: "4242424242424242", - expMonth: 12, - expYear: 2028, - cvc: "123", - injectionRef: "inj_test", -}; - -const FOREIGN: HeldPlaceholder = { - ...HELD, - number: "4111111111111111", - injectionRef: "inj_foreign", -}; - -describe("luhn gate", () => { - it("accepts a Luhn-valid number and rejects a check-digit failure", () => { - expect(luhnHeldPan("4242 4242 4242 4242")).toBe("4242424242424242"); - expect(luhnHeldPan("4242424242424243")).toBeNull(); - expect(luhnHeldPan("424242424242")).toBeNull(); - }); - - it("does not match when the held number fails Luhn", () => { - const held: HeldPlaceholder = { ...HELD, number: "4242424242424243" }; - expect( - matchHeldPlaceholderInBody( - JSON.stringify({ number: "4242424242424243" }), - held, - ), - ).toEqual({ kind: "not_held" }); - }); -}); - -describe("matchHeldPlaceholderInBody", () => { - it("matches the held number anywhere, including grouped digits", () => { - expect( - matchHeldPlaceholderInBody(`note 4242 4242 4242 4242 trailing`, HELD), - ).toEqual({ kind: "match", injectionRef: "inj_test" }); - expect(matchHeldPlaceholderInBody("4242-4242-4242-4242", HELD)).toEqual({ - kind: "match", - injectionRef: "inj_test", - }); - }); - - it("matches a JSON body and a form body without comparing expiry or cvc", () => { - const json = JSON.stringify({ - credit_card: { - number: "4242 4242 4242 4242", - month: 1, - year: 1999, - verification_value: "999", - }, - }); - expect(matchHeldPlaceholderInBody(json, HELD)).toEqual({ - kind: "match", - injectionRef: "inj_test", - }); - const form = - "credit_card[number]=4242+4242+4242+4242&credit_card[month]=1&credit_card[verification_value]=000"; - expect(matchHeldPlaceholderInBody(form, HELD)).toEqual({ - kind: "match", - injectionRef: "inj_test", - }); - }); - - it("matches percent-encoded and JSON-escaped separators", () => { - expect( - textHoldsPlaceholderNumber( - encodeURIComponent("4242 4242 4242 4242"), - HELD.number, - ), - ).toBe(true); - expect( - matchHeldPlaceholderInBody("4242%204242%204242%204242", HELD), - ).toEqual({ kind: "match", injectionRef: "inj_test" }); - expect( - matchHeldPlaceholderInBody("4242\\u00204242\\u00204242\\u00204242", HELD), - ).toEqual({ kind: "match", injectionRef: "inj_test" }); - }); - - it("does not match a foreign Luhn-valid number", () => { - const body = JSON.stringify({ - credit_card: { number: "4111111111111111", month: 12, year: 2028 }, - }); - expect(matchHeldPlaceholderInBody(body, HELD)).toEqual({ - kind: "not_held", - }); - expect(matchHeldPlaceholderInBody(body, FOREIGN)).toEqual({ - kind: "match", - injectionRef: "inj_foreign", - }); - }); - - it("returns not_held for an empty body and for digits that are not a card", () => { - expect(matchHeldPlaceholderInBody("", HELD)).toEqual({ kind: "not_held" }); - expect(matchHeldPlaceholderInBody('{"order":12345}', HELD)).toEqual({ - kind: "not_held", - }); - }); - - it("still sees the placeholder when more digits follow it", () => { - expect(matchHeldPlaceholderInBody("4242 4242 4242 4242 999", HELD)).toEqual( - { kind: "match", injectionRef: "inj_test" }, - ); - }); -}); - -describe("textContainsForeignCard", () => { - it("flags a different Luhn card and ignores the held number", () => { - expect(textContainsForeignCard("4111111111111111", HELD.number)).toBe(true); - expect(textContainsForeignCard(HELD.number, HELD.number)).toBe(false); - expect(textContainsForeignCard(HELD.number, "4242 4242 4242 4242")).toBe( - false, - ); - expect(textContainsForeignCard('{"id":"west-fixture"}', HELD.number)).toBe( - false, - ); - }); -}); - -describe("redact", () => { - it("masks a card number to last4 and leaves short numbers", () => { - expect(maskCardNumber("4242424242424242")).toBe("••••4242"); - expect(redact("pan 4242-4242-4242-4242 end")).toBe("pan ••••4242 end"); - expect(redact("order 1001")).toBe("order 1001"); - }); -}); diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts index 15e9d3204..f64c10abd 100644 --- a/packages/browser-pay/src/detect.ts +++ b/packages/browser-pay/src/detect.ts @@ -1,15 +1,3 @@ -/** - * Whether a paused request body holds the one placeholder this interceptor - * was given. - * - * Luhn gate first: the held number has to be 13–19 digits and pass Luhn, or - * nothing matches. Then the exact digit sequence, anywhere in the raw body. - * Digits may be grouped by spaces or dashes, including the URL-encoded and - * JSON-escaped forms of those separators. Expiry and CVC are not compared. - * A different Luhn-valid number does not match. - */ - -/** A placeholder as the API hands it to the agent. */ export interface HeldPlaceholder { number: string; expMonth: number; @@ -18,37 +6,50 @@ export interface HeldPlaceholder { injectionRef: string; } -export type BodyPlaceholderMatch = - | { kind: "match"; injectionRef: string } - | { kind: "not_held" }; - -/** Separators the card field may insert between digits, raw or encoded. */ -const SEPARATOR = "(?:[ \\t-]|\\+|%20|%2[Dd]|\\\\u0020|\\\\u002[Dd])?"; - -export function luhnCheckDigit(body: string): string { - let sum = 0; - for (let i = 0; i < body.length; i++) { - let digit = Number(body[body.length - 1 - i]); - if (i % 2 === 0) { - digit *= 2; - if (digit > 9) digit -= 9; - } - sum += digit; - } - return String((10 - (sum % 10)) % 10); +function placeholderDigits(number: string): string | undefined { + const digits = number.replace(/[\s-]/g, ""); + if (digits.length === 0 || !/^\d+$/.test(digits)) return undefined; + return digits; } -/** Digits of a held number that pass Luhn. Null when the gate is closed. */ -export function luhnHeldPan(number: string): string | null { - const digits = number.replace(/[\s-]/g, ""); - if (!/^\d{13,19}$/.test(digits)) return null; - return luhnCheckDigit(digits.slice(0, -1)) === digits.slice(-1) - ? digits - : null; +function separatorLength(text: string, index: number): number { + const code = text.charCodeAt(index); + if (code === 32 || code === 9 || code === 45 || code === 43) return 1; + if ( + text.startsWith("%20", index) || + text.startsWith("%2D", index) || + text.startsWith("%2d", index) + ) { + return 3; + } + if ( + text.startsWith("\\u0020", index) || + text.startsWith("\\u002D", index) || + text.startsWith("\\u002d", index) + ) { + return 6; + } + return 0; } -function placeholderPattern(pan: string): RegExp { - return new RegExp(pan.split("").join(SEPARATOR)); +function holdsDigits(text: string, digits: string): boolean { + const last = text.length - digits.length; + for (let start = 0; start <= last; start++) { + if (text[start] !== digits[0]) continue; + let index = start + 1; + let matched = true; + for (let digit = 1; digit < digits.length; digit++) { + const skip = separatorLength(text, index); + if (skip > 0 && text[index + skip] === digits[digit]) index += skip; + if (text[index] !== digits[digit]) { + matched = false; + break; + } + index += 1; + } + if (matched) return true; + } + return false; } function decodeForSearch(text: string): string { @@ -64,61 +65,23 @@ function decodeForSearch(text: string): string { } } -/** - * True when `text` contains the held PAN. The PAN must already have passed - * the Luhn gate. Grouped, percent-encoded, and JSON-escaped forms count. - */ export function textHoldsPlaceholderNumber( text: string, number: string, ): boolean { - const pan = luhnHeldPan(number); - if (!pan || text.length === 0) return false; - const pattern = placeholderPattern(pan); - if (pattern.test(text)) return true; + if (number.length === 0 || text.length === 0) return false; + if (text.includes(number)) return true; + const digits = placeholderDigits(number); + if (!digits) return false; + if (holdsDigits(text, digits)) return true; const decoded = decodeForSearch(text); if (decoded === text) return false; - pattern.lastIndex = 0; - return pattern.test(decoded); + return decoded.includes(number) || holdsDigits(decoded, digits); } -/** - * A Luhn-valid card number in `text` that is not the held placeholder. - * Used to refuse a relay response that still carries a real PAN. - */ -export function textContainsForeignCard( - text: string, - placeholderNumber: string, -): boolean { - const held = luhnHeldPan(placeholderNumber); - if (!held) return false; - const decoded = decodeForSearch(text); - const runs = decoded.match(/\d(?:[\s+-]*\d)+/g) ?? []; - for (const run of runs) { - const digits = run.replace(/\D/g, ""); - if (digits.length < 13) continue; - const last = Math.min(19, digits.length); - for (let length = 13; length <= last; length++) { - for (let index = 0; index + length <= digits.length; index++) { - const candidate = digits.slice(index, index + length); - if (candidate === held || held.includes(candidate)) continue; - if (luhnHeldPan(candidate)) return true; - } - } - } - return false; -} - -/** - * Whether `body` contains the single held placeholder. - * `not_held` covers a foreign card, a Luhn failure, and an empty body. - */ -export function matchHeldPlaceholderInBody( - body: string, - held: HeldPlaceholder, -): BodyPlaceholderMatch { - if (!textHoldsPlaceholderNumber(body, held.number)) { - return { kind: "not_held" }; - } - return { kind: "match", injectionRef: held.injectionRef }; +export function redactHeld(value: string, number: string): string { + let out = number.length > 0 ? value.split(number).join("••••") : value; + const digits = placeholderDigits(number); + if (digits && digits !== number) out = out.split(digits).join("••••"); + return out; } diff --git a/packages/browser-pay/src/fixtures.ts b/packages/browser-pay/src/fixtures.ts deleted file mode 100644 index 2b81c0732..000000000 --- a/packages/browser-pay/src/fixtures.ts +++ /dev/null @@ -1,67 +0,0 @@ -import shopifyCardSessionJson from "./fixtures/shopify-card-session.json"; -import refusalJson from "./fixtures/refusals.json"; -import { - isRelayRequest, - parseRelayResponse, - RELAY_REFUSAL_REASONS, - type RelayRefusal, - type RelayRefusalReason, - type RelayRequest, - type RelaySuccess, -} from "./contract.js"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function loadRoundTrip(value: unknown): { - request: RelayRequest; - response: RelaySuccess; -} { - if (!isRecord(value) || !isRelayRequest(value.request)) { - throw new Error("Shopify card-session fixture request is not a T7 body"); - } - const response = parseRelayResponse(value.response); - if (response.kind !== "relayed") { - throw new Error("Shopify card-session fixture response is not relayed"); - } - return { request: value.request, response }; -} - -function loadRefusals( - value: unknown, -): Record { - if (!isRecord(value)) { - throw new Error("Refusal fixtures are unreadable"); - } - const out: Partial> = {}; - for (const reason of RELAY_REFUSAL_REASONS) { - const parsed = parseRelayResponse(value[reason]); - if (parsed.kind !== "refused" || parsed.reason !== reason) { - throw new Error("Refusal fixture does not match its reason"); - } - out[reason] = parsed; - } - if (!refusalsComplete(out)) { - throw new Error("Refusal fixtures are missing a reason"); - } - return out; -} - -function refusalsComplete( - value: Partial>, -): value is Record { - return RELAY_REFUSAL_REASONS.every((reason) => value[reason] !== undefined); -} - -/** - * One Shopify card-session round trip. The number in the request body is a - * placeholder stand-in (a published test PAN), not a credential. The response - * is only the provider session id. - */ -export const shopifyCardSessionRoundTrip = loadRoundTrip( - shopifyCardSessionJson, -); - -/** One refusal per reason the relay can return. None of them echo the request. */ -export const relayRefusalFixtures = loadRefusals(refusalJson); diff --git a/packages/browser-pay/src/fixtures/refusals.json b/packages/browser-pay/src/fixtures/refusals.json deleted file mode 100644 index 8b69411c1..000000000 --- a/packages/browser-pay/src/fixtures/refusals.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "already_relayed": { "kind": "refused", "reason": "already_relayed" }, - "total_changed": { "kind": "refused", "reason": "total_changed" }, - "key_rotated": { "kind": "refused", "reason": "key_rotated" }, - "card_removed": { "kind": "refused", "reason": "card_removed" }, - "expired": { "kind": "refused", "reason": "expired" }, - "closed": { "kind": "refused", "reason": "closed" }, - "refused": { "kind": "refused", "reason": "refused" }, - "unknown": { "kind": "refused", "reason": "unknown" } -} diff --git a/packages/browser-pay/src/fixtures/shopify-card-session.json b/packages/browser-pay/src/fixtures/shopify-card-session.json deleted file mode 100644 index 76a6607ea..000000000 --- a/packages/browser-pay/src/fixtures/shopify-card-session.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "request": { - "transactionId": "tx_fixture_shopify", - "injectionRef": "inj_fixture_shopify", - "merchantOrigin": "https://shop.example", - "url": "https://checkout.pci.shopifyinc.com/sessions", - "method": "POST", - "headers": [ - ["accept", "application/json"], - ["content-type", "application/json"], - ["origin", "https://checkout.pci.shopifyinc.com"], - ["referer", "https://checkout.pci.shopifyinc.com/"], - ["user-agent", "Mozilla/5.0 (fixture)"], - ["accept-language", "en-US,en;q=0.9"], - ["shopify-identification-signature", "fixture-signature"] - ], - "body": "{\"credit_card\":{\"number\":\"4242424242424242\",\"month\":12,\"year\":2028,\"verification_value\":\"123\",\"name\":\"Fixture Buyer\"},\"payment_session_scope\":\"fixture_scope\"}", - "idempotencyKey": "req_fixture_shopify" - }, - "response": { - "kind": "relayed", - "status": 200, - "headers": [ - ["content-type", "application/json"], - ["access-control-allow-origin", "https://checkout.pci.shopifyinc.com"], - ["set-cookie", "session=a"], - ["set-cookie", "session=b"] - ], - "body": "{\"id\":\"west-fixture-session\"}", - "bodyEncoding": "utf8" - } -} diff --git a/packages/browser-pay/src/headers.test.ts b/packages/browser-pay/src/headers.test.ts deleted file mode 100644 index 70ae7d5f1..000000000 --- a/packages/browser-pay/src/headers.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { acceptLanguageHeader, secFetchDest, secFetchSite } from "./headers"; - -describe("secFetchSite", () => { - it("reports same-origin, same-site, and cross-site", () => { - expect( - secFetchSite( - "https://checkout.pci.shopifyinc.com/sessions", - "https://checkout.pci.shopifyinc.com/blank", - ), - ).toBe("same-origin"); - expect( - secFetchSite( - "https://api.shop.example/pay", - "https://shop.example/checkout", - ), - ).toBe("same-site"); - expect( - secFetchSite( - "https://checkout.pci.shopifyinc.com/sessions", - "https://shop.example/checkout", - ), - ).toBe("cross-site"); - }); - - it("returns undefined for a URL that cannot be parsed", () => { - expect(secFetchSite("not a url", "https://shop.example/")).toBeUndefined(); - }); -}); - -describe("acceptLanguageHeader", () => { - it("uses Chrome q-values after the first language", () => { - expect(acceptLanguageHeader(["en-US", "en"])).toBe("en-US,en;q=0.9"); - }); -}); - -describe("secFetchDest", () => { - it("is empty for fetch and xhr", () => { - expect(secFetchDest("Fetch")).toBe("empty"); - expect(secFetchDest("XHR")).toBe("empty"); - expect(secFetchDest("Document")).toBeUndefined(); - }); -}); diff --git a/packages/browser-pay/src/headers.ts b/packages/browser-pay/src/headers.ts deleted file mode 100644 index ee84f0cd6..000000000 --- a/packages/browser-pay/src/headers.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Headers Chrome's network stack adds after the Fetch request stage. - * Missing ones are filled. A header the request already has is left alone. - * - * Sec-Fetch-Site follows the schemeful same-site check. The registrable - * domain is the last two hostname labels. This repo has no public suffix - * list, so a multi-label public suffix such as `co.uk` is treated as the - * site itself: `a.co.uk` and `b.co.uk` are reported same-site. Shopify's - * card hosts are not in that set. - */ - -export type SecFetchSite = "same-origin" | "same-site" | "cross-site"; - -function registrableDomain(hostname: string): string { - const labels = hostname.toLowerCase().split(".").filter(Boolean); - if (labels.length <= 2) return labels.join("."); - return labels.slice(-2).join("."); -} - -/** Schemeful same-site of `requestUrl` relative to the frame that sent it. */ -export function secFetchSite( - requestUrl: string, - frameUrl: string, -): SecFetchSite | undefined { - let target: URL; - let frame: URL; - try { - target = new URL(requestUrl); - frame = new URL(frameUrl); - } catch { - return undefined; - } - if (target.origin === frame.origin) return "same-origin"; - if ( - target.protocol === frame.protocol && - registrableDomain(target.hostname) === registrableDomain(frame.hostname) - ) { - return "same-site"; - } - return "cross-site"; -} - -/** Chrome's Accept-Language q-values from `navigator.languages`. */ -export function acceptLanguageHeader(languages: readonly string[]): string { - return languages - .map((language, index) => - index === 0 - ? language - : `${language};q=${Math.max(0.1, 1 - index / 10).toFixed(1)}`, - ) - .join(","); -} - -export function cookieHeader( - cookies: readonly { name: string; value: string }[], -): string { - return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); -} - -/** `empty` for fetch and XHR. Other resource types are left unset. */ -export function secFetchDest(resourceType: string): "empty" | undefined { - return resourceType === "Fetch" || resourceType === "XHR" - ? "empty" - : undefined; -} diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts index abaea4a4a..989686b72 100644 --- a/packages/browser-pay/src/index.ts +++ b/packages/browser-pay/src/index.ts @@ -1,4 +1,4 @@ -export { attachInterceptor, resolveCdpUrl } from "./interceptor.js"; +export { attachInterceptor } from "./interceptor.js"; export type { AttachOptions, InterceptorEvent, @@ -6,41 +6,15 @@ export type { InterceptorTarget, } from "./interceptor.js"; -export { httpPaymentRelay, stubPaymentRelay } from "./relay.js"; +export { httpPaymentRelay } from "./relay.js"; export type { HttpRelayOptions, PaymentRelay, RelayCallOptions, } from "./relay.js"; -export { - RELAY_REFUSAL_REASONS, - isRelayRefusalReason, - parseRelayResponse, - paymentIdempotencyKey, - relayHeadersFromRecord, -} from "./contract.js"; -export type { - RelayBodyEncoding, - RelayHeader, - RelayRefusal, - RelayRefusalReason, - RelayRequest, - RelayResponse, - RelaySuccess, -} from "./contract.js"; +export type { RelayRequest, RelayResponse } from "./contract.js"; -export { - luhnHeldPan, - matchHeldPlaceholderInBody, - textHoldsPlaceholderNumber, -} from "./detect.js"; -export type { BodyPlaceholderMatch, HeldPlaceholder } from "./detect.js"; +export type { HeldPlaceholder } from "./detect.js"; export type { PayLogger } from "./logger.js"; -export { silentLogger } from "./logger.js"; - -export { maskCardNumber, redact } from "./redact.js"; - -export { secFetchSite } from "./headers.js"; -export type { SecFetchSite } from "./headers.js"; diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 95a91965e..63d55768e 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -12,7 +12,7 @@ import { type PaymentRelay, type RelayCallOptions, } from "./relay"; -import type { HeldPlaceholder, RelayHeader, RelayRequest } from "./index"; +import type { HeldPlaceholder, RelayRequest } from "./index"; const nodeRequire = createRequire(__filename); @@ -127,7 +127,7 @@ function startServer( }); } -function chromeExecutable(): string { +function findChrome(): string | undefined { const candidates = [ process.env.CHROME_PATH, "/usr/local/bin/google-chrome", @@ -137,18 +137,15 @@ function chromeExecutable(): string { ].filter((candidate): candidate is string => Boolean(candidate)); const found = candidates.find((candidate) => existsSync(candidate)); if (found) return found; - let playwrightPath = ""; try { - playwrightPath = nodeRequire( + const playwrightPath = nodeRequire( "playwright", ).chromium.executablePath() as string; + if (playwrightPath && existsSync(playwrightPath)) return playwrightPath; } catch { - playwrightPath = ""; + return undefined; } - if (playwrightPath && existsSync(playwrightPath)) return playwrightPath; - throw new Error( - `Chrome is required for the interceptor integration test. Set CHROME_PATH. Looked for: ${[...candidates, playwrightPath].filter(Boolean).join(", ")}`, - ); + return undefined; } async function waitForDebugger( @@ -177,344 +174,368 @@ async function waitForDebugger( throw new Error(`CDP endpoint did not come up (${last})`); } -const chromeBin = chromeExecutable(); - -describe("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[] = []; +const chromeBin = findChrome(); +if (!chromeBin) { + process.stderr.write( + "browser-pay: skipping Chrome integration tests. Install Google Chrome or Chromium, or set CHROME_PATH.\n", + ); +} - beforeAll(async () => { - userDataDir = await mkdtemp(path.join(os.tmpdir(), "browser-pay-")); - const port = await freePort(); - chrome = spawn( - chromeBin, - [ - "--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); +(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[] = []; - 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 }); - }); + 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); - beforeEach(() => { - hits.length = 0; - }); + 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 }); + }); - 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, - transactionId: "tx_integration", - timeoutMs, + beforeEach(() => { + hits.length = 0; }); - 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 + + 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))); - } - 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(" | ")})`, + 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; } - 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 () => ({ - kind: "relayed", - status: 201, - headers: [["content-type", "application/json"]], - body: '{"id":"sess_test"}', - bodyEncoding: "utf8", - })), - { ping: PING, other: UNRELATED, mismatch: MISMATCH, card: CARD }, - ); + 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([]); + 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({ - transactionId: "tx_integration", - injectionRef: "inj_integration", - merchantOrigin, - method: "POST", - url: `http://127.0.0.1:${card.port}/sessions`, - body: CARD, - }); - expect(calls[0].body).toBe(MISMATCH); - expect(calls[0].idempotencyKey).not.toBe(calls[1].idempotencyKey); - expect(calls[1].merchantOrigin).not.toContain(String(card.port)); - expect(header(calls[0].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(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); + 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 () => ({ - kind: "refused", + 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", - })), - { 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].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); + }); + 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("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("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 { - kind: "relayed", - status: 201, - headers: [["content-type", "text/plain"] satisfies RelayHeader], - body: "ok", - bodyEncoding: "utf8", - }; - }, - }, - placeholder: PLACEHOLDER, - transactionId: "tx_integration", - 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(), + 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, + }); }), - (error: Error) => ({ error: error.name }), - ); - return { queryResult, formResult }; - }, - { - query: `/lookup?number=${PLACEHOLDER.number}`, - multipartBody: multipart, - formBoundary: boundary, - }, + ), + { ping: PING, other: PING, mismatch: PING, card: CARD }, + 400, ); - expect(outcome.queryResult).toEqual({ error: "TypeError" }); - 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(1); - expect(calls[0].body).toContain("4242 4242 4242 4242"); - expect(events.filter((event) => event.type === "paused")).toHaveLength(1); - expect(events.some((event) => event.type === "failed")).toBe(true); - } finally { - await page.close().catch(() => {}); - await handle.detach(); - void browser; - } - }, 60_000); -}); + 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); -function header(headers: RelayHeader[], name: string): string | undefined { - return headers.find(([key]) => key.toLowerCase() === name)?.[1]; -} + 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 index 632d42dc3..8acf4ec33 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -2,7 +2,6 @@ import crypto from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; -import { paymentIdempotencyKey } from "./contract"; import { attachInterceptor, type InterceptorEvent } from "./interceptor"; import type { PayLogger } from "./logger"; import { stubPaymentRelay, type RelayCallOptions } from "./relay"; @@ -38,8 +37,6 @@ interface StubOptions { postDataError?: boolean; targets?: { targetId: string; type: string; url: string }[]; attachedSessionId?: string; - languages?: string[]; - cookies?: { name: string; value: string }[]; } function serverFrame(text: string): Buffer { @@ -134,20 +131,9 @@ async function startStub(options: StubOptions = {}): Promise<{ return; } if (message.method === "Runtime.evaluate") { - const expression = message.params?.expression; - if (expression === "navigator.languages") { - reply(message.id, { - result: { value: options.languages ?? ["en-US", "en"] }, - }); - return; - } reply(message.id, {}); return; } - if (message.method === "Network.getCookies") { - reply(message.id, { cookies: options.cookies ?? [] }); - return; - } if (message.method === "Fetch.getRequestPostData") { if (options.postDataError) { reply(message.id, undefined, { message: "no body" }); @@ -267,18 +253,19 @@ function attachPage( } const success = (): RelayResponse => ({ - kind: "relayed", - status: 201, - headers: [["content-type", "application/json"]], - body: '{"id":"sess"}', - bodyEncoding: "utf8", + outcome: "relayed", + response: { + status: 201, + headers: { "content-type": "application/json" }, + body: '{"id":"sess"}', + }, }); function header( - headers: RelayRequest["headers"], + headers: RelayRequest["request"]["headers"], name: string, ): string | undefined { - return headers.find(([key]) => key.toLowerCase() === name)?.[1]; + return headers[name.toLowerCase()]; } describe("interceptor CDP decisions", () => { @@ -289,7 +276,7 @@ describe("interceptor CDP decisions", () => { const handle = await attachInterceptor(stub.url, { relay: stubPaymentRelay(async () => success()), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -331,7 +318,7 @@ describe("interceptor CDP decisions", () => { return success(); }), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -365,16 +352,19 @@ describe("interceptor CDP decisions", () => { ), ).toBe(true); expect(calls).toHaveLength(1); - expect(calls[0].body).toBe(CARD); + expect(calls[0].request.body).toBe(CARD); expect(calls[0].idempotencyKey).toBe( - paymentIdempotencyKey("POST", calls[0].url, CARD), + crypto + .createHash("sha256") + .update(`POST\0${calls[0].request.url}\0${CARD}`) + .digest("hex"), ); - expect(header(calls[0].headers, "content-type")).toBe("application/json"); - expect(header(calls[0].headers, "sec-fetch-site")).toBe("cross-site"); - expect(header(calls[0].headers, "sec-fetch-dest")).toBe("empty"); - expect(header(calls[0].headers, "accept-language")).toBe( - "en-US,en;q=0.9", + 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(); @@ -392,7 +382,7 @@ describe("interceptor CDP decisions", () => { return success(); }), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -435,7 +425,7 @@ describe("interceptor CDP decisions", () => { } }); - it("fails a request whose URL holds the placeholder even when the body does not", async () => { + 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[] = []; @@ -446,7 +436,7 @@ describe("interceptor CDP decisions", () => { return success(); }), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -468,15 +458,19 @@ describe("interceptor CDP decisions", () => { }, }); await waitFor( - () => events.some((event) => event.type === "failed"), - "failed", + () => events.some((event) => event.type === "relayed"), + "relayed", ); - expect(calls).toHaveLength(0); - expect(events[0]).toMatchObject({ - type: "failed", - reason: "placeholder_mismatch", - }); + expect(calls).toHaveLength(1); + expect(calls[0].request.url).toContain(HELD.number); + 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( + stub.commands.some( + (command) => command.method === "Fetch.continueRequest", + ), + ).toBe(false); await handle.detach(); } finally { await stub.close(); @@ -495,7 +489,7 @@ describe("interceptor CDP decisions", () => { return success(); }), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); attachPage(stub.emit); await waitFor( @@ -516,7 +510,7 @@ describe("interceptor CDP decisions", () => { }, }); await waitFor(() => calls.length === 1, "relay"); - expect(calls[0].body).toBe(multipart); + expect(calls[0].request.body).toBe(multipart); expect( stub.commands.some( (command) => command.method === "Fetch.continueRequest", @@ -547,7 +541,7 @@ describe("interceptor CDP decisions", () => { }), ), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", timeoutMs: 30_000, }); handle.on((event) => events.push(event)); @@ -594,7 +588,7 @@ describe("interceptor CDP decisions", () => { const handle = await attachInterceptor(stub.url, { relay: stubPaymentRelay(async () => success()), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -636,20 +630,21 @@ describe("interceptor CDP decisions", () => { } }); - it("does not fulfill a response that contains a different card number", async () => { + 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 () => ({ - kind: "relayed", - status: 200, - headers: [["content-type", "text/plain"]], - body: "4111111111111111", - bodyEncoding: "utf8", + outcome: "relayed", + response: { + status: 200, + headers: { "content-type": "text/plain" }, + body: '{"id":"sess","created_at":1760650000123,"pan":"4111111111111111"}', + }, })), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -671,16 +666,15 @@ describe("interceptor CDP decisions", () => { }, }); await waitFor( - () => events.some((event) => event.type === "failed"), - "failed", + () => events.some((event) => event.type === "relayed"), + "relayed", ); - expect(events.map((event) => event.type)).toEqual(["paused", "failed"]); - expect(events[1]).toMatchObject({ reason: "response_credential" }); + expect(events.map((event) => event.type)).toEqual(["paused", "relayed"]); expect( stub.commands.some( (command) => command.method === "Fetch.fulfillRequest", ), - ).toBe(false); + ).toBe(true); await handle.detach(); } finally { await stub.close(); @@ -693,7 +687,7 @@ describe("interceptor CDP decisions", () => { const handle = await attachInterceptor(stub.url, { relay: stubPaymentRelay(async () => success()), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); attachPage(stub.emit, "page-1"); await waitFor( @@ -768,8 +762,7 @@ describe("interceptor CDP decisions", () => { const handle = await attachInterceptor(stub.url, { relay: stubPaymentRelay(async () => success()), placeholder: HELD, - transactionId: "tx", - pauseOnlyUrls: ["checkout.pci.shopifyinc.com"], + profileId: "shopify-card-vault", }); const enable = stub.commands.findIndex( (command) => @@ -798,7 +791,7 @@ describe("interceptor CDP decisions", () => { const handle = await attachInterceptor(stub.url, { relay: stubPaymentRelay(async () => success()), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); stub.emit({ @@ -855,10 +848,10 @@ describe("interceptor CDP decisions", () => { relay: stubPaymentRelay(async () => { calls += 1; if (calls === 1) await gate; - return { kind: "refused", reason: "already_relayed" }; + return { outcome: "refused", reason: "already_relayed" }; }), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", logger, }); handle.on((event) => events.push(event)); @@ -908,10 +901,8 @@ describe("interceptor CDP decisions", () => { } }); - it("copies a missing Cookie the way Chrome would", async () => { - const stub = await startStub({ - cookies: [{ name: "cart", value: "1" }], - }); + it("forwards only the headers David accepts", async () => { + const stub = await startStub(); const calls: RelayRequest[] = []; const events: InterceptorEvent[] = []; try { @@ -921,7 +912,7 @@ describe("interceptor CDP decisions", () => { return success(); }), placeholder: HELD, - transactionId: "tx", + profileId: "shopify-card-vault", }); handle.on((event) => events.push(event)); attachPage(stub.emit); @@ -939,7 +930,13 @@ describe("interceptor CDP decisions", () => { request: { url: "https://checkout.example/sessions", method: "POST", - headers: { "Sec-Fetch-Site": "same-origin" }, + headers: { + "Content-Type": "application/json", + Cookie: "cart=1", + Accept: "application/json", + "Shopify-Identification-Signature": "sig", + "X-Unforwarded": "nope", + }, postData: CARD, }, }, @@ -948,9 +945,355 @@ describe("interceptor CDP decisions", () => { () => events.some((event) => event.type === "relayed"), "relayed", ); - expect(header(calls[0].headers, "sec-fetch-site")).toBe("same-origin"); - expect(header(calls[0].headers, "cookie")).toBe("cart=1"); - expect(header(calls[0].headers, "sec-fetch-dest")).toBe("empty"); + 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(); diff --git a/packages/browser-pay/src/interceptor.test.ts b/packages/browser-pay/src/interceptor.test.ts deleted file mode 100644 index a32f87ecc..000000000 --- a/packages/browser-pay/src/interceptor.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - canContinueWithoutReading, - isSolverTraffic, - postDataPlan, -} from "./interceptor"; - -describe("isSolverTraffic", () => { - it("matches captcha widgets and the browser solver, not the card host", () => { - expect( - isSolverTraffic( - "https://hcaptcha.com/captcha/v1/abc/static/hcaptcha.html", - ), - ).toBe(true); - expect( - isSolverTraffic("https://newassets.hcaptcha.com/captcha/v1/abc/api.js"), - ).toBe(true); - expect(isSolverTraffic("https://api.hcaptcha.com/getcaptcha")).toBe(true); - expect( - isSolverTraffic("https://www.google.com/recaptcha/api2/anchor"), - ).toBe(true); - expect( - isSolverTraffic("https://challenges.cloudflare.com/cdn-cgi/challenge"), - ).toBe(true); - expect(isSolverTraffic("https://api.anchorbrowser.io/v1/sessions")).toBe( - true, - ); - expect( - isSolverTraffic("https://checkout.pci.shopifyinc.com/sessions"), - ).toBe(false); - expect(isSolverTraffic("https://1a2uh3-r7.myshopify.com/checkouts")).toBe( - false, - ); - expect(isSolverTraffic("not a url")).toBe(false); - }); -}); - -describe("canContinueWithoutReading", () => { - const number = "4123456789012349"; - - it("continues a GET or HEAD whose URL does not hold the placeholder", () => { - expect( - canContinueWithoutReading( - "GET", - "https://newassets.hcaptcha.com/captcha/v1/api.js", - false, - number, - ), - ).toBe(true); - expect( - canContinueWithoutReading( - "HEAD", - "https://example.com/style.css", - false, - number, - ), - ).toBe(true); - }); - - it("does not skip a body, a query holding the placeholder, or a POST", () => { - expect( - canContinueWithoutReading("GET", "https://example.com/a", true, number), - ).toBe(false); - expect( - canContinueWithoutReading( - "GET", - `https://example.com/pay?card=${number}`, - false, - number, - ), - ).toBe(false); - expect( - canContinueWithoutReading( - "GET", - "https://example.com/pay?card=4123-4567-8901-2349", - false, - number, - ), - ).toBe(false); - expect( - canContinueWithoutReading( - "POST", - "https://example.com/sessions", - false, - number, - ), - ).toBe(false); - }); -}); - -describe("postDataPlan", () => { - it("fetches when an entry has no bytes or the body was not inlined", () => { - expect(postDataPlan({ postData: '{"a":1}' })).toBe("inline"); - expect(postDataPlan({ postDataEntries: [{ bytes: "e30=" }] })).toBe( - "entries", - ); - expect(postDataPlan({ hasPostData: true, postDataEntries: [{}] })).toBe( - "fetch", - ); - expect(postDataPlan({ hasPostData: true })).toBe("fetch"); - expect(postDataPlan({})).toBe("empty"); - }); -}); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 71ef5a204..ac7b8998f 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -1,43 +1,24 @@ import { CdpConnection, type CdpEvent } from "./cdp.js"; +import { paymentIdempotencyKey, type RelayResponse } from "./contract.js"; import { - parseRelayResponse, - paymentIdempotencyKey, - type RelayHeader, - type RelayResponse, -} from "./contract.js"; -import { - luhnHeldPan, - matchHeldPlaceholderInBody, - textContainsForeignCard, + redactHeld, textHoldsPlaceholderNumber, type HeldPlaceholder, } from "./detect.js"; -import { - acceptLanguageHeader, - cookieHeader, - secFetchDest, - secFetchSite, -} from "./headers.js"; import { silentLogger, type PayLogger } from "./logger.js"; import type { PaymentRelay } from "./relay.js"; -import { redact } from "./redact.js"; export interface AttachOptions { relay: PaymentRelay; /** The one placeholder the agent was given for this checkout. */ placeholder: HeldPlaceholder; - transactionId: string; + /** 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. + * The relay is not retried. `outcome: "unknown"` is not sent again. */ timeoutMs?: number; - /** - * When set, Fetch pauses only URLs containing one of these strings. - * Other requests are not paused, so a captcha solver keeps running. - * Omit it to pause every page and iframe request. - */ - pauseOnlyUrls?: string[]; /** Defaults to a logger that discards every line. */ logger?: PayLogger; } @@ -70,7 +51,7 @@ const DEFAULT_TIMEOUT_MS = 20_000; const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; /** GET/HEAD with no body and no held number in the URL. Safe to continue immediately. */ -export function canContinueWithoutReading( +function canContinueWithoutReading( method: string, url: string, hasBody: boolean, @@ -83,11 +64,10 @@ export function canContinueWithoutReading( } /** - * Captcha widgets and the session solver. Their requests are not the card - * post. Holding them on Fetch or the debugger stalls the solver past its - * own timeout. A POST is still scanned so a placeholder is never continued. + * Captcha widgets and the session solver. Holding them on Fetch or the + * debugger stalls the solver past its own timeout. */ -export function isSolverTraffic(url: string): boolean { +function isSolverTraffic(url: string): boolean { let parsed: URL; try { parsed = new URL(url); @@ -135,13 +115,8 @@ function isBlankTarget(url: string): boolean { return url === "" || url === "about:blank" || url === "about:srcdoc"; } -function shouldPauseTarget( - session: Session, - pauseOnlyUrls?: string[], -): boolean { - if (isHandsOffTarget(session) || isBlankTarget(session.url)) return false; - if (!pauseOnlyUrls || pauseOnlyUrls.length === 0) return true; - return pauseOnlyUrls.some((part) => session.url.includes(part)); +function shouldPauseTarget(session: Session): boolean { + return !isHandsOffTarget(session) && !isBlankTarget(session.url); } function isHandsOffTarget(session: Session): boolean { if ( @@ -155,21 +130,6 @@ function isHandsOffTarget(session: Session): boolean { return isSolverTraffic(session.url); } -/** - * Attach the payment interceptor to a browser. - * - * Page and iframe requests are paused. Captcha widgets, extension pages, and - * workers are not: pausing them stalls the session's captcha solver. A paused - * body that does not contain the held placeholder is continued. A match is - * sent to the relay and the paused request is fulfilled with the provider - * response. A mismatch, refusal, timeout, or network error fails the paused - * request. The placeholder is never continued: it is a random number in a - * real BIN range. - * - * `detach()` fails in-flight paused requests before it disables Fetch, so a - * placeholder mid-relay is not released. If this process crashes or the CDP - * socket drops first, Chrome continues those paused requests on its own. - */ export async function attachInterceptor( target: InterceptorTarget, options: AttachOptions, @@ -181,8 +141,9 @@ export async function attachInterceptor( const seen = new Set(); const listeners = new Set<(event: InterceptorEvent) => void>(); const inflight = new Map(); - const relayFlights = new Map>(); + const relayFlights = new Map(); const relayAborts = new Set(); + const consumed = new Set(); const loggedRefusals = new Set(); const pendingArms: Promise[] = []; let closed = false; @@ -218,13 +179,15 @@ export async function attachInterceptor( return; } // Fetch has to be on before the target runs, or the card iframe can post - // before we are listening. Network has to be enabled before - // Network.getCookies will return the jar the card session needs. - const armed = await armFetch(connection, session, options.pauseOnlyUrls); + // before we are listening. + const armed = await armFetch(connection, session); if (!armed) { emit({ type: "failed", - url: redact(session.url || session.targetId), + url: redactHeld( + session.url || session.targetId, + options.placeholder.number, + ), reason: "intercept_unavailable", }); await connection @@ -297,7 +260,7 @@ export async function attachInterceptor( void failRequest(connection, sessionId, params.requestId); emit({ type: "failed", - url: redact(params.request.url ?? ""), + url: redactHeld(params.request.url ?? "", options.placeholder.number), reason: "unknown_session", }); return; @@ -325,6 +288,7 @@ export async function attachInterceptor( loggedRefusals, logger, relayAborts, + consumed, isClosed: () => closed, }; if ( @@ -345,29 +309,11 @@ export async function attachInterceptor( }); return; } - if ( - !hasBody && - (method.toUpperCase() === "GET" || method.toUpperCase() === "HEAD") - ) { - void finish(ctx, entry, "fail").then(() => { - emit({ - type: "failed", - url: redact(url), - reason: "placeholder_mismatch", - }); - }); - return; - } if (closed) { void settleClosed(ctx, entry, url, params.request); return; } - const session = sessions.get(sessionId); - if (isSolverTraffic(url) || (session ? isHandsOffTarget(session) : false)) { - void continueSolverRequest(ctx, entry, url, params.request); - return; - } - void handlePaused(ctx, entry, params.resourceType ?? "", params.request); + void handlePaused(ctx, entry, params.request); }; connection.onEvent((event) => { @@ -386,7 +332,7 @@ export async function attachInterceptor( for (const session of sessions.values()) { if (session.targetId !== info.targetId) continue; session.url = info.url; - if (!shouldPauseTarget(session, options.pauseOnlyUrls)) { + if (!shouldPauseTarget(session)) { if (session.fetchEnabled) { session.fetchEnabled = false; void connection @@ -399,19 +345,20 @@ export async function attachInterceptor( continue; } if (!session.fetchEnabled) { - void armFetch(connection, session, options.pauseOnlyUrls).then( - (armed) => { - if (armed || closed) return; - emit({ - type: "failed", - url: redact(session.url || session.targetId), - reason: "intercept_unavailable", - }); - void connection - .send("Target.closeTarget", { targetId: session.targetId }) - .catch(() => {}); - }, - ); + void armFetch(connection, session).then((armed) => { + if (armed || closed) return; + emit({ + type: "failed", + url: redactHeld( + session.url || session.targetId, + options.placeholder.number, + ), + reason: "intercept_unavailable", + }); + void connection + .send("Target.closeTarget", { targetId: session.targetId }) + .catch(() => {}); + }); } } } @@ -426,7 +373,7 @@ export async function attachInterceptor( await connection .send("Target.setDiscoverTargets", { discover: true }) .catch(() => {}); - await attachExistingPaymentFrames(connection, sessions, options, (session) => + await attachExistingPaymentFrames(connection, sessions, (session) => enableSession(session.sessionId, false), ); await Promise.all([...pendingArms]); @@ -476,6 +423,12 @@ interface PausedRequest { postDataEntries?: { bytes?: string }[]; } +interface Flight { + promise: Promise; + controller: AbortController; + waiters: number; +} + interface PauseContext { connection: CdpConnection; sessions: Map; @@ -483,10 +436,11 @@ interface PauseContext { timeoutMs: number; emit: (event: InterceptorEvent) => void; inflight: Map; - relayFlights: Map>; + relayFlights: Map; loggedRefusals: Set; logger: PayLogger; relayAborts: Set; + consumed: Set; isClosed: () => boolean; } @@ -549,12 +503,12 @@ async function finishContinue( async function handlePaused( ctx: PauseContext, entry: Inflight, - resourceType: string, request: PausedRequest, ): Promise { const url = request.url ?? ""; const method = request.method ?? "GET"; - const safeUrl = redact(url); + const number = ctx.options.placeholder.number; + const safeUrl = redactHeld(url, number); const targetType = ctx.sessions.get(entry.sessionId)?.type ?? ""; try { const read = await readBodyResult( @@ -568,74 +522,61 @@ async function handlePaused( ctx.emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); return; } - const urlHolds = textHoldsPlaceholderNumber( - url, - ctx.options.placeholder.number, - ); - const match = matchHeldPlaceholderInBody( - read.body, - ctx.options.placeholder, - ); - if (match.kind === "not_held" && !urlHolds) { + const held = + textHoldsPlaceholderNumber(url, number) || + textHoldsPlaceholderNumber(read.body, number); + if (!held) { await finishContinue(ctx, entry); return; } - if (match.kind !== "match" || ctx.isClosed()) { + if (ctx.isClosed()) { await finish(ctx, entry, "fail"); - ctx.emit({ - type: "failed", - url: safeUrl, - reason: "placeholder_mismatch", - }); + ctx.emit({ type: "failed", url: safeUrl, reason: "detached" }); return; } ctx.emit({ type: "paused", url: safeUrl, method, targetType }); - const merchantOrigin = await readMerchantOrigin( + const pageOrigin = await readPageOrigin( ctx.connection, ctx.sessions, entry.sessionId, ); - if (!merchantOrigin || ctx.isClosed()) { + if (!pageOrigin || ctx.isClosed()) { await finish(ctx, entry, "fail"); ctx.emit({ type: "failed", url: safeUrl, - reason: merchantOrigin ? "detached" : "merchant_origin", + reason: pageOrigin ? "detached" : "page_origin", }); return; } - const headers = await completeHeaders( - ctx.connection, - ctx.sessions, - entry.sessionId, - url, - resourceType, - request.headers, - ); const idempotencyKey = paymentIdempotencyKey(method, url, read.body); - const controller = new AbortController(); - ctx.relayAborts.add(controller); - const timer = setTimeout(() => controller.abort(), ctx.timeoutMs); + if (ctx.consumed.has(idempotencyKey)) { + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "unknown" }); + return; + } + const flight = relayOnce(ctx, idempotencyKey, (signal) => + ctx.options.relay.relay( + { + injectionRef: ctx.options.placeholder.injectionRef, + profileId: ctx.options.profileId, + pageOrigin, + request: { + method, + url, + headers: forwardedHeaders(request.headers), + body: read.body, + }, + idempotencyKey, + }, + { signal }, + ), + ); let response: RelayResponse; try { - response = parseRelayResponse( - await relayOnce(ctx, idempotencyKey, () => - ctx.options.relay.relay( - { - transactionId: ctx.options.transactionId, - injectionRef: match.injectionRef, - merchantOrigin, - url, - method, - headers, - body: read.body, - idempotencyKey, - }, - { signal: controller.signal }, - ), - ), - ); + response = await failAfter(flight.promise, ctx.timeoutMs); } catch (error) { + releaseWaiter(ctx, flight); await finish(ctx, entry, "fail"); ctx.emit({ type: "failed", @@ -643,11 +584,15 @@ async function handlePaused( reason: ctx.isClosed() ? "detached" : failureReason(error), }); return; - } finally { - clearTimeout(timer); - ctx.relayAborts.delete(controller); } - if (response.kind === "refused") { + releaseWaiter(ctx, flight); + if (response.outcome === "unknown") { + ctx.consumed.add(idempotencyKey); + await finish(ctx, entry, "fail"); + ctx.emit({ type: "failed", url: safeUrl, reason: "unknown" }); + return; + } + if (response.outcome === "refused") { if (!ctx.loggedRefusals.has(idempotencyKey)) { ctx.loggedRefusals.add(idempotencyKey); ctx.logger.warn("payment relay refused the paused request", { @@ -658,11 +603,7 @@ async function handlePaused( ctx.emit({ type: "refused", url: safeUrl, reason: response.reason }); return; } - const responseText = - response.bodyEncoding === "base64" - ? Buffer.from(response.body, "base64").toString("utf8") - : response.body; - if (textContainsForeignCard(responseText, ctx.options.placeholder.number)) { + if (textHoldsPlaceholderNumber(response.response.body, number)) { await finish(ctx, entry, "fail"); ctx.emit({ type: "failed", url: safeUrl, reason: "response_credential" }); return; @@ -674,15 +615,13 @@ async function handlePaused( ctx.connection, entry.sessionId, entry.requestId, - response, + response.response, ); fulfilled = true; } } catch { fulfilled = false; } - // A failed fulfill must not leave the pause open. Chrome continues an - // open pause when the socket drops, which would deliver the placeholder. if (!fulfilled && !entry.settled) { await failRequest(ctx.connection, entry.sessionId, entry.requestId); } @@ -692,11 +631,15 @@ async function handlePaused( } ctx.emit( fulfilled - ? { type: "relayed", url: safeUrl, status: response.status } + ? { + type: "relayed", + url: safeUrl, + status: response.response.status, + } : { type: "relayed_unfulfilled", url: safeUrl, - status: response.status, + status: response.response.status, }, ); } catch { @@ -711,7 +654,7 @@ async function settleClosed( url: string, request: PausedRequest, ): Promise { - const safeUrl = redact(url); + const safeUrl = redactHeld(url, ctx.options.placeholder.number); try { let holds = textHoldsPlaceholderNumber(url, ctx.options.placeholder.number); let unreadable = false; @@ -725,8 +668,7 @@ async function settleClosed( unreadable = read.unreadable; holds = unreadable || - matchHeldPlaceholderInBody(read.body, ctx.options.placeholder).kind !== - "not_held"; + textHoldsPlaceholderNumber(read.body, ctx.options.placeholder.number); } if (holds) { await finish(ctx, entry, "fail"); @@ -744,10 +686,9 @@ async function settleClosed( } } -export type PostDataPlan = "inline" | "entries" | "fetch" | "empty"; +type PostDataPlan = "inline" | "entries" | "fetch" | "empty"; -/** How a paused request's body should be read. Missing entry bytes are fetched. */ -export function postDataPlan(request: { +function postDataPlan(request: { postData?: string; hasPostData?: boolean; postDataEntries?: { bytes?: string }[]; @@ -776,9 +717,11 @@ async function readBodyResult( if (plan === "inline") return { body: request.postData ?? "", unreadable: false }; if (plan === "entries") { - const body = (request.postDataEntries ?? []) - .map((entry) => Buffer.from(entry.bytes ?? "", "base64").toString("utf8")) - .join(""); + const body = Buffer.concat( + (request.postDataEntries ?? []).map((entry) => + Buffer.from(entry.bytes ?? "", "base64"), + ), + ).toString("utf8"); return { body, unreadable: false }; } try { @@ -795,7 +738,7 @@ async function readBodyResult( } } -async function readMerchantOrigin( +async function readPageOrigin( connection: CdpConnection, sessions: Map, sessionId: string, @@ -870,121 +813,42 @@ function topPageSession( return undefined; } -/** - * At the Fetch Request stage Chrome has not yet added the headers its network - * stack appends. Fill the ones that are missing. Never override a header the - * request already has. Cookie values are not logged. - */ -async function completeHeaders( - connection: CdpConnection, - sessions: Map, - sessionId: string, - requestUrl: string, - resourceType: string, - rawHeaders: unknown, -): Promise { - const headers = headerList(rawHeaders); - const has = (name: string) => - headers.some(([key]) => key.toLowerCase() === name); - const frameUrl = sessions.get(sessionId)?.url || requestUrl; - const site = secFetchSite(requestUrl, frameUrl); - if (!has("sec-fetch-site") && site) { - headers.push(["Sec-Fetch-Site", site]); - } - const dest = secFetchDest(resourceType); - if (!has("sec-fetch-dest") && dest) { - headers.push(["Sec-Fetch-Dest", dest]); - } - if (!has("accept-language")) { - const languages = await readLanguages(connection, sessionId); - if (languages.length > 0) { - headers.push(["Accept-Language", acceptLanguageHeader(languages)]); - } - } - if (!has("cookie")) { - const cookies = await readCookies(connection, sessionId, requestUrl); - if (cookies.length > 0) { - headers.push(["Cookie", cookieHeader(cookies)]); - } - } - return headers; -} - -async function readLanguages( - connection: CdpConnection, - sessionId: string, -): Promise { - const evaluated = await connection - .send( - "Runtime.evaluate", - { expression: "navigator.languages", returnByValue: true }, - sessionId, - ) - .catch(() => undefined); - return stringList(evaluated); -} - -async function readCookies( - connection: CdpConnection, - sessionId: string, - requestUrl: string, -): Promise<{ name: string; value: string }[]> { - const result = await connection - .send("Network.getCookies", { urls: [requestUrl] }, sessionId) - .catch(() => undefined); - if (!isRecord(result) || !Array.isArray(result.cookies)) return []; - const cookies: { name: string; value: string }[] = []; - for (const entry of result.cookies) { - if (!isRecord(entry)) continue; - if (typeof entry.name !== "string" || typeof entry.value !== "string") { - continue; - } - cookies.push({ name: entry.name, value: entry.value }); - } - return cookies; -} - -function stringList(value: unknown): string[] { - if (!isRecord(value) || !isRecord(value.result)) return []; - const list = value.result.value; - if ( - !Array.isArray(list) || - !list.every((entry) => typeof entry === "string") - ) { - return []; - } - return list.filter((entry): entry is string => typeof entry === "string"); -} +const FORWARDED_HEADERS = new Set([ + "accept", + "content-type", + "shopify-identification-signature", +]); -function headerList(headers: unknown): RelayHeader[] { - if (!headers) return []; - if (Array.isArray(headers)) { - const out: RelayHeader[] = []; - for (const entry of headers) { +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" ) { - out.push([entry[0], entry[1]]); + take(entry[0], entry[1]); continue; } if (isRecord(entry) && typeof entry.name === "string") { - const value = entry.value; - out.push([entry.name, typeof value === "string" ? value : ""]); + take(entry.name, typeof entry.value === "string" ? entry.value : ""); } } return out; } - if (isRecord(headers)) { - const out: RelayHeader[] = []; - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") out.push([key, value]); + if (isRecord(raw)) { + for (const [key, value] of Object.entries(raw)) { + if (typeof value === "string") take(key, value); } - return out; } - return []; + return out; } function parseAttachedTarget(value: unknown): @@ -1013,16 +877,12 @@ function parseAttachedTarget(value: unknown): function parsePaused( value: unknown, -): - | { requestId: string; resourceType: string; request: PausedRequest } - | undefined { +): { 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, - resourceType: - typeof value.resourceType === "string" ? value.resourceType : "", request: { url: typeof request.url === "string" ? request.url : "", method: typeof request.method === "string" ? request.method : "GET", @@ -1085,17 +945,13 @@ function requestHasBody(request: PausedRequest): boolean { async function armFetch( connection: CdpConnection, session: Session | undefined, - pauseOnlyUrls: string[] | undefined, ): Promise { if (!session || isHandsOffTarget(session)) return false; if (session.fetchEnabled) return true; - await connection - .send("Network.enable", {}, session.sessionId) - .catch(() => {}); try { await connection.send( "Fetch.enable", - { patterns: fetchPatterns(pauseOnlyUrls) }, + { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, session.sessionId, ); } catch { @@ -1105,19 +961,6 @@ async function armFetch( return true; } -function fetchPatterns( - pauseOnlyUrls: string[] | undefined, -): { urlPattern: string; requestStage: "Request" }[] { - if (!pauseOnlyUrls || pauseOnlyUrls.length === 0) { - return [{ urlPattern: "*", requestStage: "Request" }]; - } - const patterns: { urlPattern: string; requestStage: "Request" }[] = []; - for (const part of pauseOnlyUrls) { - patterns.push({ urlPattern: `*${part}*`, requestStage: "Request" }); - } - return patterns; -} - async function releaseTarget( connection: CdpConnection, sessionId: string, @@ -1129,47 +972,6 @@ async function releaseTarget( .catch(() => {}); } -async function continueSolverRequest( - ctx: PauseContext, - entry: Inflight, - url: string, - request: PausedRequest, -): Promise { - const safeUrl = redact(url); - if ( - textHoldsPlaceholderNumber(url, ctx.options.placeholder.number) || - requestHasBody(request) - ) { - try { - const read = requestHasBody(request) - ? await readBodyResult( - ctx.connection, - entry.sessionId, - entry.requestId, - request, - ) - : { body: "", unreadable: false }; - const held = - textHoldsPlaceholderNumber(url, ctx.options.placeholder.number) || - (!read.unreadable && - matchHeldPlaceholderInBody(read.body, ctx.options.placeholder) - .kind !== "not_held"); - if (held) { - await finish(ctx, entry, "fail"); - ctx.emit({ - type: "failed", - url: safeUrl, - reason: "placeholder_mismatch", - }); - return; - } - } catch { - // An unreadable captcha body is not the card post. Continue it. - } - } - await finishContinue(ctx, entry); -} - async function continueRequest( connection: CdpConnection, sessionId: string, @@ -1200,23 +1002,21 @@ async function fulfillRequest( connection: CdpConnection, sessionId: string, requestId: string, - response: Extract, + response: { status: number; headers: Record; body: string }, ): Promise { - const responseHeaders = response.headers.flatMap(([name, value]) => { - if (RESPONSE_HOP.has(name.toLowerCase())) return []; - return [{ name, value }]; - }); - const body = - response.bodyEncoding === "base64" - ? response.body - : Buffer.from(response.body, "utf8").toString("base64"); + 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, + body: Buffer.from(response.body, "utf8").toString("base64"), }, sessionId, ); @@ -1240,21 +1040,56 @@ function failureReason(error: unknown): string { function relayOnce( ctx: PauseContext, key: string, - call: () => Promise, -): Promise { + start: (signal: AbortSignal) => Promise, +): Flight { const existing = ctx.relayFlights.get(key); - if (existing) return existing; - const flight = call().finally(() => { - if (ctx.relayFlights.get(key) === flight) ctx.relayFlights.delete(key); + if (existing && !existing.controller.signal.aborted) { + existing.waiters += 1; + return existing; + } + const controller = new AbortController(); + ctx.relayAborts.add(controller); + const promise = start(controller.signal).finally(() => { + ctx.relayAborts.delete(controller); + const current = ctx.relayFlights.get(key); + if (current?.controller === controller) ctx.relayFlights.delete(key); }); + void promise.catch(() => {}); + const flight = { controller, waiters: 1, promise }; ctx.relayFlights.set(key, flight); return flight; } +function releaseWaiter(ctx: PauseContext, flight: Flight): void { + flight.waiters -= 1; + if (flight.waiters > 0) return; + flight.controller.abort(); + ctx.relayAborts.delete(flight.controller); +} + +function failAfter(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("Payment relay timed out")); + }, ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject( + error instanceof Error ? error : new Error("Payment relay failed"), + ); + }, + ); + }); +} + async function attachExistingPaymentFrames( connection: CdpConnection, sessions: Map, - options: AttachOptions, enable: (session: Session) => Promise, ): Promise { const listed = await connection @@ -1262,7 +1097,6 @@ async function attachExistingPaymentFrames( .catch(() => undefined); for (const info of parseTargetInfos(listed)) { if (info.type !== "iframe") continue; - if (!urlMatchesPause(info.url, options.pauseOnlyUrls)) continue; const known = [...sessions.values()].some( (session) => session.targetId === info.targetId, ); @@ -1295,11 +1129,6 @@ async function attachExistingPaymentFrames( } } -function urlMatchesPause(url: string, pauseOnlyUrls?: string[]): boolean { - if (!pauseOnlyUrls || pauseOnlyUrls.length === 0) return true; - return pauseOnlyUrls.some((part) => url.includes(part)); -} - function parseTargetInfos( value: unknown, ): { targetId: string; type: string; url: string }[] { @@ -1328,32 +1157,22 @@ function assertAttachOptions(options: AttachOptions): void { if (typeof options.relay?.relay !== "function") { throw new Error("A payment relay is required"); } - if (!options.transactionId) { - throw new Error("transactionId is required"); + if (!options.profileId) { + throw new Error("profileId is required"); } - if ( - !options.placeholder?.injectionRef || - !luhnHeldPan(options.placeholder.number) - ) { + const digits = options.placeholder?.number?.replace(/\D/g, "") ?? ""; + if (!options.placeholder?.injectionRef || digits.length === 0) { throw new Error( - "placeholder.injectionRef and a Luhn-valid placeholder number are required", + "placeholder.injectionRef and placeholder.number are required", ); } - const patterns = options.pauseOnlyUrls; - if ( - patterns !== undefined && - (!Array.isArray(patterns) || - patterns.some((part) => typeof part !== "string")) - ) { - throw new Error("pauseOnlyUrls must be an array of strings"); - } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export function resolveCdpUrl(target: InterceptorTarget): string { +function resolveCdpUrl(target: InterceptorTarget): string { if (typeof target === "string") { if (!target) throw new Error("A CDP websocket URL is required"); return target; diff --git a/packages/browser-pay/src/logger.ts b/packages/browser-pay/src/logger.ts index 41b77c14c..65694860f 100644 --- a/packages/browser-pay/src/logger.ts +++ b/packages/browser-pay/src/logger.ts @@ -1,8 +1,3 @@ -/** - * Injectable log sink. The package never writes to the console. Fields are - * short strings the caller already considers safe to record. Card numbers - * are masked before they reach a logger. - */ export interface PayLogger { info( message: string, diff --git a/packages/browser-pay/src/redact.ts b/packages/browser-pay/src/redact.ts deleted file mode 100644 index 97525f85d..000000000 --- a/packages/browser-pay/src/redact.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** Mask a card number to its last four digits. Shorter values collapse. */ -export function maskCardNumber(number: string): string { - const digits = number.replace(/\D/g, ""); - if (digits.length < 4) return "••••"; - return `••••${digits.slice(-4)}`; -} - -/** - * Replace 13–19 digit runs (spaces and dashes allowed) with a last-4 mask. - * Used on URLs and error text so a number typed into a query string cannot - * leave the process through an event. - */ -export function redact(value: string): string { - return value.replace(/\d(?:[\s-]*\d){12,18}/g, (run) => { - const digits = run.replace(/\D/g, ""); - if (digits.length < 13 || digits.length > 19) return run; - return maskCardNumber(digits); - }); -} diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 2cc896ed9..b619904ca 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -1,15 +1,33 @@ import http from "node:http"; import type { AddressInfo } from "node:net"; -import { - RELAY_REFUSAL_REASONS, - parseRelayResponse, - type RelayRequest, -} from "./contract"; -import { relayRefusalFixtures, shopifyCardSessionRoundTrip } from "./fixtures"; -import { matchHeldPlaceholderInBody } from "./detect"; -import { httpPaymentRelay, stubPaymentRelay } from "./relay"; +import type { RelayRequest } from "./contract"; +import { httpPaymentRelay } from "./relay"; -const REQUEST: RelayRequest = shopifyCardSessionRoundTrip.request; +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); @@ -23,61 +41,14 @@ function origin(server: http.Server): string { return `http://127.0.0.1:${address.port}`; } -describe("T7 fixtures", () => { - it("round-trips a Shopify card session and matches the placeholder", () => { - const { request, response } = shopifyCardSessionRoundTrip; - expect(request.url).toBe("https://checkout.pci.shopifyinc.com/sessions"); - expect(request.merchantOrigin).toBe("https://shop.example"); - expect(Object.keys(response).sort()).toEqual([ - "body", - "bodyEncoding", - "headers", - "kind", - "status", - ]); - expect(response.headers.filter(([name]) => name === "set-cookie")).toEqual([ - ["set-cookie", "session=a"], - ["set-cookie", "session=b"], - ]); - expect( - matchHeldPlaceholderInBody(request.body, { - number: "4242424242424242", - expMonth: 12, - expYear: 2028, - cvc: "123", - injectionRef: request.injectionRef, - }), - ).toEqual({ kind: "match", injectionRef: "inj_fixture_shopify" }); - expect(response.body).not.toMatch(/\d(?:[\s-]*\d){12,18}/); - }); - - it("has one refusal per reason and does not echo a request", () => { - expect(Object.keys(relayRefusalFixtures).sort()).toEqual( - [...RELAY_REFUSAL_REASONS].sort(), - ); - for (const reason of RELAY_REFUSAL_REASONS) { - expect(relayRefusalFixtures[reason]).toEqual({ - kind: "refused", - reason, - }); - expect(parseRelayResponse(relayRefusalFixtures[reason])).toEqual({ - kind: "refused", - reason, - }); - } - }); -}); - describe("httpPaymentRelay", () => { - it("POSTs the T7 body to David's route with the API key and does not retry", async () => { + 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, - }; + } = { count: 0 }; const server = await listen((req, res) => { seen.count += 1; const chunks: Buffer[] = []; @@ -94,6 +65,7 @@ describe("httpPaymentRelay", () => { const relay = httpPaymentRelay({ baseUrl: origin(server), apiKey: "test-key", + transactionId: "tx_fixture_shopify", timeoutMs: 1000, }); await expect(relay.relay(REQUEST)).rejects.toThrow( @@ -104,43 +76,54 @@ describe("httpPaymentRelay", () => { "/v1/transactions/tx_fixture_shopify/payment/relay", ); expect(seen.headers?.["x-api-key"]).toBe("test-key"); - expect(seen.headers?.["idempotency-key"]).toBe(REQUEST.idempotencyKey); + 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 provider response and a typed refusal", async () => { + it("returns a relayed response, a preserved refusal reason, and outcome unknown", async () => { const payloads = [ - shopifyCardSessionRoundTrip.response, - relayRefusalFixtures.already_relayed, + 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(payload && payload.kind === "relayed" ? 200 : 403, { - "content-type": "application/json", - }); + 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( - shopifyCardSessionRoundTrip.response, - ); await expect(relay.relay(REQUEST)).resolves.toEqual({ - kind: "refused", - reason: "already_relayed", + outcome: "unknown", }); } finally { await new Promise((resolve) => server.close(resolve)); } }); - it("aborts when the caller signal fires and does not keep its own timer", async () => { + it("aborts when the caller signal fires before timeoutMs", async () => { let arrived: () => void = () => {}; const requestArrived = new Promise((resolve) => { arrived = resolve; @@ -154,6 +137,7 @@ describe("httpPaymentRelay", () => { const relay = httpPaymentRelay({ baseUrl: origin(server), apiKey: "test-key", + transactionId: "tx_fixture_shopify", timeoutMs: 30_000, }); const pending = relay.relay(REQUEST, { signal: controller.signal }); @@ -169,33 +153,27 @@ describe("httpPaymentRelay", () => { } }); - it("times out without including a response body", async () => { + it("times out on its own timeoutMs while the caller signal stays open", async () => { const server = await listen(() => { // Never respond. }); + const controller = new AbortController(); try { const relay = httpPaymentRelay({ baseUrl: origin(server), apiKey: "test-key", + transactionId: "tx_fixture_shopify", timeoutMs: 50, }); - await expect(relay.relay(REQUEST)).rejects.toThrow( - "Payment relay timed out", - ); + const started = Date.now(); + await expect( + relay.relay(REQUEST, { signal: controller.signal }), + ).rejects.toThrow("Payment relay timed out"); + expect(Date.now() - started).toBeLessThan(2_000); + expect(controller.signal.aborted).toBe(false); } finally { + server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); } }); }); - -describe("stubPaymentRelay", () => { - it("returns the handler's response", async () => { - const relay = stubPaymentRelay( - async () => relayRefusalFixtures.already_relayed, - ); - await expect(relay.relay(REQUEST)).resolves.toEqual({ - kind: "refused", - reason: "already_relayed", - }); - }); -}); diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index c7267be11..3df0541c8 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -1,5 +1,4 @@ import { - isRelayRequest, parseRelayResponse, type RelayRequest, type RelayResponse, @@ -8,14 +7,9 @@ import { export type { RelayRequest, RelayResponse }; export interface RelayCallOptions { - /** Aborts the relay. The interceptor aborts this when its own timeout fires or it detaches. */ signal?: AbortSignal; } -/** - * Pluggable relay. `httpPaymentRelay` is the default HTTP client for David's - * route. A test double implements the same interface. - */ export interface PaymentRelay { relay( request: RelayRequest, @@ -24,11 +18,9 @@ export interface PaymentRelay { } export interface HttpRelayOptions { - /** API origin, for example `https://api.sapiom.ai`. The `/v1/...` path is added here. */ baseUrl: string; - /** Defaults to `SAPIOM_API_KEY`. Sent as `x-api-key`, same as the rest of the SDK. */ apiKey?: string; - /** One attempt. A timeout fails the paused request; it is never retried. */ + transactionId: string; timeoutMs?: number; fetchImpl?: typeof fetch; } @@ -38,12 +30,16 @@ const DEFAULT_TIMEOUT_MS = 20_000; function requireHttpOptions(options: HttpRelayOptions): { baseUrl: string; apiKey: string; + transactionId: string; timeoutMs: number; fetchImpl: typeof fetch; } { if (!options || typeof options.baseUrl !== "string" || !options.baseUrl) { throw new Error("Payment relay baseUrl is required"); } + 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( @@ -59,24 +55,39 @@ function requireHttpOptions(options: HttpRelayOptions): { return { baseUrl: options.baseUrl.replace(/\/$/, ""), apiKey, + transactionId: options.transactionId, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchImpl: options.fetchImpl ?? fetch, }; } -/** - * David's route: `POST /v1/transactions/{id}/payment/relay`. - * One attempt, no retry. A redirect fails closed. - */ +function combinedSignal( + timeoutMs: number, + caller?: AbortSignal, +): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + if (caller?.aborted) controller.abort(); + else caller?.addEventListener("abort", onAbort); + return { + signal: controller.signal, + dispose() { + clearTimeout(timer); + caller?.removeEventListener("abort", onAbort); + }, + }; +} + export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { const config = requireHttpOptions(options); return { async relay(request, call) { - if (!isRelayRequest(request)) { - throw new Error("Payment relay request is not a T7 body"); - } - const url = `${config.baseUrl}/v1/transactions/${encodeURIComponent(request.transactionId)}/payment/relay`; - const signal = call?.signal ?? AbortSignal.timeout(config.timeoutMs); + const url = `${config.baseUrl}/v1/transactions/${encodeURIComponent(config.transactionId)}/payment/relay`; + const { signal, dispose } = combinedSignal( + config.timeoutMs, + call?.signal, + ); let response: Response; try { response = await config.fetchImpl(url, { @@ -86,7 +97,6 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { accept: "application/json", "content-type": "application/json", "x-api-key": config.apiKey, - "idempotency-key": request.idempotencyKey, }, body: JSON.stringify(request), signal, @@ -96,6 +106,8 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { throw new Error("Payment relay timed out"); } throw new Error("Payment relay failed"); + } finally { + dispose(); } if (response.status >= 300 && response.status < 400) { throw new Error("Payment relay refused a redirect"); @@ -111,7 +123,7 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { try { return parseRelayResponse(parsed); } catch { - // Fall through. Never include `text`. + // The thrown error must not include the response text. } } throw new Error(`Payment relay failed (HTTP ${response.status})`); @@ -119,7 +131,6 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { }; } -/** In-memory relay for tests. */ export function stubPaymentRelay( handler: ( request: RelayRequest, diff --git a/packages/browser-pay/src/testing.ts b/packages/browser-pay/src/testing.ts deleted file mode 100644 index be927e09a..000000000 --- a/packages/browser-pay/src/testing.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Test fixtures. Import from `@sapiom/browser-pay/testing`, not the package root. - * The Shopify fixture contains a published test PAN. - */ -export { - relayRefusalFixtures, - shopifyCardSessionRoundTrip, -} from "./fixtures.js"; From 7edc30256ad52a94fc9126576454d84c90480b90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 22:24:44 +0000 Subject: [PATCH 19/26] fix(browser-pay): time out a stalled relay body and mask encoded placeholders Co-authored-by: Rimmely Ewan --- packages/browser-pay/examples/attach.ts | 69 +++++++++------- packages/browser-pay/src/detect.test.ts | 34 ++++++++ packages/browser-pay/src/detect.ts | 80 ++++++++++++++++++- .../src/interceptor.protocol.test.ts | 6 +- packages/browser-pay/src/relay.test.ts | 73 +++++++++++++++++ packages/browser-pay/src/relay.ts | 22 +++-- 6 files changed, 241 insertions(+), 43 deletions(-) create mode 100644 packages/browser-pay/src/detect.test.ts diff --git a/packages/browser-pay/examples/attach.ts b/packages/browser-pay/examples/attach.ts index 42d3de4c3..38da9b703 100644 --- a/packages/browser-pay/examples/attach.ts +++ b/packages/browser-pay/examples/attach.ts @@ -14,6 +14,7 @@ import { createClient } from "@sapiom/tools"; import { attachInterceptor, httpPaymentRelay, + type InterceptorHandle, type PayLogger, } from "../src/index.js"; @@ -48,43 +49,49 @@ async function main(): Promise { stealth: true, headless: false, }); - const handle = await attachInterceptor(session.cdpUrl, { - relay: httpPaymentRelay({ - baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", - apiKey, - transactionId, - }), - profileId, - placeholder: { - number, - expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), - expYear: Number(process.env.PLACEHOLDER_EXP_YEAR), - cvc: process.env.PLACEHOLDER_CVC ?? "", - 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 }); - } - }); - + let handle: InterceptorHandle | undefined; const shutdown = async () => { - await handle.detach(); + await handle?.detach()?.catch(() => {}); await sapiom.browserAutomation.sessions .closeManaged(session.sessionId) .catch(() => {}); await sapiom.shutdown().catch(() => {}); }; - process.on("SIGINT", () => { - void shutdown().finally(() => process.exit(0)); - }); + try { + handle = await attachInterceptor(session.cdpUrl, { + relay: httpPaymentRelay({ + baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai", + apiKey, + transactionId, + }), + profileId, + placeholder: { + number, + expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), + expYear: Number(process.env.PLACEHOLDER_EXP_YEAR), + cvc: process.env.PLACEHOLDER_CVC ?? "", + 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) => { diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts new file mode 100644 index 000000000..3faddd746 --- /dev/null +++ b/packages/browser-pay/src/detect.test.ts @@ -0,0 +1,34 @@ +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%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) { + expect(textHoldsPlaceholderNumber(value, NUMBER)).toBe(true); + const redacted = redactHeld(value, NUMBER); + 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); + }); +}); diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts index f64c10abd..feeb57750 100644 --- a/packages/browser-pay/src/detect.ts +++ b/packages/browser-pay/src/detect.ts @@ -79,9 +79,83 @@ export function textHoldsPlaceholderNumber( return decoded.includes(number) || holdsDigits(decoded, 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++) { + const separator = readDecoded(text, index); + if (isSeparatorChar(separator.char)) { + const after = readDecoded(text, separator.next); + 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 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 { - let out = number.length > 0 ? value.split(number).join("••••") : value; + if (number.length === 0 || value.length === 0) return value; const digits = placeholderDigits(number); - if (digits && digits !== number) out = out.split(digits).join("••••"); - return out; + if (!digits) return value.split(number).join("••••"); + return maskHeldSpans(value, digits); } diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 8acf4ec33..4c34405ae 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -451,7 +451,7 @@ describe("interceptor CDP decisions", () => { params: { requestId: "req-url", request: { - url: `https://checkout.example/pay?number=${HELD.number}`, + url: "https://checkout.example/pay?number=4242%204242%204242%204242", method: "POST", postData: '{"event":"ping"}', }, @@ -462,10 +462,12 @@ describe("interceptor CDP decisions", () => { "relayed", ); expect(calls).toHaveLength(1); - expect(calls[0].request.url).toContain(HELD.number); + 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", diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index b619904ca..3be4e8aab 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -176,4 +176,77 @@ describe("httpPaymentRelay", () => { 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("times out when headers arrive and the body stays open", async () => { + const relay = httpPaymentRelay({ + baseUrl: "https://relay.example", + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + timeoutMs: 50, + fetchImpl: stalledBody, + }); + const started = Date.now(); + await expect(relay.relay(REQUEST)).rejects.toThrow( + "Payment relay timed out", + ); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + 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", + timeoutMs: 30_000, + 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 index 3df0541c8..c8ea72fac 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -88,9 +88,10 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { config.timeoutMs, call?.signal, ); - let response: Response; + let text: string; + let status = 0; try { - response = await config.fetchImpl(url, { + const response = await config.fetchImpl(url, { method: "POST", redirect: "manual", headers: { @@ -101,7 +102,18 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { body: JSON.stringify(request), signal, }); + status = response.status; + if (status >= 300 && status < 400) { + throw new Error("Payment relay refused a redirect"); + } + text = await response.text(); } catch (error) { + if ( + error instanceof Error && + error.message === "Payment relay refused a redirect" + ) { + throw error; + } if (signal.aborted || isTimeout(error)) { throw new Error("Payment relay timed out"); } @@ -109,10 +121,6 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { } finally { dispose(); } - if (response.status >= 300 && response.status < 400) { - throw new Error("Payment relay refused a redirect"); - } - const text = await response.text(); let parsed: unknown; try { parsed = JSON.parse(text); @@ -126,7 +134,7 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { // The thrown error must not include the response text. } } - throw new Error(`Payment relay failed (HTTP ${response.status})`); + throw new Error(`Payment relay failed (HTTP ${status})`); }, }; } From 79ff05da7face6d4f6a5e3368fc439fb02eb0e70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 23:42:05 +0000 Subject: [PATCH 20/26] fix(browser-pay): reject a non-https relay base URL Co-authored-by: Rimmely Ewan --- packages/browser-pay/src/relay.test.ts | 24 ++++++++++++++++++++++++ packages/browser-pay/src/relay.ts | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 3be4e8aab..29bb7e4e0 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -42,6 +42,30 @@ function origin(server: http.Server): string { } 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)", + ); + }); + it("POSTs David's body to the transaction path and does not retry", async () => { const seen: { url?: string; diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index c8ea72fac..b6d93c9d0 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -27,6 +27,28 @@ export interface HttpRelayOptions { const DEFAULT_TIMEOUT_MS = 20_000; +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)", + ); + } + 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; @@ -37,6 +59,7 @@ function requireHttpOptions(options: HttpRelayOptions): { 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"); } From 0c5d748c5423bf2bfe4579db8534897f64ab8373 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 23:46:10 +0000 Subject: [PATCH 21/26] fix(browser-pay): read omitted post data and repeated separators Co-authored-by: Rimmely Ewan --- packages/browser-pay/src/detect.test.ts | 2 + packages/browser-pay/src/detect.ts | 23 +++++-- .../src/interceptor.protocol.test.ts | 60 ++++++++++++++++++- packages/browser-pay/src/interceptor.ts | 27 +++++---- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts index 3faddd746..b3866dbef 100644 --- a/packages/browser-pay/src/detect.test.ts +++ b/packages/browser-pay/src/detect.test.ts @@ -11,6 +11,8 @@ describe("redactHeld", () => { 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", diff --git a/packages/browser-pay/src/detect.ts b/packages/browser-pay/src/detect.ts index feeb57750..dd9c373a2 100644 --- a/packages/browser-pay/src/detect.ts +++ b/packages/browser-pay/src/detect.ts @@ -32,6 +32,16 @@ function separatorLength(text: string, index: number): number { return 0; } +function separatorRun(text: string, index: number): number { + let cursor = index; + while (cursor < text.length) { + const skip = separatorLength(text, cursor); + if (skip === 0) break; + cursor += skip; + } + return cursor - index; +} + function holdsDigits(text: string, digits: string): boolean { const last = text.length - digits.length; for (let start = 0; start <= last; start++) { @@ -39,7 +49,7 @@ function holdsDigits(text: string, digits: string): boolean { let index = start + 1; let matched = true; for (let digit = 1; digit < digits.length; digit++) { - const skip = separatorLength(text, index); + const skip = separatorRun(text, index); if (skip > 0 && text[index + skip] === digits[digit]) index += skip; if (text[index] !== digits[digit]) { matched = false; @@ -121,9 +131,14 @@ function heldSpanEnd( if (first.char !== digits[0]) return undefined; let index = first.next; for (let digit = 1; digit < digits.length; digit++) { - const separator = readDecoded(text, index); - if (isSeparatorChar(separator.char)) { - const after = readDecoded(text, separator.next); + 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; diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 4c34405ae..45d3e6fb5 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -134,7 +134,7 @@ async function startStub(options: StubOptions = {}): Promise<{ reply(message.id, {}); return; } - if (message.method === "Fetch.getRequestPostData") { + if (message.method === "Network.getRequestPostData") { if (options.postDataError) { reply(message.id, undefined, { message: "no body" }); return; @@ -332,6 +332,7 @@ describe("interceptor CDP decisions", () => { sessionId: "page-1", params: { requestId: "req-1", + networkId: "net-1", request: { url: "https://checkout.example/sessions", method: "POST", @@ -346,11 +347,17 @@ describe("interceptor CDP decisions", () => { () => 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(true); + ).toBe(false); expect(calls).toHaveLength(1); expect(calls[0].request.body).toBe(CARD); expect(calls[0].idempotencyKey).toBe( @@ -396,6 +403,7 @@ describe("interceptor CDP decisions", () => { sessionId: "page-1", params: { requestId: "req-2", + networkId: "net-2", request: { url: "https://checkout.example/sessions", method: "POST", @@ -524,6 +532,50 @@ describe("interceptor CDP decisions", () => { } }); + 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; @@ -740,7 +792,9 @@ describe("interceptor CDP decisions", () => { ); expect( stub.commands.some( - (command) => command.method === "Fetch.getRequestPostData", + (command) => + command.method === "Network.getRequestPostData" || + command.method === "Fetch.getRequestPostData", ), ).toBe(false); await handle.detach(); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index ac7b8998f..aba6445ce 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -421,6 +421,7 @@ interface PausedRequest { postData?: string; hasPostData?: boolean; postDataEntries?: { bytes?: string }[]; + networkId?: string; } interface Flight { @@ -511,12 +512,7 @@ async function handlePaused( const safeUrl = redactHeld(url, number); const targetType = ctx.sessions.get(entry.sessionId)?.type ?? ""; try { - const read = await readBodyResult( - ctx.connection, - entry.sessionId, - entry.requestId, - request, - ); + const read = await readBodyResult(ctx.connection, entry.sessionId, request); if (read.unreadable) { await finish(ctx, entry, "fail"); ctx.emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); @@ -662,7 +658,6 @@ async function settleClosed( const read = await readBodyResult( ctx.connection, entry.sessionId, - entry.requestId, request, ); unreadable = read.unreadable; @@ -709,7 +704,6 @@ function postDataPlan(request: { async function readBodyResult( connection: CdpConnection, sessionId: string, - requestId: string, request: PausedRequest, ): Promise<{ body: string; unreadable: boolean }> { const plan = postDataPlan(request); @@ -724,14 +718,21 @@ async function readBodyResult( ).toString("utf8"); return { body, unreadable: false }; } + if (!request.networkId) return { body: "", unreadable: true }; try { const result = await connection.send( - "Fetch.getRequestPostData", - { requestId }, + "Network.getRequestPostData", + { requestId: request.networkId }, sessionId, ); const postData = readStringField(result, "postData"); if (!postData) return { body: "", unreadable: true }; + if (isRecord(result) && result.base64Encoded === true) { + return { + body: Buffer.from(postData, "base64").toString("utf8"), + unreadable: false, + }; + } return { body: postData, unreadable: false }; } catch { return { body: "", unreadable: true }; @@ -891,6 +892,9 @@ function parsePaused( 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 } + : {}), }, }; } @@ -949,6 +953,9 @@ async function armFetch( if (!session || isHandsOffTarget(session)) return false; if (session.fetchEnabled) return true; try { + await connection + .send("Network.enable", {}, session.sessionId) + .catch(() => {}); await connection.send( "Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, From 3733ad169fe309255626306467065447a2838edd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 23:47:36 +0000 Subject: [PATCH 22/26] fix(browser-pay): reject a relay base URL that contains a fragment Co-authored-by: Rimmely Ewan --- packages/browser-pay/src/relay.test.ts | 6 ++++++ packages/browser-pay/src/relay.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 29bb7e4e0..ad74b8277 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -64,6 +64,12 @@ describe("httpPaymentRelay", () => { ).toThrow( "Payment relay baseUrl must be https (http is only allowed on loopback)", ); + expect(() => + httpPaymentRelay({ + ...options, + baseUrl: "https://api.sapiom.ai#/payment/relay", + }), + ).toThrow("Payment relay baseUrl must not contain a fragment"); }); it("POSTs David's body to the transaction path and does not retry", async () => { diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index b6d93c9d0..f9cd93cdc 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -36,6 +36,9 @@ function assertBaseUrl(baseUrl: string): void { "Payment relay baseUrl must be https (http is only allowed on loopback)", ); } + if (url.hash) { + throw new Error("Payment relay baseUrl must not contain a fragment"); + } const host = url.hostname; if (url.protocol === "https:") return; if ( From 194b4f42e543361713d9f92c9aeab1ab4289be42 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 00:49:37 +0000 Subject: [PATCH 23/26] fix(browser-pay): reject relay URL delimiters and incomplete multipart bodies Co-authored-by: Rimmely Ewan --- .../src/interceptor.protocol.test.ts | 62 +++++++++++++++++++ packages/browser-pay/src/interceptor.ts | 44 ++++++++++++- packages/browser-pay/src/relay.test.ts | 16 +++-- packages/browser-pay/src/relay.ts | 11 +++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 45d3e6fb5..97a0acdd4 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -378,6 +378,68 @@ describe("interceptor CDP decisions", () => { } }); + it("fails closed when a multipart body would omit file bytes", async () => { + const stub = await startStub({ + postData: `--b\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n`, + }); + 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-file", + networkId: "net-file", + request: { + url: "https://checkout.example/sessions", + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=b", + }, + 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.continueRequest", + ), + ).toBe(false); + expect( + stub.commands.some( + (command) => command.method === "Network.getRequestPostData", + ), + ).toBe(false); + 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[] = []; diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index aba6445ce..2a08813ca 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -718,7 +718,9 @@ async function readBodyResult( ).toString("utf8"); return { body, unreadable: false }; } - if (!request.networkId) return { body: "", unreadable: true }; + if (!request.networkId || isMultipart(request.headers)) { + return { body: "", unreadable: true }; + } try { const result = await connection.send( "Network.getRequestPostData", @@ -938,6 +940,46 @@ function readStringField(value: unknown, key: string): string | undefined { 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/"); +} + function requestHasBody(request: PausedRequest): boolean { if (request.hasPostData) return true; if (typeof request.postData === "string" && request.postData.length > 0) { diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index ad74b8277..5ffa8829c 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -64,12 +64,16 @@ describe("httpPaymentRelay", () => { ).toThrow( "Payment relay baseUrl must be https (http is only allowed on loopback)", ); - expect(() => - httpPaymentRelay({ - ...options, - baseUrl: "https://api.sapiom.ai#/payment/relay", - }), - ).toThrow("Payment relay baseUrl must not contain a fragment"); + 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 () => { diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index f9cd93cdc..49986465e 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -36,8 +36,15 @@ function assertBaseUrl(baseUrl: string): void { "Payment relay baseUrl must be https (http is only allowed on loopback)", ); } - if (url.hash) { - throw new Error("Payment relay baseUrl must not contain a fragment"); + 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; From 6385e74bb709e6a3711d1fe4c64636e33aea5241 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 01:29:59 +0000 Subject: [PATCH 24/26] test(browser-pay): reproduce four interceptor and relay bugs Attach-time arm failures are dropped before on() exists. detach() can fail a request that fulfill is already settling. A non-2xx body shaped like an outcome is accepted. Blank iframes are disarmed after attach. Co-authored-by: Rimmely Ewan --- .../src/interceptor.protocol.test.ts | 166 ++++++++++++++++++ packages/browser-pay/src/relay.test.ts | 32 ++++ 2 files changed, 198 insertions(+) diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 97a0acdd4..eac7e8e8c 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -33,6 +33,7 @@ interface Command { interface StubOptions { failFetch?: boolean; failFulfill?: boolean; + holdFulfill?: boolean; postData?: string; postDataError?: boolean; targets?: { targetId: string; type: string; url: string }[]; @@ -146,6 +147,9 @@ async function startStub(options: StubOptions = {}): Promise<{ reply(message.id, undefined, { message: "request is gone" }); return; } + if (message.method === "Fetch.fulfillRequest" && options.holdFulfill) { + return; + } reply(message.id, {}); }; @@ -1417,4 +1421,166 @@ describe("interceptor CDP decisions", () => { 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 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", + }); + 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/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 5ffa8829c..5396b6400 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -211,6 +211,38 @@ describe("httpPaymentRelay", () => { } }); + 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" }); From f23838aa80747780ec1389131b9e67167c75a9de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 01:41:51 +0000 Subject: [PATCH 25/26] fix(browser-pay): settle once and fail closed on non-2xx relays Buffer attach-time events until the first subscriber. Claim a paused request before fulfill so detach cannot fail it too. Keep blank frames armed with the same predicate used at attach. Treat any non-2xx relay response as a transport failure and do not read it as an outcome. One interceptor timeout covers the relay wait. HeldPlaceholder keeps number and injectionRef. PayLogger keeps warn. The test relay stub moves out of the published package. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-interceptor.md | 2 + packages/browser-pay/README.md | 12 +- packages/browser-pay/examples/attach.ts | 12 +- packages/browser-pay/src/cdp.ts | 11 +- packages/browser-pay/src/contract.ts | 5 +- packages/browser-pay/src/detect.test.ts | 5 +- packages/browser-pay/src/detect.ts | 98 +- packages/browser-pay/src/guards.ts | 3 + packages/browser-pay/src/index.ts | 1 + .../src/interceptor.integration.test.ts | 31 +- .../src/interceptor.protocol.test.ts | 26 +- packages/browser-pay/src/interceptor.ts | 1217 ++++++++--------- packages/browser-pay/src/logger.ts | 5 - packages/browser-pay/src/relay.test.ts | 44 +- packages/browser-pay/src/relay.ts | 114 +- packages/browser-pay/test/stub-relay.ts | 15 + packages/browser-pay/tsconfig.build.json | 4 +- packages/browser-pay/tsconfig.cjs.json | 2 + packages/browser-pay/tsconfig.esm.json | 2 + packages/browser-pay/tsconfig.examples.json | 2 +- packages/browser-pay/tsconfig.json | 4 +- 21 files changed, 740 insertions(+), 875 deletions(-) create mode 100644 packages/browser-pay/src/guards.ts create mode 100644 packages/browser-pay/test/stub-relay.ts diff --git a/.changeset/browser-pay-interceptor.md b/.changeset/browser-pay-interceptor.md index a1bc92226..57ecf5422 100644 --- a/.changeset/browser-pay-interceptor.md +++ b/.changeset/browser-pay-interceptor.md @@ -3,3 +3,5 @@ --- 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/packages/browser-pay/README.md b/packages/browser-pay/README.md index 1b3c141f3..855a2fa6a 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -63,7 +63,7 @@ No answer after the send: The injection is consumed. The paused request fails and the same body is not sent again. -A transport error, a redirect, or an unreadable body fails the paused browser request. It is not fulfilled with the error JSON. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`. +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 @@ -77,17 +77,19 @@ const handle = await attachInterceptor(cdpUrl, { transactionId, }), profileId: "shopify-card-vault", - placeholder, // number, expMonth, expYear, cvc, injectionRef + placeholder, }); ``` -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. Expiry and CVC are not compared. The backend's `checkPlaceholderUse` decides whether the number is valid. A paused request that does not hold the placeholder is continued. +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. A refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. +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 placeholder that shows up on any other page or iframe is relayed. +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. diff --git a/packages/browser-pay/examples/attach.ts b/packages/browser-pay/examples/attach.ts index 38da9b703..8ee27f2a0 100644 --- a/packages/browser-pay/examples/attach.ts +++ b/packages/browser-pay/examples/attach.ts @@ -6,7 +6,7 @@ * 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=… PLACEHOLDER_EXP_MONTH=… PLACEHOLDER_EXP_YEAR=… PLACEHOLDER_CVC=… \ + * PLACEHOLDER_NUMBER=… \ * npx tsx examples/attach.ts */ import { randomUUID } from "node:crypto"; @@ -15,14 +15,13 @@ import { attachInterceptor, httpPaymentRelay, type InterceptorHandle, - type PayLogger, } from "../src/index.js"; -const log: PayLogger = { - info(message, fields) { +const log = { + info(message: string, fields?: Readonly>) { process.stdout.write(`${message} ${JSON.stringify(fields ?? {})}\n`); }, - warn(message, fields) { + warn(message: string, fields?: Readonly>) { process.stderr.write(`${message} ${JSON.stringify(fields ?? {})}\n`); }, }; @@ -67,9 +66,6 @@ async function main(): Promise { profileId, placeholder: { number, - expMonth: Number(process.env.PLACEHOLDER_EXP_MONTH), - expYear: Number(process.env.PLACEHOLDER_EXP_YEAR), - cvc: process.env.PLACEHOLDER_CVC ?? "", injectionRef, }, logger: log, diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index 2e39fe5f1..b18f437cc 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -2,6 +2,7 @@ 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 @@ -22,10 +23,6 @@ type Pending = { timer: NodeJS.Timeout; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseCdpMessage(value: unknown): | { id?: number; @@ -335,9 +332,9 @@ export class CdpConnection { } const message = parseCdpMessage(parsed); if (!message) return; - if (message.id !== undefined && this.pending.has(message.id)) { - const pending = this.pending.get(message.id); - if (!pending) 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) { diff --git a/packages/browser-pay/src/contract.ts b/packages/browser-pay/src/contract.ts index 6f255bedd..1a5529850 100644 --- a/packages/browser-pay/src/contract.ts +++ b/packages/browser-pay/src/contract.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { isRecord } from "./guards.js"; export interface RelayHttpRequest { method: string; @@ -40,10 +41,6 @@ export function paymentIdempotencyKey( .digest("hex"); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function stringRecord(value: unknown): Record | undefined { if (!isRecord(value)) return undefined; const out: Record = {}; diff --git a/packages/browser-pay/src/detect.test.ts b/packages/browser-pay/src/detect.test.ts index b3866dbef..d351b7816 100644 --- a/packages/browser-pay/src/detect.test.ts +++ b/packages/browser-pay/src/detect.test.ts @@ -21,8 +21,10 @@ describe("redactHeld", () => { `https://checkout.example/pay?number=${encodedDigits(NUMBER)}`, ]; for (const value of values) { - expect(textHoldsPlaceholderNumber(value, NUMBER)).toBe(true); 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); @@ -32,5 +34,6 @@ describe("redactHeld", () => { 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 index dd9c373a2..5c6b0d2dc 100644 --- a/packages/browser-pay/src/detect.ts +++ b/packages/browser-pay/src/detect.ts @@ -1,8 +1,5 @@ export interface HeldPlaceholder { number: string; - expMonth: number; - expYear: number; - cvc: string; injectionRef: string; } @@ -12,83 +9,6 @@ function placeholderDigits(number: string): string | undefined { return digits; } -function separatorLength(text: string, index: number): number { - const code = text.charCodeAt(index); - if (code === 32 || code === 9 || code === 45 || code === 43) return 1; - if ( - text.startsWith("%20", index) || - text.startsWith("%2D", index) || - text.startsWith("%2d", index) - ) { - return 3; - } - if ( - text.startsWith("\\u0020", index) || - text.startsWith("\\u002D", index) || - text.startsWith("\\u002d", index) - ) { - return 6; - } - return 0; -} - -function separatorRun(text: string, index: number): number { - let cursor = index; - while (cursor < text.length) { - const skip = separatorLength(text, cursor); - if (skip === 0) break; - cursor += skip; - } - return cursor - index; -} - -function holdsDigits(text: string, digits: string): boolean { - const last = text.length - digits.length; - for (let start = 0; start <= last; start++) { - if (text[start] !== digits[0]) continue; - let index = start + 1; - let matched = true; - for (let digit = 1; digit < digits.length; digit++) { - const skip = separatorRun(text, index); - if (skip > 0 && text[index + skip] === digits[digit]) index += skip; - if (text[index] !== digits[digit]) { - matched = false; - break; - } - index += 1; - } - if (matched) return true; - } - return false; -} - -function decodeForSearch(text: string): string { - const withUnicode = text.replace( - /\\u([0-9a-fA-F]{4})/g, - (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), - ); - const plus = withUnicode.replace(/\+/g, " "); - try { - return decodeURIComponent(plus); - } catch { - return plus; - } -} - -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; - if (holdsDigits(text, digits)) return true; - const decoded = decodeForSearch(text); - if (decoded === text) return false; - return decoded.includes(number) || holdsDigits(decoded, digits); -} - function isSeparatorChar(char: string): boolean { return char === " " || char === "\t" || char === "-" || char === "+"; } @@ -151,6 +71,24 @@ function heldSpanEnd( 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; 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 index 989686b72..71a4769b8 100644 --- a/packages/browser-pay/src/index.ts +++ b/packages/browser-pay/src/index.ts @@ -1,6 +1,7 @@ export { attachInterceptor } from "./interceptor.js"; export type { AttachOptions, + FailureReason, InterceptorEvent, InterceptorHandle, InterceptorTarget, diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 63d55768e..cb144fbca 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -7,20 +7,19 @@ import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { attachInterceptor, type InterceptorEvent } from "./interceptor"; -import { - stubPaymentRelay, - type PaymentRelay, - type RelayCallOptions, -} from "./relay"; +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: "4242424242424242", - expMonth: 12, - expYear: 2028, - cvc: "123", + number: NUMBER, injectionRef: "inj_integration", }; @@ -35,18 +34,18 @@ const UNRELATED = JSON.stringify({ }); const MISMATCH = JSON.stringify({ credit_card: { - number: PLACEHOLDER.number, - month: PLACEHOLDER.expMonth, - year: PLACEHOLDER.expYear, + number: NUMBER, + month: EXP_MONTH, + year: EXP_YEAR, verification_value: "999", }, }); const CARD = JSON.stringify({ credit_card: { - number: PLACEHOLDER.number, - month: PLACEHOLDER.expMonth, - year: PLACEHOLDER.expYear, - verification_value: PLACEHOLDER.cvc, + number: NUMBER, + month: EXP_MONTH, + year: EXP_YEAR, + verification_value: CVC, }, }); diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index eac7e8e8c..376004c46 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -3,15 +3,12 @@ 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 { PayLogger } from "./logger"; -import { stubPaymentRelay, type RelayCallOptions } from "./relay"; import type { HeldPlaceholder, RelayRequest, RelayResponse } from "./index"; +import { stubPaymentRelay } from "../test/stub-relay"; +import type { RelayCallOptions } from "./relay"; const HELD: HeldPlaceholder = { number: "4242424242424242", - expMonth: 12, - expYear: 2028, - cvc: "123", injectionRef: "inj_protocol", }; @@ -959,9 +956,8 @@ describe("interceptor CDP decisions", () => { release = resolve; }); const warnings: string[] = []; - const logger: PayLogger = { - info() {}, - warn(message) { + const logger = { + warn(message: string) { warnings.push(message); }, }; @@ -1441,7 +1437,9 @@ describe("interceptor CDP decisions", () => { profileId: "shopify-card-vault", }); expect( - stub.commands.some((command) => command.method === "Target.closeTarget"), + stub.commands.some( + (command) => command.method === "Target.closeTarget", + ), ).toBe(true); handle.on((event) => events.push(event)); expect(events).toContainEqual({ @@ -1514,6 +1512,16 @@ describe("interceptor CDP decisions", () => { 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: { diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 2a08813ca..36ced8640 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -1,12 +1,21 @@ import { CdpConnection, type CdpEvent } from "./cdp.js"; -import { paymentIdempotencyKey, type RelayResponse } from "./contract.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 type { PaymentRelay } from "./relay.js"; +import { + isRelayTimeout, + RelayTimeoutError, + type PaymentRelay, +} from "./relay.js"; export interface AttachOptions { relay: PaymentRelay; @@ -17,18 +26,31 @@ export interface AttachOptions { /** * 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: string }; + | { type: "failed"; url: string; reason: FailureReason }; export interface InterceptorHandle { detach(): Promise; @@ -49,6 +71,7 @@ export type InterceptorTarget = 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( @@ -108,16 +131,15 @@ interface Session { type: string; url: string; parentSessionId?: string; - fetchEnabled?: boolean; + fetchEnabled: boolean; } -function isBlankTarget(url: string): boolean { - return url === "" || url === "about:blank" || url === "about:srcdoc"; +// 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 shouldPauseTarget(session: Session): boolean { - return !isHandsOffTarget(session) && !isBlankTarget(session.url); -} function isHandsOffTarget(session: Session): boolean { if ( session.type === "worker" || @@ -130,564 +152,655 @@ function isHandsOffTarget(session: Session): boolean { 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 cdpUrl = resolveCdpUrl(target); - const connection = await CdpConnection.connect(cdpUrl); - const sessions = new Map(); - const seen = new Set(); - const listeners = new Set<(event: InterceptorEvent) => void>(); - const inflight = new Map(); - const relayFlights = new Map(); - const relayAborts = new Set(); - const consumed = new Set(); - const loggedRefusals = new Set(); - const pendingArms: Promise[] = []; - let closed = false; - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const logger = options.logger ?? silentLogger; - - const emit = (event: InterceptorEvent) => { - for (const listener of listeners) { - try { - listener(event); - } catch { - // A listener must not break interception. - } - } - }; + const connection = await CdpConnection.connect(resolveCdpUrl(target)); + return new PaymentInterceptor(connection, options).start(); +} - const enableSession = async ( - sessionId: string, - waitingForDebugger: boolean, - ) => { - if (seen.has(sessionId)) { - if (waitingForDebugger) { - await connection - .send("Runtime.runIfWaitingForDebugger", {}, sessionId) - .catch(() => {}); - } - return; - } - seen.add(sessionId); - const session = sessions.get(sessionId); - if (!session || isHandsOffTarget(session)) { - await releaseTarget(connection, sessionId, waitingForDebugger); +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 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]); + return { + on: (listener) => this.subscribe(listener), + detach: () => this.detach(), + }; + } + + private dispatch(event: CdpEvent): void { + if (event.method === "Fetch.requestPaused") { + this.onPaused(event); return; } - // Fetch has to be on before the target runs, or the card iframe can post - // before we are listening. - const armed = await armFetch(connection, session); - if (!armed) { - emit({ - type: "failed", - url: redactHeld( - session.url || session.targetId, - options.placeholder.number, - ), - reason: "intercept_unavailable", - }); - await connection - .send("Target.closeTarget", { targetId: session.targetId }) - .catch(() => {}); - 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); } - await releaseTarget(connection, sessionId, waitingForDebugger); - }; + } - const onAttached = (event: CdpEvent) => { + private onAttached(event: CdpEvent): void { const params = parseAttachedTarget(event.params); if (!params) return; const sessionId = params.sessionId; const info = params.targetInfo; - const duplicate = [...sessions.values()].some( - (session) => session.targetId === info.targetId, - ); - if (duplicate) { + if (this.byTarget.has(info.targetId)) { // setAutoAttach already owns this target. A second session would pause // every request twice. - void releaseTarget( - connection, - sessionId, - params.waitingForDebugger === true, - ).then(() => - connection + void this.resumeIfWaiting(sessionId, params.waitingForDebugger).then(() => + this.connection .send("Target.detachFromTarget", { sessionId }) .catch(() => {}), ); return; } - sessions.set(sessionId, { + const session = this.trackSession({ sessionId, targetId: info.targetId, type: info.type, - url: info.url ?? "", + url: info.url, + fetchEnabled: false, ...(event.sessionId ? { parentSessionId: event.sessionId } : {}), }); const arm = (async () => { - // Child iframes pause on attach so Fetch.enable runs before they resume. if (info.type === "page" || info.type === "iframe") { - await connection - .send( - "Target.setAutoAttach", - { - autoAttach: true, - waitForDebuggerOnStart: true, - flatten: true, - filter: [{ type: "iframe" }], - }, - sessionId, - ) - .catch(() => {}); + await this.autoAttachChildren(sessionId); } - await enableSession(sessionId, params.waitingForDebugger === true); + await this.enableSession(session.sessionId, params.waitingForDebugger); })(); - pendingArms.push(arm); + this.pendingArms.add(arm); void arm.finally(() => { - const index = pendingArms.indexOf(arm); - if (index >= 0) pendingArms.splice(index, 1); + this.pendingArms.delete(arm); }); - }; + } - const onPaused = (event: CdpEvent) => { - const sessionId = event.sessionId; - const params = parsePaused(event.params); - if (!sessionId || !params) return; - if (!sessions.has(sessionId)) { - void failRequest(connection, sessionId, params.requestId); - emit({ - type: "failed", - url: redactHeld(params.request.url ?? "", options.placeholder.number), - reason: "unknown_session", - }); - return; + 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); } - const url = params.request.url ?? ""; - const method = params.request.method ?? "GET"; - const hasBody = requestHasBody(params.request); - const key = inflightKey(sessionId, params.requestId); - if (inflight.has(key)) return; - const entry: Inflight = { - key, - sessionId, - requestId: params.requestId, - settled: false, - }; - inflight.set(key, entry); - const ctx: PauseContext = { - connection, - sessions, - options, - timeoutMs, - emit, - inflight, - relayFlights, - loggedRefusals, - logger, - relayAborts, - consumed, - isClosed: () => closed, - }; - if ( - canContinueWithoutReading( - method, - url, - hasBody, - options.placeholder.number, - ) - ) { - entry.continuing = continueRequest( - connection, - sessionId, - params.requestId, - ).finally(() => { - entry.settled = true; - inflight.delete(key); - }); + } + + 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 (closed) { - void settleClosed(ctx, entry, url, params.request); - 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 handlePaused(ctx, entry, params.request); - }; + void this.resumeIfWaiting(session.sessionId, true); + } - connection.onEvent((event) => { - if (event.method === "Fetch.requestPaused") { - onPaused(event); + private async enableSession( + sessionId: string, + waitingForDebugger: boolean, + ): Promise { + if (this.enabledSessions.has(sessionId)) { + await this.resumeIfWaiting(sessionId, waitingForDebugger); return; } - if (closed) return; - if (event.method === "Target.attachedToTarget") onAttached(event); - else if (event.method === "Target.detachedFromTarget") { - const sessionId = readStringField(event.params, "sessionId"); - if (sessionId) sessions.delete(sessionId); - } else if (event.method === "Target.targetInfoChanged") { - const info = parseTargetInfoChange(event.params); - if (!info) return; - for (const session of sessions.values()) { - if (session.targetId !== info.targetId) continue; - session.url = info.url; - if (!shouldPauseTarget(session)) { - if (session.fetchEnabled) { - session.fetchEnabled = false; - void connection - .send("Fetch.disable", {}, session.sessionId) - .catch(() => {}); - } - void connection - .send("Runtime.runIfWaitingForDebugger", {}, session.sessionId) - .catch(() => {}); - continue; - } - if (!session.fetchEnabled) { - void armFetch(connection, session).then((armed) => { - if (armed || closed) return; - emit({ - type: "failed", - url: redactHeld( - session.url || session.targetId, - options.placeholder.number, - ), - reason: "intercept_unavailable", - }); - void connection - .send("Target.closeTarget", { targetId: session.targetId }) - .catch(() => {}); - }); - } - } + 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); + } - await connection.send("Target.setAutoAttach", { - autoAttach: true, - waitForDebuggerOnStart: true, - flatten: true, - filter: TARGET_FILTER, - }); - await connection - .send("Target.setDiscoverTargets", { discover: true }) - .catch(() => {}); - await attachExistingPaymentFrames(connection, sessions, (session) => - enableSession(session.sessionId, false), - ); - await Promise.all([...pendingArms]); + 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; + } - return { - on(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async detach() { - if (closed) return; - closed = true; - for (const controller of relayAborts) controller.abort(); - await drainInflight(connection, inflight); - for (const sessionId of sessions.keys()) { - await 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 drainInflight(connection, inflight); - await connection - .send("Target.setAutoAttach", { - autoAttach: false, - waitForDebuggerOnStart: false, + private async autoAttachChildren(sessionId: string): Promise { + await this.connection + .send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: true, flatten: true, - }) - .catch(() => {}); - connection.close(); - }, - }; -} - -interface Inflight { - key: string; - sessionId: string; - requestId: string; - settled: boolean; - continuing?: Promise; -} - -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; -} - -interface PauseContext { - connection: CdpConnection; - sessions: Map; - options: AttachOptions; - timeoutMs: number; - emit: (event: InterceptorEvent) => void; - inflight: Map; - relayFlights: Map; - loggedRefusals: Set; - logger: PayLogger; - relayAborts: Set; - consumed: Set; - isClosed: () => boolean; -} - -function inflightKey(sessionId: string, requestId: string): string { - return `${sessionId}\n${requestId}`; -} + filter: CHILD_FILTER, + }, + sessionId, + ) + .catch(() => {}); + } -async function drainInflight( - connection: CdpConnection, - inflight: Map, -): Promise { - const entries = [...inflight.values()]; - await Promise.all( - entries.map(async (entry) => { - if (entry.settled) return; - if (entry.continuing) { - await entry.continuing; - return; - } - entry.settled = true; - inflight.delete(entry.key); - await failRequest(connection, entry.sessionId, entry.requestId); - }), - ); -} + private async resumeIfWaiting( + sessionId: string, + waitingForDebugger: boolean, + ): Promise { + if (!waitingForDebugger) return; + await this.connection + .send("Runtime.runIfWaitingForDebugger", {}, sessionId) + .catch(() => {}); + } -async function finish( - ctx: PauseContext, - entry: Inflight, - action: "fail" | "continue", -): Promise { - if (entry.settled) return false; - entry.settled = true; - ctx.inflight.delete(entry.key); - if (action === "continue") { - await continueRequest(ctx.connection, entry.sessionId, entry.requestId); - } else { - await failRequest(ctx.connection, entry.sessionId, entry.requestId); + 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); + } } - return true; -} -async function finishContinue( - ctx: PauseContext, - entry: Inflight, -): Promise { - if (entry.settled) return; - const promise = continueRequest( - ctx.connection, - entry.sessionId, - entry.requestId, - ).finally(() => { - entry.settled = true; - ctx.inflight.delete(entry.key); - }); - entry.continuing = promise; - await promise; -} + private trackSession(session: Session): Session { + this.sessions.set(session.sessionId, session); + this.byTarget.set(session.targetId, session); + return session; + } -async function handlePaused( - ctx: PauseContext, - entry: Inflight, - request: PausedRequest, -): Promise { - const url = request.url ?? ""; - const method = request.method ?? "GET"; - const number = ctx.options.placeholder.number; - const safeUrl = redactHeld(url, number); - const targetType = ctx.sessions.get(entry.sessionId)?.type ?? ""; - try { - const read = await readBodyResult(ctx.connection, entry.sessionId, request); - if (read.unreadable) { - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); + 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 held = - textHoldsPlaceholderNumber(url, number) || - textHoldsPlaceholderNumber(read.body, number); - if (!held) { - await finishContinue(ctx, entry); + 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; } - if (ctx.isClosed()) { - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "detached" }); - 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" }; } - ctx.emit({ type: "paused", url: safeUrl, method, targetType }); + 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( - ctx.connection, - ctx.sessions, + this.connection, + this.sessions, entry.sessionId, ); - if (!pageOrigin || ctx.isClosed()) { - await finish(ctx, entry, "fail"); - ctx.emit({ - type: "failed", - url: safeUrl, + if (!pageOrigin || this.closed) { + return { + action: "fail", reason: pageOrigin ? "detached" : "page_origin", - }); - return; + }; } - const idempotencyKey = paymentIdempotencyKey(method, url, read.body); - if (ctx.consumed.has(idempotencyKey)) { - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "unknown" }); - return; + 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 flight = relayOnce(ctx, idempotencyKey, (signal) => - ctx.options.relay.relay( + 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: ctx.options.placeholder.injectionRef, - profileId: ctx.options.profileId, + injectionRef: this.options.placeholder.injectionRef, + profileId: this.options.profileId, pageOrigin, request: { - method, - url, + method: request.method, + url: request.url, headers: forwardedHeaders(request.headers), - body: read.body, + body, }, idempotencyKey, }, { signal }, ), ); - let response: RelayResponse; try { - response = await failAfter(flight.promise, ctx.timeoutMs); + const response = await failAfter(flight.promise, this.timeoutMs); + this.releaseWaiter(flight); + return { action: "ok", response }; } catch (error) { - releaseWaiter(ctx, flight); - await finish(ctx, entry, "fail"); - ctx.emit({ - type: "failed", - url: safeUrl, - reason: ctx.isClosed() ? "detached" : failureReason(error), - }); - return; + this.releaseWaiter(flight); + return { + action: "fail", + reason: this.closed ? "detached" : failureReason(error), + }; } - releaseWaiter(ctx, flight); + } + + private decisionForOutcome( + response: RelayResponse, + idempotencyKey: string, + ): Decision { if (response.outcome === "unknown") { - ctx.consumed.add(idempotencyKey); - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "unknown" }); - return; + this.unknownKeys.add(idempotencyKey); + return { action: "fail", reason: "unknown" }; } if (response.outcome === "refused") { - if (!ctx.loggedRefusals.has(idempotencyKey)) { - ctx.loggedRefusals.add(idempotencyKey); - ctx.logger.warn("payment relay refused the paused request", { - reason: response.reason, - }); - } - await finish(ctx, entry, "fail"); - ctx.emit({ type: "refused", url: safeUrl, reason: response.reason }); - return; + this.logRefusalOnce(idempotencyKey, response.reason); + return { action: "refused", reason: response.reason }; } - if (textHoldsPlaceholderNumber(response.response.body, number)) { - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "response_credential" }); - return; + if (textHoldsPlaceholderNumber(response.response.body, this.number)) { + return { action: "fail", reason: "response_credential" }; } - let fulfilled = false; - try { - if (!entry.settled) { - await fulfillRequest( - ctx.connection, - entry.sessionId, - entry.requestId, - response.response, - ); - fulfilled = true; - } - } catch { - fulfilled = false; + 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 (!fulfilled && !entry.settled) { - await failRequest(ctx.connection, entry.sessionId, entry.requestId); + if (decision.action === "fail") { + await this.settle(entry, "fail"); + this.emit({ type: "failed", url: safeUrl, reason: decision.reason }); + return; } - if (!entry.settled) { - entry.settled = true; - ctx.inflight.delete(entry.key); + if (decision.action === "refused") { + await this.settle(entry, "fail"); + this.emit({ type: "refused", url: safeUrl, reason: decision.reason }); + return; } - ctx.emit( + const fulfilled = await this.settle(entry, { fulfill: decision.response }); + this.emit( fulfilled - ? { - type: "relayed", - url: safeUrl, - status: response.response.status, - } + ? { type: "relayed", url: safeUrl, status: decision.response.status } : { type: "relayed_unfulfilled", url: safeUrl, - status: response.response.status, + status: decision.response.status, }, ); - } catch { - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "network" }); } -} -async function settleClosed( - ctx: PauseContext, - entry: Inflight, - url: string, - request: PausedRequest, -): Promise { - const safeUrl = redactHeld(url, ctx.options.placeholder.number); - try { - let holds = textHoldsPlaceholderNumber(url, ctx.options.placeholder.number); - let unreadable = false; - if (!holds && requestHasBody(request)) { - const read = await readBodyResult( - ctx.connection, + 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" }; + this.inflight.delete(entry.key); + if (action === "fail") { + await failRequest(this.connection, entry.sessionId, entry.requestId); + return true; + } + try { + await fulfillRequest( + this.connection, entry.sessionId, - request, + entry.requestId, + action.fulfill, ); - unreadable = read.unreadable; - holds = - unreadable || - textHoldsPlaceholderNumber(read.body, ctx.options.placeholder.number); + return true; + } catch { + await failRequest(this.connection, entry.sessionId, entry.requestId); + return false; } - if (holds) { - await finish(ctx, entry, "fail"); - ctx.emit({ - type: "failed", - url: safeUrl, - reason: unreadable ? "unreadable_body" : "placeholder_mismatch", - }); + } + + 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; + 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.backlog) { + this.backlog.push(event); return; } - await finishContinue(ctx, entry); - } catch { - await finish(ctx, entry, "fail"); - ctx.emit({ type: "failed", url: safeUrl, reason: "unreadable_body" }); + 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: { - postData?: string; - hasPostData?: boolean; - postDataEntries?: { bytes?: string }[]; -}): PostDataPlan { +function postDataPlan(request: PausedRequest): PostDataPlan { if (typeof request.postData === "string" && request.postData.length > 0) { return "inline"; } @@ -701,26 +814,22 @@ function postDataPlan(request: { return "empty"; } -async function readBodyResult( +async function readBody( connection: CdpConnection, sessionId: string, request: PausedRequest, -): Promise<{ body: string; unreadable: boolean }> { +): Promise { const plan = postDataPlan(request); - if (plan === "empty") return { body: "", unreadable: false }; - if (plan === "inline") - return { body: request.postData ?? "", unreadable: false }; + if (plan === "empty") return ""; + if (plan === "inline") return request.postData ?? ""; if (plan === "entries") { - const body = Buffer.concat( + return Buffer.concat( (request.postDataEntries ?? []).map((entry) => Buffer.from(entry.bytes ?? "", "base64"), ), ).toString("utf8"); - return { body, unreadable: false }; - } - if (!request.networkId || isMultipart(request.headers)) { - return { body: "", unreadable: true }; } + if (!request.networkId || isMultipart(request.headers)) return undefined; try { const result = await connection.send( "Network.getRequestPostData", @@ -728,16 +837,13 @@ async function readBodyResult( sessionId, ); const postData = readStringField(result, "postData"); - if (!postData) return { body: "", unreadable: true }; + if (!postData) return undefined; if (isRecord(result) && result.base64Encoded === true) { - return { - body: Buffer.from(postData, "base64").toString("utf8"), - unreadable: false, - }; + return Buffer.from(postData, "base64").toString("utf8"); } - return { body: postData, unreadable: false }; + return postData; } catch { - return { body: "", unreadable: true }; + return undefined; } } @@ -800,9 +906,9 @@ function topPageSession( sessionId: string, ): string | undefined { let current: string | undefined = sessionId; - const seen = new Set(); - while (current && !seen.has(current)) { - seen.add(current); + 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; @@ -980,19 +1086,10 @@ function isMultipart(headers: unknown): boolean { .includes("multipart/"); } -function requestHasBody(request: PausedRequest): boolean { - if (request.hasPostData) return true; - if (typeof request.postData === "string" && request.postData.length > 0) { - return true; - } - return Boolean(request.postDataEntries && request.postDataEntries.length > 0); -} - async function armFetch( connection: CdpConnection, - session: Session | undefined, + session: Session, ): Promise { - if (!session || isHandsOffTarget(session)) return false; if (session.fetchEnabled) return true; try { await connection @@ -1010,17 +1107,6 @@ async function armFetch( return true; } -async function releaseTarget( - connection: CdpConnection, - sessionId: string, - waitingForDebugger: boolean, -): Promise { - if (!waitingForDebugger) return; - await connection - .send("Runtime.runIfWaitingForDebugger", {}, sessionId) - .catch(() => {}); -} - async function continueRequest( connection: CdpConnection, sessionId: string, @@ -1051,7 +1137,7 @@ async function fulfillRequest( connection: CdpConnection, sessionId: string, requestId: string, - response: { status: number; headers: Record; body: string }, + response: RelayedHttpResponse, ): Promise { const responseHeaders = Object.entries(response.headers).flatMap( ([name, value]) => { @@ -1071,113 +1157,6 @@ async function fulfillRequest( ); } -function failureReason(error: unknown): string { - if (!error || typeof error !== "object") return "network"; - const name = "name" in error ? String(error.name) : ""; - const message = "message" in error ? String(error.message) : ""; - if ( - name === "TimeoutError" || - name === "AbortError" || - message === "Payment relay timed out" || - /timeout/i.test(message) - ) { - return "timeout"; - } - return "network"; -} - -function relayOnce( - ctx: PauseContext, - key: string, - start: (signal: AbortSignal) => Promise, -): Flight { - const existing = ctx.relayFlights.get(key); - if (existing && !existing.controller.signal.aborted) { - existing.waiters += 1; - return existing; - } - const controller = new AbortController(); - ctx.relayAborts.add(controller); - const promise = start(controller.signal).finally(() => { - ctx.relayAborts.delete(controller); - const current = ctx.relayFlights.get(key); - if (current?.controller === controller) ctx.relayFlights.delete(key); - }); - void promise.catch(() => {}); - const flight = { controller, waiters: 1, promise }; - ctx.relayFlights.set(key, flight); - return flight; -} - -function releaseWaiter(ctx: PauseContext, flight: Flight): void { - flight.waiters -= 1; - if (flight.waiters > 0) return; - flight.controller.abort(); - ctx.relayAborts.delete(flight.controller); -} - -function failAfter(promise: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("Payment relay timed out")); - }, ms); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timer); - reject( - error instanceof Error ? error : new Error("Payment relay failed"), - ); - }, - ); - }); -} - -async function attachExistingPaymentFrames( - connection: CdpConnection, - sessions: Map, - enable: (session: Session) => Promise, -): Promise { - const listed = await connection - .send("Target.getTargets") - .catch(() => undefined); - for (const info of parseTargetInfos(listed)) { - if (info.type !== "iframe") continue; - const known = [...sessions.values()].some( - (session) => session.targetId === info.targetId, - ); - if (known) continue; - const attached = await connection - .send("Target.attachToTarget", { targetId: info.targetId, flatten: true }) - .catch(() => undefined); - const sessionId = readAttachedSessionId(attached); - if (!sessionId || sessions.has(sessionId)) continue; - const session: Session = { - sessionId, - targetId: info.targetId, - type: "iframe", - url: info.url, - }; - sessions.set(sessionId, session); - await connection - .send( - "Target.setAutoAttach", - { - autoAttach: true, - waitForDebuggerOnStart: true, - flatten: true, - filter: [{ type: "iframe" }], - }, - sessionId, - ) - .catch(() => {}); - await enable(session); - } -} - function parseTargetInfos( value: unknown, ): { targetId: string; type: string; url: string }[] { @@ -1217,10 +1196,6 @@ function assertAttachOptions(options: AttachOptions): void { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveCdpUrl(target: InterceptorTarget): string { if (typeof target === "string") { if (!target) throw new Error("A CDP websocket URL is required"); diff --git a/packages/browser-pay/src/logger.ts b/packages/browser-pay/src/logger.ts index 65694860f..71b49ca46 100644 --- a/packages/browser-pay/src/logger.ts +++ b/packages/browser-pay/src/logger.ts @@ -1,8 +1,4 @@ export interface PayLogger { - info( - message: string, - fields?: Readonly>, - ): void; warn( message: string, fields?: Readonly>, @@ -10,6 +6,5 @@ export interface PayLogger { } export const silentLogger: PayLogger = { - info() {}, warn() {}, }; diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 5396b6400..0f52a77f3 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -100,7 +100,6 @@ describe("httpPaymentRelay", () => { baseUrl: origin(server), apiKey: "test-key", transactionId: "tx_fixture_shopify", - timeoutMs: 1000, }); await expect(relay.relay(REQUEST)).rejects.toThrow( "Payment relay failed (HTTP 500)", @@ -157,7 +156,7 @@ describe("httpPaymentRelay", () => { } }); - it("aborts when the caller signal fires before timeoutMs", async () => { + it("aborts when the caller signal fires", async () => { let arrived: () => void = () => {}; const requestArrived = new Promise((resolve) => { arrived = resolve; @@ -172,7 +171,6 @@ describe("httpPaymentRelay", () => { baseUrl: origin(server), apiKey: "test-key", transactionId: "tx_fixture_shopify", - timeoutMs: 30_000, }); const pending = relay.relay(REQUEST, { signal: controller.signal }); await requestArrived; @@ -187,30 +185,6 @@ describe("httpPaymentRelay", () => { } }); - it("times out on its own timeoutMs while the caller signal stays open", async () => { - const server = await listen(() => { - // Never respond. - }); - const controller = new AbortController(); - try { - const relay = httpPaymentRelay({ - baseUrl: origin(server), - apiKey: "test-key", - transactionId: "tx_fixture_shopify", - timeoutMs: 50, - }); - const started = Date.now(); - await expect( - relay.relay(REQUEST, { signal: controller.signal }), - ).rejects.toThrow("Payment relay timed out"); - expect(Date.now() - started).toBeLessThan(2_000); - expect(controller.signal.aborted).toBe(false); - } 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" } }, @@ -262,28 +236,12 @@ describe("httpPaymentRelay", () => { } }); - it("times out when headers arrive and the body stays open", async () => { - const relay = httpPaymentRelay({ - baseUrl: "https://relay.example", - apiKey: "test-key", - transactionId: "tx_fixture_shopify", - timeoutMs: 50, - fetchImpl: stalledBody, - }); - const started = Date.now(); - await expect(relay.relay(REQUEST)).rejects.toThrow( - "Payment relay timed out", - ); - expect(Date.now() - started).toBeLessThan(2_000); - }); - 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", - timeoutMs: 30_000, fetchImpl: stalledBody, }); const pending = relay.relay(REQUEST, { signal: controller.signal }); diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index 49986465e..697aa6c75 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -4,8 +4,6 @@ import { type RelayResponse, } from "./contract.js"; -export type { RelayRequest, RelayResponse }; - export interface RelayCallOptions { signal?: AbortSignal; } @@ -21,11 +19,28 @@ export interface HttpRelayOptions { baseUrl: string; apiKey?: string; transactionId: string; - timeoutMs?: number; fetchImpl?: typeof fetch; } -const DEFAULT_TIMEOUT_MS = 20_000; +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; @@ -63,7 +78,6 @@ function requireHttpOptions(options: HttpRelayOptions): { baseUrl: string; apiKey: string; transactionId: string; - timeoutMs: number; fetchImpl: typeof fetch; } { if (!options || typeof options.baseUrl !== "string" || !options.baseUrl) { @@ -79,37 +93,22 @@ function requireHttpOptions(options: HttpRelayOptions): { "SAPIOM_API_KEY is required for the payment relay HTTP client", ); } - if ( - options.timeoutMs !== undefined && - (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) - ) { - throw new Error("Payment relay timeoutMs must be a positive number"); - } return { baseUrl: options.baseUrl.replace(/\/$/, ""), apiKey, transactionId: options.transactionId, - timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchImpl: options.fetchImpl ?? fetch, }; } -function combinedSignal( - timeoutMs: number, - caller?: AbortSignal, -): { signal: AbortSignal; dispose: () => void } { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const onAbort = () => controller.abort(); - if (caller?.aborted) controller.abort(); - else caller?.addEventListener("abort", onAbort); - return { - signal: controller.signal, - dispose() { - clearTimeout(timer); - caller?.removeEventListener("abort", onAbort); - }, - }; +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 { @@ -117,14 +116,9 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { return { async relay(request, call) { const url = `${config.baseUrl}/v1/transactions/${encodeURIComponent(config.transactionId)}/payment/relay`; - const { signal, dispose } = combinedSignal( - config.timeoutMs, - call?.signal, - ); - let text: string; - let status = 0; + let response: Response; try { - const response = await config.fetchImpl(url, { + response = await config.fetchImpl(url, { method: "POST", redirect: "manual", headers: { @@ -133,26 +127,23 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { "x-api-key": config.apiKey, }, body: JSON.stringify(request), - signal, + signal: call?.signal, }); - status = response.status; - if (status >= 300 && status < 400) { + } 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) { - if ( - error instanceof Error && - error.message === "Payment relay refused a redirect" - ) { - throw error; - } - if (signal.aborted || isTimeout(error)) { - throw new Error("Payment relay timed out"); - } - throw new Error("Payment relay failed"); - } finally { - dispose(); + throw transportError(error, call?.signal); } let parsed: unknown; try { @@ -167,28 +158,7 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { // The thrown error must not include the response text. } } - throw new Error(`Payment relay failed (HTTP ${status})`); - }, - }; -} - -export function stubPaymentRelay( - handler: ( - request: RelayRequest, - options?: RelayCallOptions, - ) => RelayResponse | Promise, -): PaymentRelay { - return { - relay(request, options) { - return Promise.resolve(handler(request, options)); + throw new Error(`Payment relay failed (HTTP ${response.status})`); }, }; } - -function isTimeout(error: unknown): boolean { - if (!error || typeof error !== "object") return false; - const name = "name" in error ? String(error.name) : ""; - const message = "message" in error ? String(error.message) : ""; - if (name === "TimeoutError" || name === "AbortError") return true; - return /timeout/i.test(message); -} 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 index 6fb75ac16..8a3e9cb88 100644 --- a/packages/browser-pay/tsconfig.build.json +++ b/packages/browser-pay/tsconfig.build.json @@ -1,8 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "composite": true + "composite": true, + "rootDir": "./src" }, + "include": ["src/**/*.ts", "src/**/*.json"], "exclude": [ "node_modules", "dist", diff --git a/packages/browser-pay/tsconfig.cjs.json b/packages/browser-pay/tsconfig.cjs.json index 2af2a9e3b..134d607e8 100644 --- a/packages/browser-pay/tsconfig.cjs.json +++ b/packages/browser-pay/tsconfig.cjs.json @@ -3,8 +3,10 @@ "compilerOptions": { "module": "commonjs", "outDir": "./dist/cjs", + "rootDir": "./src", "composite": true }, + "include": ["src/**/*.ts", "src/**/*.json"], "exclude": [ "node_modules", "dist", diff --git a/packages/browser-pay/tsconfig.esm.json b/packages/browser-pay/tsconfig.esm.json index cabfe5714..926f20c4a 100644 --- a/packages/browser-pay/tsconfig.esm.json +++ b/packages/browser-pay/tsconfig.esm.json @@ -3,8 +3,10 @@ "compilerOptions": { "module": "ES2020", "outDir": "./dist/esm", + "rootDir": "./src", "composite": true }, + "include": ["src/**/*.ts", "src/**/*.json"], "exclude": [ "node_modules", "dist", diff --git a/packages/browser-pay/tsconfig.examples.json b/packages/browser-pay/tsconfig.examples.json index e201dd2d1..32b3b6b34 100644 --- a/packages/browser-pay/tsconfig.examples.json +++ b/packages/browser-pay/tsconfig.examples.json @@ -5,6 +5,6 @@ "rootDir": ".", "types": ["node", "jest"] }, - "include": ["src/**/*.ts", "examples/**/*.ts"], + "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 index a4352dcf6..68c310351 100644 --- a/packages/browser-pay/tsconfig.json +++ b/packages/browser-pay/tsconfig.json @@ -2,9 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", "types": ["node", "jest"] }, - "include": ["src/**/*.ts", "src/**/*.json"], + "include": ["src/**/*.ts", "src/**/*.json", "test/**/*.ts"], "exclude": ["node_modules", "dist"] } From 48d3c8586bf81fe68ad5e008024883f5b4f3385f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 02:31:35 +0000 Subject: [PATCH 26/26] fix(browser-pay): read text multipart bodies and close a failed attach Text-only multipart bodies are read with Network.getRequestPostData; a filename part still fails closed. Fulfill and fail stay in the inflight map until CDP settles, so a duplicate pause cannot relay twice. The startup event buffer stops after start, and a failed attach closes the CDP socket. Co-authored-by: Rimmely Ewan --- .../src/interceptor.protocol.test.ts | 293 ++++++++++++++++-- packages/browser-pay/src/interceptor.ts | 41 ++- 2 files changed, 302 insertions(+), 32 deletions(-) diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 376004c46..9b4291787 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -28,6 +28,7 @@ interface Command { } interface StubOptions { + failAutoAttach?: boolean; failFetch?: boolean; failFulfill?: boolean; holdFulfill?: boolean; @@ -52,11 +53,13 @@ function serverFrame(text: string): Buffer { 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; @@ -114,6 +117,10 @@ async function startStub(options: StubOptions = {}): Promise<{ 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; @@ -177,7 +184,10 @@ async function startStub(options: StubOptions = {}): Promise<{ } payload = unmasked; } - if ((header[0] & 0x0f) === 0x8) return; + if ((header[0] & 0x0f) === 0x8) { + sawClose = true; + return; + } if ((header[0] & 0x0f) !== 0x1) continue; onMessage(payload.toString("utf8")); } @@ -210,6 +220,7 @@ async function startStub(options: StubOptions = {}): Promise<{ return { url: `ws://127.0.0.1:${address.port}`, commands, + sawClose: () => sawClose, emit(event) { socket?.write(serverFrame(JSON.stringify(event))); }, @@ -235,6 +246,26 @@ async function waitFor(predicate: () => boolean, label: string): Promise { } } +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", @@ -270,6 +301,22 @@ function header( } 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[] = []; @@ -379,10 +426,10 @@ describe("interceptor CDP decisions", () => { } }); - it("fails closed when a multipart body would omit file bytes", async () => { - const stub = await startStub({ - postData: `--b\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n`, - }); + 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 { @@ -401,22 +448,87 @@ describe("interceptor CDP decisions", () => { stub.commands.some((command) => command.method === "Fetch.enable"), "Fetch.enable", ); - stub.emit({ - method: "Fetch.requestPaused", - sessionId: "page-1", - params: { - requestId: "req-file", - networkId: "net-file", - request: { - url: "https://checkout.example/sessions", - method: "POST", - headers: { - "content-type": "multipart/form-data; boundary=b", - }, - hasPostData: true, - }, - }, + 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", @@ -434,7 +546,7 @@ describe("interceptor CDP decisions", () => { stub.commands.some( (command) => command.method === "Network.getRequestPostData", ), - ).toBe(false); + ).toBe(true); await handle.detach(); } finally { await stub.close(); @@ -1453,6 +1565,145 @@ describe("interceptor CDP decisions", () => { } }); + 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 { diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 36ced8640..1f73d4721 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -201,7 +201,12 @@ export async function attachInterceptor( ): Promise { assertAttachOptions(options); const connection = await CdpConnection.connect(resolveCdpUrl(target)); - return new PaymentInterceptor(connection, options).start(); + try { + return await new PaymentInterceptor(connection, options).start(); + } catch (error) { + connection.close(); + throw error; + } } class PaymentInterceptor { @@ -217,6 +222,7 @@ class PaymentInterceptor { private readonly timeoutMs: number; private readonly logger: PayLogger; private backlog: InterceptorEvent[] | null = []; + private buffering = true; private closed = false; constructor( @@ -244,6 +250,7 @@ class PaymentInterceptor { .catch(() => {}); await this.attachExistingFrames(); await Promise.all([...this.pendingArms]); + this.buffering = false; return { on: (listener) => this.subscribe(listener), detach: () => this.detach(), @@ -656,12 +663,11 @@ class PaymentInterceptor { return true; } entry.phase = { status: "done" }; - this.inflight.delete(entry.key); - if (action === "fail") { - await failRequest(this.connection, entry.sessionId, entry.requestId); - return true; - } try { + if (action === "fail") { + await failRequest(this.connection, entry.sessionId, entry.requestId); + return true; + } await fulfillRequest( this.connection, entry.sessionId, @@ -672,6 +678,8 @@ class PaymentInterceptor { } catch { await failRequest(this.connection, entry.sessionId, entry.requestId); return false; + } finally { + this.inflight.delete(entry.key); } } @@ -717,6 +725,8 @@ class PaymentInterceptor { 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()) { @@ -750,7 +760,7 @@ class PaymentInterceptor { } private emit(event: InterceptorEvent): void { - if (this.backlog) { + if (this.buffering && this.backlog) { this.backlog.push(event); return; } @@ -829,7 +839,7 @@ async function readBody( ), ).toString("utf8"); } - if (!request.networkId || isMultipart(request.headers)) return undefined; + if (!request.networkId) return undefined; try { const result = await connection.send( "Network.getRequestPostData", @@ -838,10 +848,14 @@ async function readBody( ); const postData = readStringField(result, "postData"); if (!postData) return undefined; - if (isRecord(result) && result.base64Encoded === true) { - return Buffer.from(postData, "base64").toString("utf8"); + const body = + isRecord(result) && result.base64Encoded === true + ? Buffer.from(postData, "base64").toString("utf8") + : postData; + if (isMultipart(request.headers) && multipartOmitsFileBytes(body)) { + return undefined; } - return postData; + return body; } catch { return undefined; } @@ -1086,6 +1100,11 @@ function isMultipart(headers: unknown): boolean { .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,