Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
66fb760
feat(browser-pay): pause placeholder card requests over CDP
cursoragent Oct 16, 2026
bc59eed
fix(browser-pay): relay the Shopify Payments test card
cursoragent Oct 16, 2026
d4c2871
fix(browser-pay): check a real address before the single pay click
cursoragent Oct 16, 2026
21c9cea
fix(browser-pay): enable Anchor's solver and stop pausing it
cursoragent Oct 16, 2026
426ba98
fix(browser-pay): let the solver finish before retrying a card field
cursoragent Oct 16, 2026
74c55fe
fix(browser-pay): continue unrelated GETs and keep one session per ta…
cursoragent Oct 16, 2026
3108b08
fix(browser-pay): accept the checkout address suggestion before pay
cursoragent Oct 16, 2026
cf22532
fix(browser-pay): attach the interceptor after hCaptcha clears
cursoragent Oct 16, 2026
9037e09
fix(browser-pay): accept the suggested zip without the Shop Pay overlay
cursoragent Oct 16, 2026
4657d0f
fix(browser-pay): let the solver finish before the address suggestion
cursoragent Oct 16, 2026
0300709
fix(browser-pay): do not hold blank frames while Fetch is enabled
cursoragent Oct 16, 2026
fc6cc4b
fix(browser-pay): request the captcha session with the popup blocker off
cursoragent Oct 16, 2026
e6582ab
fix(browser-pay): do not pause new frames for the debugger
cursoragent Oct 16, 2026
687d75b
fix(browser-pay): click Pay through the Shop Pay overlay
cursoragent Oct 16, 2026
365de79
fix(browser-pay): pause only the card iframe during checkout
cursoragent Oct 16, 2026
48072f4
fix(browser-pay): fail closed when a placeholder is easy to miss
cursoragent Oct 16, 2026
fe7a987
fix(browser-pay): relay the held placeholder without continuing it
cursoragent Oct 16, 2026
b7f360f
fix(browser-pay): speak David's relay contract
cursoragent Oct 16, 2026
7edc302
fix(browser-pay): time out a stalled relay body and mask encoded plac…
cursoragent Oct 16, 2026
79ff05d
fix(browser-pay): reject a non-https relay base URL
cursoragent Oct 16, 2026
0c5d748
fix(browser-pay): read omitted post data and repeated separators
cursoragent Oct 16, 2026
3733ad1
fix(browser-pay): reject a relay base URL that contains a fragment
cursoragent Oct 16, 2026
194b4f4
fix(browser-pay): reject relay URL delimiters and incomplete multipar…
cursoragent Oct 17, 2026
6385e74
test(browser-pay): reproduce four interceptor and relay bugs
cursoragent Oct 17, 2026
f23838a
fix(browser-pay): settle once and fail closed on non-2xx relays
cursoragent Oct 17, 2026
48d3c85
fix(browser-pay): read text multipart bodies and close a failed attach
cursoragent Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/browser-pay-interceptor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/browser-pay": minor
---

Add `@sapiom/browser-pay`. It pauses a held placeholder over CDP and posts David's relay body to `POST /v1/transactions/{id}/payment/relay`. The idempotency key is sha256 of method, URL, and body.

The placeholder is `{ number, injectionRef }`. `PayLogger` only has `warn`. `failed.reason` is a closed set, and a match seen after detach is `detached`. `HttpRelayOptions` has no `timeoutMs`. `AttachOptions.timeoutMs` is the relay deadline. A non-2xx relay HTTP status is a transport failure and is not read as an outcome.
1 change: 1 addition & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- any-glob-to-any-file:
- packages/core/**
- packages/analytics-core/**
- packages/browser-pay/**

"area: integrations":
- changed-files:
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions packages/browser-pay/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -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: "^_" }],
},
};
5 changes: 5 additions & 0 deletions packages/browser-pay/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @sapiom/browser-pay

## 0.0.0

Initial package. The first published version is cut by Changesets. Pauses a held placeholder over CDP and relays it to `POST /v1/transactions/{id}/payment/relay`.
21 changes: 21 additions & 0 deletions packages/browser-pay/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions packages/browser-pay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# @sapiom/browser-pay

Browser-side payment interceptor. The agent types a governance placeholder into checkout. This package pauses that request over CDP and hands it to David's relay. The browser is fulfilled with the processor's response, or the request fails. The placeholder is never continued.

## Sequence

1. The agent creates a checkout (UCP `create_checkout`).
2. Governance arms a placeholder and returns it with `continue_url`.
3. The agent opens a browser session and drives checkout, typing the placeholder.
4. `attachInterceptor` pauses the card request, including inside a payment iframe.
5. A match is `POST /v1/transactions/{transactionId}/payment/relay`.
6. The page receives the processor status, headers, and body. A refusal, an unknown outcome, or a relay error fails the paused request.

## Relay contract

`POST /v1/transactions/{transactionId}/payment/relay` with `x-api-key`. `transactionId` is only in the path. The JSON body is:

```json
{
"injectionRef": "inj_…",
"profileId": "shopify-card-vault",
"pageOrigin": "https://shop.example",
"request": {
"method": "POST",
"url": "https://checkout.pci.shopifyinc.com/sessions",
"headers": { "content-type": "application/json" },
"body": "<paused request body, placeholder included>"
},
"idempotencyKey": "<sha256 hex of method, url, and 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
{
"outcome": "relayed",
"response": {
"status": 200,
"headers": { "content-type": "application/json" },
"body": "<processor body>"
}
}
```

`response.body` is the string the page should read. `response.headers` is a string map. Hop-by-hop headers are dropped because CDP sends the body bytes itself.

Refusal, nothing forwarded:

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

The reason string is passed through. Reasons David returns include `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `unknown_injection`, `injection_expired`, `payment_released`, `relay_in_progress`, `payment_not_authorized`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`.

No answer after the send:

```json
{ "outcome": "unknown" }
```

The injection is consumed. The paused request fails and the same body is not sent again.

A transport error, a redirect, a non-2xx HTTP status, or an unreadable body fails the paused browser request. David's relay returns HTTP 200 for every outcome, so a 401 or 500 is not read as `unknown` or `refused`. It is not fulfilled with the error JSON. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`.

## What an agent does

```typescript
import { attachInterceptor, httpPaymentRelay } from "@sapiom/browser-pay";

const handle = await attachInterceptor(cdpUrl, {
relay: httpPaymentRelay({
baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai",
apiKey: process.env.SAPIOM_API_KEY,
transactionId,
}),
profileId: "shopify-card-vault",
placeholder,
});
```

Call `handle.on` after `attachInterceptor` resolves. Events emitted while attach was running, including a target that could not arm Fetch, are delivered to that first subscriber.

A request is relayed when the held placeholder number appears in the URL or body. An exact match counts, and so do digits split by spaces or dashes, including the URL-encoded and JSON-escaped forms of those separators. `placeholder` is `{ number, injectionRef }`. The backend's `checkPlaceholderUse` decides whether the number is valid. A paused request that does not hold the placeholder is continued.

A relayed response fails the paused request only when that response contains the held placeholder number.

Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the only deadline on that call. It defaults to 20 seconds. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not.

Captcha widgets, extension pages, and workers are not paused. A blank iframe, including `about:blank`, stays paused so a navigation onto the card URL cannot slip through. A placeholder that shows up on any other page or iframe is relayed.

Child iframes are auto-attached with `waitForDebuggerOnStart`. `Fetch.enable` runs on each child session before it is resumed. An iframe that already exists is attached before `attachInterceptor` resolves. A paused request whose CDP session was never attached is failed.

`detach()` fails in-flight paused requests before it disables Fetch. A process crash or a dropped CDP socket can still let Chrome continue those requests. WebSocket frames, client-side-encrypted card payloads, and worklet targets are not visible to this check.

`examples/attach.ts` is the agent-side script. It is not published.
97 changes: 97 additions & 0 deletions packages/browser-pay/examples/attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* What an agent platform runs: open a Sapiom browser (captcha solving is the
* capability's own flag), attach the interceptor to the returned CDP URL, and
* let the agent's own driver use that same URL.
*
* A raw CDP URL works the same way: `attachInterceptor(process.env.CDP_URL, …)`.
*
* SAPIOM_API_KEY=… TRANSACTION_ID=… INJECTION_REF=… PROFILE_ID=shopify-card-vault \
* PLACEHOLDER_NUMBER=… \
* npx tsx examples/attach.ts
*/
import { randomUUID } from "node:crypto";
import { createClient } from "@sapiom/tools";
import {
attachInterceptor,
httpPaymentRelay,
type InterceptorHandle,
} from "../src/index.js";

const log = {
info(message: string, fields?: Readonly<Record<string, string | number>>) {
process.stdout.write(`${message} ${JSON.stringify(fields ?? {})}\n`);
},
warn(message: string, fields?: Readonly<Record<string, string | number>>) {
process.stderr.write(`${message} ${JSON.stringify(fields ?? {})}\n`);
},
};

async function main(): Promise<void> {
const apiKey = process.env.SAPIOM_API_KEY;
if (!apiKey) throw new Error("SAPIOM_API_KEY is required");
const transactionId = process.env.TRANSACTION_ID;
const injectionRef = process.env.INJECTION_REF;
const number = process.env.PLACEHOLDER_NUMBER;
const profileId = process.env.PROFILE_ID;
if (!transactionId || !injectionRef || !number || !profileId) {
throw new Error(
"TRANSACTION_ID, INJECTION_REF, PROFILE_ID, and PLACEHOLDER_NUMBER are required",
);
}

const sapiom = createClient({ apiKey });
const session = await sapiom.browserAutomation.sessions.createManaged({
idempotencyKey: randomUUID(),
recording: false,
captchaSolver: true,
proxy: true,
stealth: true,
headless: false,
});
let handle: InterceptorHandle | undefined;
const shutdown = async () => {
await handle?.detach()?.catch(() => {});
await sapiom.browserAutomation.sessions
.closeManaged(session.sessionId)
.catch(() => {});
await sapiom.shutdown().catch(() => {});
};
try {
handle = await attachInterceptor(session.cdpUrl, {
relay: httpPaymentRelay({
baseUrl: process.env.SAPIOM_BASE_URL ?? "https://api.sapiom.ai",
apiKey,
transactionId,
}),
profileId,
placeholder: {
number,
injectionRef,
},
logger: log,
});
const last4 = number.replace(/\D/g, "").slice(-4);
log.info("interceptor attached", { last4 });
handle.on((event) => {
if (event.type === "relayed")
log.info("relayed", { status: event.status });
else if (event.type === "refused") {
log.warn("refused", { reason: event.reason });
} else if (event.type === "failed") {
log.warn("failed", { reason: event.reason });
}
});
process.on("SIGINT", () => {
void shutdown().finally(() => process.exit(0));
});
} catch (error) {
await shutdown();
throw error;
}
}

main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : "attach failed";
process.stderr.write(`${message}\n`);
process.exit(1);
});
20 changes: 20 additions & 0 deletions packages/browser-pay/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module.exports = {
testEnvironment: "node",
roots: ["<rootDir>/src", "<rootDir>/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",
],
};
76 changes: 76 additions & 0 deletions packages/browser-pay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"name": "@sapiom/browser-pay",
"version": "0.0.0",
"description": "Browser-side payment interceptor. Pause a placeholder card request over CDP and relay it.",
"keywords": [
"sapiom",
"sdk",
"payments",
"cdp",
"checkout"
],
"homepage": "https://github.com/sapiom/sapiom-js/tree/main/packages/browser-pay#readme",
"bugs": {
"url": "https://github.com/sapiom/sapiom-js/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/sapiom/sapiom-js.git",
"directory": "packages/browser-pay"
},
"license": "MIT",
"author": "Sapiom",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/cjs/index.d.ts",
"exports": {
".": {
"types": "./dist/cjs/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
"files": [
"dist",
"!dist/**/*.tsbuildinfo",
"README.md",
"LICENSE",
"CHANGELOG.md"
],
"scripts": {
"build": "npm run build:cjs && npm run build:esm && npm run build:esm-pkg",
"build:cjs": "tsc --project tsconfig.cjs.json",
"build:esm": "tsc --project tsconfig.esm.json",
"build:esm-pkg": "node -e \"require('fs').writeFileSync('dist/esm/package.json',JSON.stringify({type:'module'}))\"",
"clean": "rm -rf dist *.tsbuildinfo",
"dev": "tsc --watch --project tsconfig.cjs.json",
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.examples.json",
"lint": "eslint src examples --ext .ts",
"format": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\"",
"prepublishOnly": "pnpm build"
},
"devDependencies": {
"@sapiom/tools": "workspace:*",
"@types/jest": "^29.5.14",
"@types/node": "^20.11.30",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"playwright": "^1.61.0",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"tsx": "^4.20.3",
"typescript": "^5.4.2"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public"
},
"sideEffects": false
}
Loading
Loading