From 43a4e05c14e6707d93c7623da3e69e0511b920ab Mon Sep 17 00:00:00 2001 From: Vittorio Minacori Date: Tue, 10 Mar 2026 11:21:15 +0100 Subject: [PATCH] feat: add proxy type --- .github/workflows/ci.yml | 2 +- AGENTS.md | 50 ++++++++------- README.md | 16 ++++- package-lock.json | 66 +++++++++---------- package.json | 7 +- skills/openpayment/SKILL.md | 41 ++++++++---- src/cli.ts | 21 ++---- src/index.ts | 9 ++- src/lib/buildPaymentUrl.ts | 7 +- src/lib/constants.ts | 1 + src/lib/createPayment.ts | 21 ++++-- src/lib/types.ts | 3 +- src/lib/validation.ts | 67 +++++++++++++++++++- test/cli.test.mjs | 123 +++++++++++++++++++++++++++++++++++- test/index.test.mjs | 103 +++++++++++++++++++++++++++++- 15 files changed, 430 insertions(+), 107 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf4d1f6..2c25f5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,4 +40,4 @@ jobs: uses: ./.github/actions/setup - name: Run Test - run: npm test + run: npm run test:coverage diff --git a/AGENTS.md b/AGENTS.md index 6263d3c..10fecdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,9 @@ Links are hosted at [https://openpayment.link](https://openpayment.link). ## Install ```bash -npm install openpayment # SDK -npm install -g openpayment # CLI (global) +npm install openpayment # SDK +npm install -g openpayment # CLI (global) +npx skills add https://github.com/noncept/openpayment # SKILL ``` ## CLI @@ -20,6 +21,7 @@ openpayment create \ --price "" \ --payTo "" \ --network "" \ + [--resourceUrl ""] \ --description "" ``` @@ -80,14 +82,15 @@ Example: ### CLI flags -| Flag | Required | Description | -| --------------- | -------- | ------------------------------------------- | -| `--type` | Yes | `SINGLE_USE`, `MULTI_USE`, or `VARIABLE` | -| `--price` | Yes | Amount in USDC, e.g. `10` or `0.50` | -| `--payTo` | Yes | Recipient EVM address (`0x` + 40 hex chars) | -| `--network` | Yes | CAIP-2 network ID | -| `--description` | No | Payment description, max 500 chars | -| `--json` | No | Print JSON output only | +| Flag | Required | Description | +| --------------- | ----------- | ------------------------------------------------------------------- | +| `--type` | Yes | `SINGLE_USE`, `MULTI_USE`, `VARIABLE`, or `PROXY` | +| `--price` | Yes | Amount in USDC, e.g. `10` or `0.50` | +| `--payTo` | Yes | Recipient EVM address (`0x` + 40 hex chars) | +| `--network` | Yes | CAIP-2 network ID | +| `--resourceUrl` | Conditional | Required when `--type` is `PROXY`; upstream API URL (`https://...`) | +| `--description` | No | Payment description, max 500 chars | +| `--json` | No | Print JSON output only | ## SDK @@ -110,13 +113,14 @@ The `create()` function validates input locally before making any network call a ### Input fields -| Field | Type | Required | Description | -| ------------- | ------------------ | -------- | ----------------------------------------- | -| `type` | `string` | Yes | `SINGLE_USE`, `MULTI_USE`, or `VARIABLE` | -| `price` | `string \| number` | Yes | Positive USDC amount, e.g. `"10"` or `10` | -| `payTo` | `string` | Yes | Recipient EVM address | -| `network` | `string` | Yes | CAIP-2 network ID | -| `description` | `string` | No | Payment description, max 500 chars | +| Field | Type | Required | Description | +| ------------- | ------------------ | ----------- | --------------------------------------------------------------- | +| `type` | `string` | Yes | `SINGLE_USE`, `MULTI_USE`, `VARIABLE`, or `PROXY` | +| `price` | `string \| number` | Yes | Positive USDC amount, e.g. `"10"` or `10` | +| `payTo` | `string` | Yes | Recipient EVM address | +| `network` | `string` | Yes | CAIP-2 network ID | +| `description` | `string` | No | Payment description, max 500 chars | +| `resourceUrl` | `string` | Conditional | Required when `type` is `PROXY`; must be a valid `https://` URL | ### Return value @@ -129,11 +133,12 @@ The `create()` function validates input locally before making any network call a ## Payment types -| Type | Use case | -| ------------ | ----------------------------------------------------------------------- | -| `SINGLE_USE` | One-time payment with fixed price (e.g., a specific order, invoice) | -| `MULTI_USE` | Fixed price, can be paid multiple times (e.g., recurring product) | -| `VARIABLE` | Reusable link; payer chooses amount per payment (e.g., tips, donations) | +| Type | Use case | +| ------------ | ----------------------------------------------------------------------------------------------- | +| `SINGLE_USE` | One-time payment with fixed price (e.g., a specific order, invoice) | +| `MULTI_USE` | Fixed price, can be paid multiple times (e.g., recurring product) | +| `VARIABLE` | Reusable link; payer chooses amount per payment (e.g., tips, donations) | +| `PROXY` | Fixed-price multi-use payment; after settlement, OpenPayment fetches a private upstream API URL | ## Networks @@ -155,3 +160,4 @@ Support for custom ERC-20 tokens will be added soon. - Website: https://openpayment.link - GitHub: https://github.com/noncept/openpayment +- SKILL: [SKILL.md](./skills/openpayment/SKILL.md) diff --git a/README.md b/README.md index e199e1d..18b9ffd 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ Global CLI: npm i -g openpayment ``` +Install [SKILL](./skills/openpayment/SKILL.md): + +```bash +npx skills add https://github.com/noncept/openpayment +``` + +> If you are an AI Agent, check [AGENTS.md](./AGENTS.md). + ## SDK Usage ```ts @@ -44,7 +52,7 @@ const result = await create({ type: "SINGLE_USE", price: "10", payTo: "0xYourWalletAddress", - network: "eip155:84532", + network: "eip155:8453", description: "Example payment", }); @@ -72,7 +80,7 @@ openpayment create \ --type "SINGLE_USE" \ --price "10" \ --payTo "0xYourWalletAddress" \ - --network "eip155:84532" \ + --network "eip155:8453" \ --description "Example payment" ``` @@ -128,16 +136,18 @@ Example: - `SINGLE_USE` (one-time) - `MULTI_USE` (reusable fixed amount) - `VARIABLE` (reusable custom amount) + - `PROXY` (reusable fixed amount + upstream API proxy call after settlement) - `price`: positive decimal string/number (example: `10`, `0.01`) - `payTo`: EVM address (`0x` + 40 hex chars) - `network`: `eip155:8453` or `eip155:84532` - `description`: optional string, max 500 chars +- `resourceUrl`: required only for `PROXY` (`https://...`) ## Links: - [OpenPayment website](https://openpayment.link/) - [AGENTS.md](./AGENTS.md) -- [OpenClaw Skill](./skills/openpayment/SKILL.md) +- [SKILL.md](./skills/openpayment/SKILL.md) ## License diff --git a/package-lock.json b/package-lock.json index 7998946..4d42c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "openpayment", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openpayment", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "bin": { "openpayment": "dist/cli.js" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^24.11.0", - "eslint": "^10.0.2", + "@types/node": "^24.12.0", + "eslint": "^10.0.3", "globals": "^17.4.0", "prettier": "^3.8.1", "rimraf": "^6.1.3", @@ -68,15 +68,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -96,9 +96,9 @@ } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -130,9 +130,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -140,13 +140,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { @@ -227,9 +227,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", - "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", "dependencies": { @@ -585,19 +585,19 @@ } }, "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", + "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -606,7 +606,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", @@ -619,7 +619,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -642,9 +642,9 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { diff --git a/package.json b/package.json index c44751d..17407ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openpayment", "description": "OpenPayment SDK and CLI for creating x402 payment links", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "private": true, "main": "./dist/index.js", @@ -31,6 +31,7 @@ "build": "tsc -p tsconfig.json", "build:watch": "tsc -p tsconfig.json --watch --preserveWatchOutput", "test": "npm run build && node --test test/**/*.test.mjs", + "test:coverage": "npm run build && node --test --experimental-test-coverage test/**/*.test.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -66,8 +67,8 @@ "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^24.11.0", - "eslint": "^10.0.2", + "@types/node": "^24.12.0", + "eslint": "^10.0.3", "globals": "^17.4.0", "prettier": "^3.8.1", "rimraf": "^6.1.3", diff --git a/skills/openpayment/SKILL.md b/skills/openpayment/SKILL.md index a000ee0..d4c97ee 100644 --- a/skills/openpayment/SKILL.md +++ b/skills/openpayment/SKILL.md @@ -33,6 +33,7 @@ openpayment create \ --price "" \ --payTo "" \ --network "" \ + [--resourceUrl ""] \ --description "" ``` @@ -47,18 +48,20 @@ openpayment create \ ### Optional Flags -| Flag | Description | -| --------------- | ------------------------------------ | -| `--description` | Payment description (max 500 chars) | -| `--json` | Output as JSON instead of plain text | +| Flag | Description | +| --------------- | ------------------------------------------------------------------- | +| `--description` | Payment description (max 500 chars) | +| `--resourceUrl` | Required when `--type` is `PROXY`; upstream API URL (`https://...`) | +| `--json` | Output as JSON instead of plain text | ## Payment Types -| Type | When to use | -| ------------ | ----------------------------------------------------------------------- | -| `SINGLE_USE` | One-time payment with fixed price (e.g., a specific order, invoice) | -| `MULTI_USE` | Fixed price, can be paid multiple times (e.g., recurring product) | -| `VARIABLE` | Reusable link; payer chooses amount per payment (e.g., tips, donations) | +| Type | When to use | +| ------------ | ------------------------------------------------------------------------------------------- | +| `SINGLE_USE` | One-time payment with fixed price (e.g., a specific order, invoice) | +| `MULTI_USE` | Fixed price, can be paid multiple times (e.g., recurring product) | +| `VARIABLE` | Reusable link; payer chooses amount per payment (e.g., tips, donations) | +| `PROXY` | Fixed-price multi-use payment that calls a private upstream API after successful settlement | **Default to `SINGLE_USE`** unless the user specifies otherwise. @@ -117,6 +120,18 @@ openpayment create \ --description "Monthly subscription" ``` +### Proxy payment link + +```bash +openpayment create \ + --type "PROXY" \ + --price "10" \ + --payTo "0xYourWalletAddress" \ + --network "eip155:8453" \ + --resourceUrl "https://private-api.example.com/endpoint" \ + --description "Proxy payment" +``` + ### Testnet payment link ```bash @@ -177,7 +192,9 @@ Example: ## Workflow for Handling User Requests -1. **Identify missing info** you need: amount (`--price`), receiver wallet address (`--payTo`). Ask if not provided. +The first time the skill runs, explain to the user what payment types and networks are allowed. + +1. **Identify missing info** you need: amount (`--price`), receiver wallet address (`--payTo`). If `--type=PROXY`, also require `--resourceUrl`. 2. **Infer defaults**: type defaults to `SINGLE_USE`, network defaults to `eip155:8453` (Base Mainnet). 3. **Confirm info** with the user before creating. 4. **Run the command** using the bash tool. @@ -189,14 +206,16 @@ Example: - **No amount**: "How much USDC should the payment be for?" - **No type specified but context suggests multi-use**: "Should this link be single-use (one payment only) or reusable?", "Should the amount be fixed or editable?" - **No network specified**: assume Base Mainnet; mention it in your response. +- **PROXY without upstream URL**: "Please provide the private upstream API URL for this proxy payment (`--resourceUrl`)." ## Validation Rules (enforced by CLI before any API call) -- `--type`: must be `SINGLE_USE`, `MULTI_USE`, or `VARIABLE` +- `--type`: must be `SINGLE_USE`, `MULTI_USE`, `VARIABLE`, or `PROXY` - `--price`: positive decimal number (e.g. `0.001`, `10`, `99.99`) - `--payTo`: valid EVM address — `0x` followed by 40 hex characters - `--network`: must be `eip155:8453` or `eip155:84532` - `--description`: optional string, max 500 characters +- `--resourceUrl`: required only for `PROXY`; must be a valid `https://` URL ## Security Notes diff --git a/src/cli.ts b/src/cli.ts index ae0a89d..6199799 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,6 @@ import { parseArgs } from "node:util"; import { getCliConfig } from "./lib/config.ts"; -import { SUPPORTED_NETWORKS, SUPPORTED_TYPES } from "./lib/constants.ts"; import { createWithConfig } from "./lib/create.ts"; import type { CreatePaymentInput, PaymentType } from "./lib/types.ts"; import { normalizePrice, validateCreateInput } from "./lib/validation.ts"; @@ -21,7 +20,7 @@ function printHelp(): void { console.log(`openpayment CLI Usage: - openpayment create --type --price --payTo
--network [--description ] [--json] + openpayment create --type --price --payTo
--network [--description ] [--resourceUrl ] [--json] Example: openpayment create --type "SINGLE_USE" --price "0.001" --payTo "0xYourWalletAddress" --network "eip155:84532" --description "your description" @@ -32,6 +31,7 @@ Options: --payTo Recipient wallet address (required) --network eip155:8453 or eip155:84532 (required) --description Payment description + --resourceUrl Required when --type is PROXY. Upstream private API URL. --json Print JSON output only --help Show help @@ -54,6 +54,7 @@ function parseCreateFlags(args: string[]): CreateCommandOptions { payTo: { type: "string" }, network: { type: "string" }, description: { type: "string" }, + resourceUrl: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean" }, }, @@ -78,6 +79,9 @@ function parseCreateFlags(args: string[]): CreateCommandOptions { if (!values.payTo) { throw new Error("Missing required flag --payTo"); } + if (values.type === "PROXY" && !values.resourceUrl) { + throw new Error("Missing required flag --resourceUrl when --type is PROXY"); + } const input: CreatePaymentInput = { type: values.type as PaymentType, @@ -85,6 +89,7 @@ function parseCreateFlags(args: string[]): CreateCommandOptions { payTo: values.payTo, network: values.network, description: values.description, + resourceUrl: values.type === "PROXY" ? values.resourceUrl : undefined, }; validateCreateInput(input); @@ -95,14 +100,6 @@ function parseCreateFlags(args: string[]): CreateCommandOptions { }; } -/** - * Prints additional allowed values for fast troubleshooting. - */ -function printValidationHints(): void { - console.error(`Allowed --type: ${Array.from(SUPPORTED_TYPES).join(", ")}`); - console.error(`Allowed --network: ${Array.from(SUPPORTED_NETWORKS).join(", ")}`); -} - /** * CLI entrypoint. */ @@ -136,10 +133,6 @@ async function main(): Promise { const message = error instanceof Error ? error.message : "Unexpected error"; console.error(`Error: ${message}`); - if (message.startsWith("Invalid --type") || message.startsWith("Invalid --network")) { - printValidationHints(); - } - process.exit(1); } } diff --git a/src/index.ts b/src/index.ts index 824854c..f01cf5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ -/** - * OpenPayment SDK public API. - */ +export type { + CreatePaymentApiResponse, + CreatePaymentInput, + CreatePaymentResult, + PaymentType, +} from "./lib/types.ts"; export { create } from "./lib/create.ts"; diff --git a/src/lib/buildPaymentUrl.ts b/src/lib/buildPaymentUrl.ts index d9f2c40..2ebe570 100644 --- a/src/lib/buildPaymentUrl.ts +++ b/src/lib/buildPaymentUrl.ts @@ -20,8 +20,9 @@ export function buildPaymentUrl(paymentId: string, siteBaseUrl: string): string url.searchParams.set("paymentId", paymentId); return url.toString(); } catch { - const trimmed = siteBaseUrl.replace(/\/+$/, ""); - const separator = trimmed.includes("?") ? "&" : "?"; - return `${trimmed}/pay/${separator}paymentId=${encodeURIComponent(paymentId)}`; + const [rawPath, rawQuery = ""] = siteBaseUrl.split("?", 2); + const normalizedPath = normalizePayPath(rawPath.replace(/\/+$/, "")); + const queryPrefix = rawQuery.length > 0 ? `${rawQuery}&` : ""; + return `${normalizedPath}?${queryPrefix}paymentId=${encodeURIComponent(paymentId)}`; } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d56bed2..a86c6b7 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -17,6 +17,7 @@ export const SUPPORTED_TYPES: ReadonlySet = new Set([ "SINGLE_USE", "MULTI_USE", "VARIABLE", + "PROXY", ]); /** diff --git a/src/lib/createPayment.ts b/src/lib/createPayment.ts index 157a731..658a046 100644 --- a/src/lib/createPayment.ts +++ b/src/lib/createPayment.ts @@ -1,5 +1,5 @@ import type { CreatePaymentApiResponse, CreatePaymentInput } from "./types.ts"; -import { normalizePrice, validateCreateInput } from "./validation.ts"; +import { normalizeCreateInput } from "./validation.ts"; interface ApiErrorBody { message?: string; @@ -48,7 +48,7 @@ export async function createPayment( input: CreatePaymentInput, apiBaseUrl: string, ): Promise { - validateCreateInput(input); + const normalizedInput = normalizeCreateInput(input); const fetchImpl = globalThis.fetch; if (typeof fetchImpl !== "function") { @@ -56,11 +56,18 @@ export async function createPayment( } const payload = { - type: input.type, - price: normalizePrice(input.price).trim(), - payTo: input.payTo.trim(), - network: input.network.trim(), - description: input.description, + type: normalizedInput.type, + price: normalizedInput.price, + payTo: normalizedInput.payTo, + network: normalizedInput.network, + description: normalizedInput.description, + resource: + normalizedInput.type === "PROXY" + ? { + type: "API", + url: normalizedInput.resourceUrl, + } + : undefined, }; const response = await fetchImpl(`${normalizeApiUrl(apiBaseUrl)}/x-payments`, { diff --git a/src/lib/types.ts b/src/lib/types.ts index 90ef87a..acc7a67 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,7 +1,7 @@ /** * Supported payment types for OpenPayment create endpoint. */ -export type PaymentType = "SINGLE_USE" | "MULTI_USE" | "VARIABLE"; +export type PaymentType = "SINGLE_USE" | "MULTI_USE" | "VARIABLE" | "PROXY"; /** * Input payload for payment creation. @@ -12,6 +12,7 @@ export interface CreatePaymentInput { payTo: string; network: string; description?: string; + resourceUrl?: string; } /** diff --git a/src/lib/validation.ts b/src/lib/validation.ts index ffef200..fab0465 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -5,6 +5,15 @@ import { } from "./constants.ts"; import type { CreatePaymentInput } from "./types.ts"; +export interface NormalizedCreatePaymentInput { + type: CreatePaymentInput["type"]; + price: string; + payTo: string; + network: string; + description?: string; + resourceUrl?: string; +} + /** * Validates EVM address shape. */ @@ -12,6 +21,31 @@ function isEvmAddress(value: string): boolean { return /^0x[a-fA-F0-9]{40}$/.test(value); } +/** + * Validates proxy resource URL shape and protocol. + */ +function isAllowedResourceUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:"; + } catch { + return false; + } +} + +/** + * Validates proxy resource URL payload. + */ +function validateProxyResourceUrl(resourceUrl: string | undefined): void { + if (!resourceUrl) { + throw new Error("Invalid resourceUrl. PROXY payments require a valid resourceUrl."); + } + + if (!isAllowedResourceUrl(resourceUrl.trim())) { + throw new Error("Invalid resourceUrl. Expected a valid https URL."); + } +} + /** * Validates positive decimal string input. */ @@ -32,17 +66,28 @@ export function normalizePrice(price: string | number): string { } /** - * Validates create request payload before network call. + * Normalizes and validates create request payload before network call. * Throws an Error with actionable text on invalid input. */ -export function validateCreateInput(input: CreatePaymentInput): void { +export function normalizeCreateInput( + input: CreatePaymentInput, +): NormalizedCreatePaymentInput { const normalizedPrice = normalizePrice(input.price).trim(); const normalizedPayTo = input.payTo.trim(); const normalizedNetwork = input.network.trim(); + const normalizedResourceUrl = input.resourceUrl?.trim(); if (!SUPPORTED_TYPES.has(input.type)) { throw new Error( - 'Invalid type. Allowed values: "SINGLE_USE", "MULTI_USE", "VARIABLE".', + 'Invalid type. Allowed values: "SINGLE_USE", "MULTI_USE", "VARIABLE", "PROXY".', + ); + } + + if (input.type === "PROXY") { + validateProxyResourceUrl(normalizedResourceUrl); + } else if (normalizedResourceUrl !== undefined) { + throw new Error( + "Invalid resourceUrl. resourceUrl is only supported when type is PROXY.", ); } @@ -69,4 +114,20 @@ export function validateCreateInput(input: CreatePaymentInput): void { ); } } + + return { + type: input.type, + price: normalizedPrice, + payTo: normalizedPayTo, + network: normalizedNetwork, + description: input.description, + resourceUrl: normalizedResourceUrl, + }; +} + +/** + * Validates create request payload before network call. + */ +export function validateCreateInput(input: CreatePaymentInput): void { + normalizeCreateInput(input); } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 0292a52..53c1c10 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -85,6 +85,51 @@ test("cli validates required flags", async () => { assert.match(result.stderr, /Missing required flag --price/); }); +test("cli validates missing --type", async () => { + const result = await runCli([ + "create", + "--price", + "0.001", + "--payTo", + "0x1111111111111111111111111111111111111111", + "--network", + "eip155:84532", + ]); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Missing required flag --type/); +}); + +test("cli validates missing --network", async () => { + const result = await runCli([ + "create", + "--type", + "SINGLE_USE", + "--price", + "0.001", + "--payTo", + "0x1111111111111111111111111111111111111111", + ]); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Missing required flag --network/); +}); + +test("cli validates missing --payTo", async () => { + const result = await runCli([ + "create", + "--type", + "SINGLE_USE", + "--price", + "0.001", + "--network", + "eip155:84532", + ]); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Missing required flag --payTo/); +}); + test("cli prints validation hints for invalid type", async () => { const result = await runCli( [ @@ -107,8 +152,23 @@ test("cli prints validation hints for invalid type", async () => { assert.equal(result.code, 1); assert.match(result.stderr, /Error: Invalid --type test hint/); - assert.match(result.stderr, /Allowed --type:/); - assert.match(result.stderr, /Allowed --network:/); +}); + +test("cli prints validation hints for local type validation errors", async () => { + const result = await runCli([ + "create", + "--type", + "WRONG", + "--price", + "0.001", + "--payTo", + "0x1111111111111111111111111111111111111111", + "--network", + "eip155:84532", + ]); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Error: Invalid type/); }); test("cli create prints human-friendly output", async () => { @@ -155,6 +215,65 @@ test("cli create prints human-friendly output", async () => { fs.rmSync(outputFile, { force: true }); }); +test("cli create PROXY passes resource payload", async () => { + const outputFile = path.resolve(__dirname, "./.cli-fetch-call-proxy.json"); + + const result = await runCli( + [ + "create", + "--type", + "PROXY", + "--price", + "1", + "--payTo", + "0x1111111111111111111111111111111111111111", + "--network", + "eip155:84532", + "--resourceUrl", + "https://private-api.example.com/endpoint", + ], + { + ...DEFAULT_ENV, + OPENPAYMENT_TEST_SCENARIO: "success", + OPENPAYMENT_TEST_OUTPUT: outputFile, + }, + { mockFetch: true }, + ); + + assert.equal(result.code, 0); + + const fetchCall = JSON.parse(fs.readFileSync(outputFile, "utf8")); + assert.deepEqual(JSON.parse(fetchCall.init.body), { + type: "PROXY", + price: "1", + payTo: "0x1111111111111111111111111111111111111111", + network: "eip155:84532", + resource: { + type: "API", + url: "https://private-api.example.com/endpoint", + }, + }); + + fs.rmSync(outputFile, { force: true }); +}); + +test("cli create PROXY requires --resourceUrl", async () => { + const result = await runCli([ + "create", + "--type", + "PROXY", + "--price", + "1", + "--payTo", + "0x1111111111111111111111111111111111111111", + "--network", + "eip155:84532", + ]); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Missing required flag --resourceUrl when --type is PROXY/); +}); + test("cli create --json prints JSON only", async () => { const result = await runCli( [ diff --git a/test/index.test.mjs b/test/index.test.mjs index 7d4097c..9725c72 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -59,6 +59,43 @@ test("createWithConfig posts payload and returns paymentId + /pay/ url", async ( ); }); +test("createWithConfig maps PROXY resourceUrl to API resource payload", async () => { + let calledInit; + + globalThis.fetch = async (_input, init) => { + calledInit = init; + return { + ok: true, + status: 200, + json: async () => ({ paymentId: "123e4567-e89b-12d3-a456-426614174000" }), + }; + }; + + await createWithConfig( + { + ...VALID_INPUT, + type: "PROXY", + resourceUrl: "https://private-api.example.com/endpoint", + }, + { + apiUrl: "https://api.example.test", + siteUrl: "https://site.example.test", + }, + ); + + assert.deepEqual(JSON.parse(calledInit.body), { + type: "PROXY", + price: "0.001", + payTo: "0x1111111111111111111111111111111111111111", + network: "eip155:84532", + description: "test payment", + resource: { + type: "API", + url: "https://private-api.example.com/endpoint", + }, + }); +}); + test("create uses SDK defaults and ignores env overrides", async () => { process.env.OPENPAYMENT_API_URL = "http://localhost:9999/dev"; process.env.OPENPAYMENT_SITE_URL = "http://localhost:3000"; @@ -189,6 +226,70 @@ test("validation rejects unsupported type", () => { ); }); +test("validation accepts PROXY with valid resourceUrl", () => { + assert.doesNotThrow(() => + validateCreateInput({ + ...VALID_INPUT, + type: "PROXY", + resourceUrl: "https://private-api.example.com/endpoint", + }), + ); +}); + +test("validation rejects PROXY without resourceUrl", () => { + assert.throws( + () => validateCreateInput({ ...VALID_INPUT, type: "PROXY" }), + /PROXY payments require a valid resourceUrl/, + ); +}); + +test("validation rejects non-PROXY with resourceUrl", () => { + assert.throws( + () => + validateCreateInput({ + ...VALID_INPUT, + resourceUrl: "https://private-api.example.com/endpoint", + }), + /resourceUrl is only supported when type is PROXY/, + ); +}); + +test("validation rejects PROXY with invalid resource url", () => { + assert.throws( + () => + validateCreateInput({ + ...VALID_INPUT, + type: "PROXY", + resourceUrl: "ftp://private-api.example.com/endpoint", + }), + /Invalid resourceUrl/, + ); +}); + +test("validation rejects PROXY with malformed resourceUrl", () => { + assert.throws( + () => + validateCreateInput({ + ...VALID_INPUT, + type: "PROXY", + resourceUrl: "https://%", + }), + /Invalid resourceUrl/, + ); +}); + +test("validation rejects PROXY with http resourceUrl", () => { + assert.throws( + () => + validateCreateInput({ + ...VALID_INPUT, + type: "PROXY", + resourceUrl: "http://localhost:3000/internal-endpoint", + }), + /https URL/, + ); +}); + test("validation rejects invalid price", () => { assert.throws( () => validateCreateInput({ ...VALID_INPUT, price: "-1" }), @@ -238,7 +339,7 @@ test("buildPaymentUrl normalizes path and keeps existing query", () => { test("buildPaymentUrl uses fallback for invalid base URL", () => { assert.equal(buildPaymentUrl("p1", "not-a-url"), "not-a-url/pay/?paymentId=p1"); - assert.equal(buildPaymentUrl("p2", "not-a-url?x=1"), "not-a-url?x=1/pay/&paymentId=p2"); + assert.equal(buildPaymentUrl("p2", "not-a-url?x=1"), "not-a-url/pay/?x=1&paymentId=p2"); }); test("buildPaymentUrl rejects invalid paymentId", () => {