Skip to content

feat(browser-pay): pause placeholder card requests over CDP - #1237

Merged
ratataque merged 26 commits into
mainfrom
cursor/browser-pay-interceptor-db10
Oct 18, 2026
Merged

ratataque merged 26 commits into
mainfrom
cursor/browser-pay-interceptor-db10

Conversation

@ratataque

@ratataque ratataque commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Primary change type

  • Bug fix
  • Documentation
  • Feature
  • Tests
  • Dependency update
  • Maintenance or refactor

Problem and motivation

An agent paying in its own browser types a governance placeholder. That number must not be delivered to the payment provider. The browser has to pause the matching request, including inside a payment iframe, hand it to David's relay, and fulfill the page with the processor response. A refusal, an unknown outcome, or a relay error has to fail the paused request. The same body is not sent again after outcome: "unknown".

Shopify's card iframe retries the same POST. The idempotency key has to stay stable across those retries, and a refusal should be logged once.

Summary and scope

What: @sapiom/browser-pay pauses a held placeholder over CDP and posts David's relay body to POST /v1/transactions/{id}/payment/relay.

Why: the package had invented a wire shape (kind, tuple headers, bodyEncoding, merchantOrigin, transactionId in the JSON, an idempotency-key header, and its own refusal strings). David's controller is the source of truth. A Luhn pre-filter and pauseOnlyUrls could also drop or continue a request the backend should see.

How to review: start with packages/browser-pay/README.md, then src/contract.ts and src/relay.ts (wire format), then src/interceptor.ts (match, iframe attach, fulfill or fail). examples/attach.ts is the agent script and is not published.

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. There is no Luhn gate. Expiry and CVC are not compared. The backend checkPlaceholderUse decides validity. A relayed response fails the paused request only when that response contains the held number.

profileId is a required attachInterceptor option and is sent on the body. transactionId is only a path parameter on httpPaymentRelay.

The idempotency key is sha256 of method, URL, and body, separated by NUL. It is a body field, not a header. Identical in-flight retries share one relay call. An earlier waiter's timeout does not abort a later waiter. A refusal is logged once. A transport failure drops the key so a later retry can resend. outcome: "unknown" is stored and a later identical pause fails without calling the relay again.

The client sends only accept, content-type, and shopify-identification-signature, lowercased, and drops a value longer than 8192 characters. Cookie and Sec-Fetch-* are not synthesized.

Page and iframe targets auto-attach with waitForDebuggerOnStart. Fetch.enable runs on each child session before it resumes, for every URL. Captcha widgets, extension pages, and workers are not paused. An iframe that already exists is attached before attachInterceptor resolves. A paused request whose CDP session was never attached is failed.

The page is fulfilled with the processor status, headers, and string body. Hop-by-hop headers, content-length, and content-encoding are dropped because CDP sends the body bytes itself. A refusal, outcome: "unknown", a relay error, a timeout, or a failed fulfill fails the paused request so the placeholder is not continued. detach() fails in-flight pauses before it disables Fetch. If this process crashes or the CDP socket drops first, Chrome can still continue those requests.

Out of scope: WebSocket frames, client-side-encrypted card payloads, and worklet targets. This package does not call a processor and does not place an order.

Integration notes for David

httpPaymentRelay sends POST {baseUrl}/v1/transactions/{transactionId}/payment/relay with accept, content-type, and x-api-key. The JSON body is:

{
  "injectionRef": "inj_…",
  "profileId": "shopify-card-vault",
  "pageOrigin": "https://shop.example",
  "request": {
    "method": "POST",
    "url": "https://checkout.pci.shopifyinc.com/sessions",
    "headers": { "content-type": "application/json" },
    "body": "<paused request body>"
  },
  "idempotencyKey": "<sha256 hex>"
}

headers is Record<string, string>. Do not log or persist request.headers or request.body.

Success:

{
  "outcome": "relayed",
  "response": {
    "status": 200,
    "headers": { "content-type": "application/json" },
    "body": "<processor body>"
  }
}

response.body is a string. There is no bodyEncoding.

Refusal:

{ "outcome": "refused", "reason": "already_relayed" }

The reason string is preserved as sent, including a reason this package has not listed. Reasons we expect: 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, provider_unavailable.

No answer after the send:

{ "outcome": "unknown" }

The paused request fails and that idempotency key is not sent again. sapiom/Sapiom was not readable from this environment (HTTP 404), so these field names follow the relay controller contract given for #5792, #5798, and #5799.

Related work

Related issue or discussion: SAP-4071. David's relay controller is sapiom/Sapiom #5792, #5798, and #5799.

Validation

At f23838aa.

cd packages/browser-pay
pnpm exec prettier --check "src/**/*.ts" "examples/**/*.ts" — All matched files use Prettier code style
pnpm exec eslint src examples --ext .ts — pass, 0 problems
pnpm exec tsc --noEmit && pnpm exec tsc --noEmit -p tsconfig.examples.json — pass
pnpm exec jest --detectOpenHandles --testTimeout 30000 — 5 suites, 43 tests passed. The Chrome suite `attachInterceptor on headless Chromium` ran 5 tests (not skipped) against /usr/local/bin/google-chrome
pnpm run build — pass
npm pack --dry-run — @sapiom/browser-pay@0.0.0, 69 files, 44.0 kB. The test helper is not in the tarball

Tests and documentation

Tests go through attachInterceptor and httpPaymentRelay. They cover a placeholder in the body or URL, grouped and encoded digits, a non-Luhn number still relayed, a foreign PAN fulfilled, a held number in the relayed body failing the page, refusal reason passthrough, outcome: "unknown" not retried, in-flight retry dedupe, a relay that ignores abort still failing on timeout, a shared flight whose earlier timeout does not abort the later waiter, split UTF-8 postDataEntries, a CDP close frame rejecting later sends, and captcha iframes left off Fetch. New cases cover an arm failure delivered to the first subscriber, detach during fulfill sending no second Fetch.failRequest, a 401 or 500 body that looks like an outcome rejected as a transport failure, and an about:blank iframe that stays armed across targetInfoChanged. Chrome integration runs when google-chrome, Chromium, or CHROME_PATH is present, and otherwise skips with browser-pay: skipping Chrome integration tests. Install Google Chrome or Chromium, or set CHROME_PATH. README is the integrator guide. Changeset is .changeset/browser-pay-interceptor.md.

Compatibility and release impact

  • Breaking or externally visible changes: @sapiom/browser-pay is still unpublished (0.0.0). RelayResponse is { outcome: "relayed" | "refused" | "unknown" }. RelayRequest is injectionRef, profileId, pageOrigin, request, idempotencyKey. profileId is required on attachInterceptor. transactionId is only on httpPaymentRelay. pauseOnlyUrls is removed. The public export is attachInterceptor, httpPaymentRelay, and the types AttachOptions, FailureReason, InterceptorEvent, InterceptorHandle, InterceptorTarget, HttpRelayOptions, PaymentRelay, RelayCallOptions, RelayRequest, RelayResponse, HeldPlaceholder, and PayLogger. HeldPlaceholder is { number, injectionRef }. PayLogger has warn only. HttpRelayOptions has no timeoutMs. AttachOptions.timeoutMs is the relay deadline. failed.reason is FailureReason. A match seen after detach is detached.
  • Changeset: Added .changeset/browser-pay-interceptor.md (@sapiom/browser-pay minor).

Security

  • I have not included secrets, credentials, private data, or unsanitized logs.
  • This pull request does not publicly disclose a suspected vulnerability. I
    will follow the
    Security Policy for
    private reporting.

AI assistance

  • I did not use AI assistance for this change.
  • I used AI assistance and have described it below.

Cursor cloud agent implemented the interceptor, the relay client, and the tests, then aligned the wire format with David's relay controller. A later pass fixed attach-time event loss, double settle on fulfill, non-2xx outcome bodies, and the blank-iframe arm predicate. Verification was Prettier, eslint, tsc (package and examples), jest (43 passed, including 5 Chrome tests), the package build, and npm pack --dry-run in packages/browser-pay. No live card was used.

Checklist

  • I read CONTRIBUTING.md, and this contribution follows the direct-PR or issue-first policy.
  • This pull request addresses one focused problem and contains no unrelated cleanup.
  • I added or updated tests, or explained above why tests are not applicable.
  • I ran the relevant build, typecheck, lint, and test commands, or explained
    any N/A checks above.
  • I updated documentation for user-facing changes, or marked it N/A above.
  • I added a Changeset for a published-package change, or explained why it is not applicable.
  • I can explain and maintain every submitted change, including any AI-assisted work.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added browser payment interception that detects held placeholder card details in browser requests and securely relays matching payments for authorization.
    • Added configurable relay and event logging, with support for page and iframe requests.
    • Added setup documentation and an example for attaching the interceptor to a managed browser session.
  • Documentation
    • Added package usage, relay behavior, and integration guidance.

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 <ratataque@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Adds @sapiom/browser-pay, a package that detects held placeholder numbers in browser requests, sends matching requests through an HTTP relay, and fulfills or fails paused requests based on relay outcomes. It also adds CDP transport, tests, package configuration, documentation, and an example.

Changes: Browser Payment Interception

Layer / File(s) Summary
Relay and placeholder contracts
packages/browser-pay/src/contract.ts, packages/browser-pay/src/detect.ts, packages/browser-pay/src/guards.ts, packages/browser-pay/src/logger.ts, packages/browser-pay/src/index.ts, packages/browser-pay/src/detect.test.ts
Defines relay request and response types, idempotency-key generation, response validation, placeholder matching and redaction, logger types, and package exports.
CDP WebSocket transport
packages/browser-pay/src/cdp.ts, packages/browser-pay/src/cdp.test.ts
Adds CDP WebSocket connection handling, frame parsing, command-response matching, event dispatch, and rejection of pending calls on errors, timeouts, or closure.
HTTP payment relay
packages/browser-pay/src/relay.ts, packages/browser-pay/src/relay.test.ts, packages/browser-pay/test/stub-relay.ts
Adds an HTTP relay with URL validation, request construction, abort handling, and relay response validation. Adds relay tests and a stub relay factory.
Request matching and interception
packages/browser-pay/src/interceptor.ts, packages/browser-pay/src/interceptor.protocol.test.ts, packages/browser-pay/src/interceptor.integration.test.ts
Attaches to eligible CDP targets, detects matching requests, shares in-flight relay calls, and fulfills or fails paused requests based on relay outcomes. Tests cover protocol behavior and browser interactions.
Package publication and repository setup
packages/browser-pay/package.json, packages/browser-pay/tsconfig*.json, packages/browser-pay/jest.config.js, packages/browser-pay/.eslintrc.js, packages/browser-pay/LICENSE, packages/browser-pay/CHANGELOG.md, .changeset/browser-pay-interceptor.md, .github/labeler.yml, README.md
Adds package metadata, build and test configuration, license and release notes, SDK labeling, and the root README entry.
Usage documentation and example
packages/browser-pay/README.md, packages/browser-pay/examples/attach.ts
Documents request matching, relay outcomes, timeout and detach behavior, and adds a managed-browser attachment example.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant CdpConnection
  participant PaymentInterceptor
  participant PaymentRelay
  Browser->>CdpConnection: Emit Fetch.requestPaused
  CdpConnection->>PaymentInterceptor: Dispatch CDP event
  PaymentInterceptor->>PaymentRelay: Relay matching request
  PaymentRelay-->>PaymentInterceptor: Return relay outcome
  PaymentInterceptor->>CdpConnection: Send fulfill or fail command
  CdpConnection->>Browser: Settle paused request
Loading
Merge Risk: 🔵 Low · up to f2383

The interceptor behaves as intended. However, if browser setup fails during attachment, the browser connection stays open and can keep the Node process alive. Closing the connection on that failure path is a small follow-up, and the change can merge with that in mind.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 9.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 83 functions across 24 files. (7 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: pausing placeholder card requests through CDP.
Description check Passed The description is complete and follows the repository template. It identifies the feature, motivation, scope, related work, validation commands, tests, compatibility and release impact, security stat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is complete and follows the repository template. It identifies the feature, motivation, scope, related work, validation commands, tests, compatibility and release impact, security status, AI assistance, and checklist items.

Full details: Docstring Coverage

Explanation

Docstring coverage is 9.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 83 functions across 24 files. (7 skipped: 7 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit watched the requests pause,
Then sent held numbers through relay’s doors.
The browser received a response or fail,
While tests traced each step of the trail.
“Hop safely,” said the rabbit, “and log no card!”

Comment @coderabbitai help to get the list of available commands.

github-advanced-security[bot]

This comment was marked as resolved.

cursoragent and others added 16 commits October 16, 2026 01:16
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
…rget

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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
The shop portal modal sits on the pay button and swallows the pointer
click. Submit that button once directly.

Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
@ratataque
ratataque marked this pull request as ready for review October 16, 2026 20:41
Copilot AI balanced review requested due to automatic review settings October 16, 2026 20:41
devin-ai-integration[bot]

This comment was marked as resolved.

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

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 <ratataque@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

cursoragent and others added 2 commits October 16, 2026 22:24
…eholders

Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

cursoragent and others added 2 commits October 16, 2026 23:46
Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

…t bodies

Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

cursoragent and others added 2 commits October 17, 2026 01:29
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 <ratataque@users.noreply.github.com>
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 <ratataque@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

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 <ratataque@users.noreply.github.com>
@cursor

cursor Bot commented Oct 17, 2026

Copy link
Copy Markdown

Docstring coverage stays low on purpose: this package keeps comments minimal, so the 80% docstring check is not a change to make.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +1104 to +1105
function multipartOmitsFileBytes(body: string): boolean {
return /content-disposition:[^\r\n]*[;\s]filename\s*\*?=/i.test(body);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Multipart text fields mistaken for uploads

When a multipart text field contains Content-Disposition: ...; filename=, multipartOmitsFileBytes mistakes its value for a file header. The complete request then fails as unreadable instead of continuing or reaching the relay.

Learn more

A multipart request can contain arbitrary text field values. The detector scans every line of the body, so text that resembles a file-part header triggers the missing-file check in readBody. The interceptor then returns unreadable_body without checking whether the held number was present.

Example: A text-only form with a note field containing Content-Disposition: form-data; filename="receipt.txt" has no file bytes to omit. Its retrieved body nevertheless matches the regex, so the browser request fails instead of continuing.

Recommended fix: Parse the multipart boundary from Content-Type and inspect only the header section of each part for a filename parameter. Keep fail-closed behavior when a real file part is present, and test header-like text inside a normal field.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@ratataque
ratataque requested a review from xoxo-david October 17, 2026 02:43
@ratataque
ratataque merged commit 9883adf into main Oct 18, 2026
15 checks passed
@ratataque
ratataque deleted the cursor/browser-pay-interceptor-db10 branch October 18, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants