Repository navigation
feat(browser-pay): pause placeholder card requests over CDP - #1237
Conversation
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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 Walkthrough
Merge Risk: 🔵 Low · up to
|
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>
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>
…eholders Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
…t bodies Co-authored-by: Rimmely Ewan <ratataque@users.noreply.github.com>
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>
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>
|
Docstring coverage stays low on purpose: this package keeps comments minimal, so the 80% docstring check is not a change to make. |
| function multipartOmitsFileBytes(body: string): boolean { | ||
| return /content-disposition:[^\r\n]*[;\s]filename\s*\*?=/i.test(body); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
Primary change type
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-paypauses a held placeholder over CDP and posts David's relay body toPOST /v1/transactions/{id}/payment/relay.Why: the package had invented a wire shape (
kind, tuple headers,bodyEncoding,merchantOrigin,transactionIdin the JSON, anidempotency-keyheader, and its own refusal strings). David's controller is the source of truth. A Luhn pre-filter andpauseOnlyUrlscould also drop or continue a request the backend should see.How to review: start with
packages/browser-pay/README.md, thensrc/contract.tsandsrc/relay.ts(wire format), thensrc/interceptor.ts(match, iframe attach, fulfill or fail).examples/attach.tsis 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
checkPlaceholderUsedecides validity. A relayed response fails the paused request only when that response contains the held number.profileIdis a requiredattachInterceptoroption and is sent on the body.transactionIdis only a path parameter onhttpPaymentRelay.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, andshopify-identification-signature, lowercased, and drops a value longer than 8192 characters. Cookie andSec-Fetch-*are not synthesized.Page and iframe targets auto-attach with
waitForDebuggerOnStart.Fetch.enableruns 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 beforeattachInterceptorresolves. 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, andcontent-encodingare 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
httpPaymentRelaysendsPOST {baseUrl}/v1/transactions/{transactionId}/payment/relaywithaccept,content-type, andx-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>" }headersisRecord<string, string>. Do not log or persistrequest.headersorrequest.body.Success:
{ "outcome": "relayed", "response": { "status": 200, "headers": { "content-type": "application/json" }, "body": "<processor body>" } }response.bodyis a string. There is nobodyEncoding.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.Tests and documentation
Tests go through
attachInterceptorandhttpPaymentRelay. 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-8postDataEntries, 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 secondFetch.failRequest, a 401 or 500 body that looks like an outcome rejected as a transport failure, and anabout:blankiframe that stays armed acrosstargetInfoChanged. Chrome integration runs whengoogle-chrome, Chromium, orCHROME_PATHis present, and otherwise skips withbrowser-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
@sapiom/browser-payis still unpublished (0.0.0).RelayResponseis{ outcome: "relayed" | "refused" | "unknown" }.RelayRequestisinjectionRef,profileId,pageOrigin,request,idempotencyKey.profileIdis required onattachInterceptor.transactionIdis only onhttpPaymentRelay.pauseOnlyUrlsis removed. The public export isattachInterceptor,httpPaymentRelay, and the typesAttachOptions,FailureReason,InterceptorEvent,InterceptorHandle,InterceptorTarget,HttpRelayOptions,PaymentRelay,RelayCallOptions,RelayRequest,RelayResponse,HeldPlaceholder, andPayLogger.HeldPlaceholderis{ number, injectionRef }.PayLoggerhaswarnonly.HttpRelayOptionshas notimeoutMs.AttachOptions.timeoutMsis the relay deadline.failed.reasonisFailureReason. A match seen after detach isdetached..changeset/browser-pay-interceptor.md(@sapiom/browser-payminor).Security
will follow the
Security Policy for
private reporting.
AI assistance
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-runinpackages/browser-pay. No live card was used.Checklist
CONTRIBUTING.md, and this contribution follows the direct-PR or issue-first policy.any N/A checks above.
Summary by CodeRabbit