From 64fc657d747e4dba8808f66e8bf130977d5a9387 Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 17:06:45 +0400 Subject: [PATCH 1/9] Release MakePay SDK 0.4.0 OAuth support --- .github/workflows/ci.yml | 48 +++ .github/workflows/publish.yml | 78 +++++ CHANGELOG.md | 33 ++ README.md | 110 ++++++- RELEASE.md | 14 +- package-lock.json | 51 +++ package.json | 6 +- scripts/verify-package.mjs | 178 +++++++++++ src/index.ts | 583 ++++++++++++++++++++++++++++++---- tests/run.mjs | 351 +++++++++++++++++++- tsconfig.json | 2 +- 11 files changed, 1357 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 package-lock.json create mode 100644 scripts/verify-package.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ffef6b2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + quality: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: + - 18 + - 22 + - 24 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Inspect exact package allowlist and secret exclusions + run: node scripts/verify-package.mjs + + - name: Audit production dependencies + if: matrix.node == 24 + run: npm audit --omit=dev --audit-level=high diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d3004f4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,78 @@ +name: Publish npm candidate + +on: + push: + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: npm-publish-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish-next: + name: Publish immutable candidate to next + runs-on: ubuntu-latest + environment: npm-release + + steps: + - name: Checkout the release commit + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js for trusted publishing + uses: actions/setup-node@v6 + with: + node-version: 24 + package-manager-cache: false + registry-url: https://registry.npmjs.org + + - name: Install trusted-publishing capable npm + run: npm install --global npm@11.6.2 + + - name: Verify tag, version, and main ancestry + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + if [[ "${GITHUB_REF_TYPE}" != "tag" || "${GITHUB_REF_NAME}" != "v${version}" ]]; then + echo "Release must run from tag v${version}." >&2 + exit 1 + fi + git fetch --no-tags origin main + git merge-base --is-ancestor "${GITHUB_SHA}" origin/main + + - name: Install dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Inspect exact package allowlist and secret exclusions + run: node scripts/verify-package.mjs + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Refuse to overwrite an existing version + shell: bash + run: | + set -euo pipefail + package="$(node -p "require('./package.json').name")" + version="$(node -p "require('./package.json').version")" + if npm view "${package}@${version}" version >/dev/null 2>&1; then + echo "${package}@${version} is already published and immutable." >&2 + exit 1 + fi + + - name: Publish candidate with npm trusted publishing + run: npm publish --provenance --access public --tag next diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f888dcf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to `@makecrypto/makepay` are documented here. + +## 0.4.0 - 2026-07-19 + +### Added + +- Asynchronous OAuth authorization providers while preserving the existing + `keyId` and `keySecret` client configuration. +- P-256 DPoP key generation, JWK thumbprints, and ES256 proof helpers for native + OAuth integrations. +- One controlled authorization refresh and retry after a `401`; token storage, + refresh locking, and atomic persistence remain the host application's + responsibility. +- Idempotency keys for payment-link mutations and grant-scoped MakePay webhook + subscription methods. +- Allowlisted Medusa payment-link correlation metadata for reliable order and + payment reconciliation. + +### Changed + +- Requests refuse cross-origin path escapes and use manual redirect handling so + credentials and DPoP proofs are never automatically forwarded to a redirect + target. +- The default hosted checkout and embedded checkout URLs use the canonical + `www.makepay.io` origin; the production modal loader uses the MakePay CDN. +- Published JavaScript and declarations no longer include source maps. + +### Compatibility + +- API-key construction and the public package root export remain compatible. +- Node.js 18 or newer is required. diff --git a/README.md b/README.md index 3a9ff86..2665cd8 100644 --- a/README.md +++ b/README.md @@ -36,31 +36,93 @@ const makepay = new MakePayClient({ The client sends `x-makecrypto-key-id` and `x-makecrypto-key-secret` headers to the MakePay partner API. +### OAuth and DPoP + +Native integrations can instead supply OAuth credentials asynchronously. The +host application remains responsible for encrypting tokens and the DPoP private +key, serializing refreshes, and atomically persisting rotated refresh tokens. + +```ts +import { + MakePayClient, + createMakePayDpopProof, + type MakePayAuthProvider, +} from "@makecrypto/makepay"; + +const authProvider: MakePayAuthProvider = { + async getAuthorization({ method, url }) { + const credentials = await tokenStore.load(); + + return { + accessToken: credentials.accessToken, + tokenType: "DPoP", + dpopProof: createMakePayDpopProof({ + accessToken: credentials.accessToken, + method, + privateKey: credentials.dpopPrivateKeyPem, + url, + }), + }; + }, + async refreshAuthorization() { + // Refresh once under your application's lock and persist both rotated + // tokens before this promise resolves. + await tokenStore.refresh(); + }, +}; + +const makepay = new MakePayClient({ authProvider }); +``` + +On a `401`, the SDK invokes `refreshAuthorization` at most once and rebuilds +authorization (including a fresh DPoP proof) before one retry. It never owns or +persists OAuth tokens. `generateMakePayDpopKeyPair`, +`calculateMakePayDpopJwkThumbprint`, and `createMakePayDpopProof` are available +for native authorization-code integrations. + ## Payment Links ```ts -const response = await makepay.createPaymentLink({ - title: "Order #1042", - description: "Checkout for order #1042", - amount: "129.99", - currency: "USDT", - orderId: "order_1042", - customerEmail: "buyer@example.com", - returnUrl: "https://merchant.example/orders/1042", - successUrl: "https://merchant.example/orders/1042/success", - failureUrl: "https://merchant.example/orders/1042/pay", - expirationTime: "12h", -}); +const response = await makepay.createPaymentLink( + { + title: "Order #1042", + description: "Checkout for order #1042", + amount: "129.99", + currency: "USDT", + orderId: "order_1042", + customerEmail: "buyer@example.com", + returnUrl: "https://merchant.example/orders/1042", + successUrl: "https://merchant.example/orders/1042/success", + failureUrl: "https://merchant.example/orders/1042/pay", + expirationTime: "12h", + }, + { + // Reuse this value while reconciling an ambiguous network outcome. + idempotencyKey: "order_1042:payment-link:v1", + }, +); console.log(response.paymentLink); ``` +`createPaymentLink`, `updatePaymentLink`, and the current webhook-subscription +mutations accept an `idempotencyKey`. Reuse the same key only for an identical +mutation; MakePay rejects reuse with a different method, path, or body. + Read, update, and email existing links: ```ts await makepay.listPaymentLinks(); await makepay.getPaymentLink("PAYMENT_LINK_UID"); await makepay.updatePaymentLink("PAYMENT_LINK_UID", { status: "paused" }); +await makepay.updatePaymentLink("PAYMENT_LINK_UID", { + metadata: { + medusaOrderId: "order_01J...", + medusaOrderDisplayId: "1042", + medusaAdminUrl: "https://merchant.example/app/orders/order_01J...", + medusaInstallationId: "installation_01J...", + }, +}); await makepay.sendPaymentRequestEmail("PAYMENT_LINK_UID", "buyer@example.com"); ``` @@ -344,6 +406,23 @@ await makepay.listDestinationAssets(); await makepay.listWebhookRequests({ limit: 25 }); ``` +OAuth integrations should use a grant-scoped webhook subscription rather than +changing a company-global callback URL. The signing secret is returned only on +creation or explicit rotation, so persist it immediately. + +```ts +const created = await makepay.upsertCurrentWebhookSubscription( + { + url: "https://merchant.example/webhooks/makepay", + events: ["makepay.payment.status_changed"], + }, + { idempotencyKey: "installation_123:webhook:v1" }, +); + +await makepay.getCurrentWebhookSubscription(); +await makepay.deleteCurrentWebhookSubscription(); +``` + ## Webhook Verification Read the exact raw body before parsing JSON. @@ -384,7 +463,7 @@ Use `verifyMakePayWebhook` when you only need a boolean result. | Simple Shop | `getShop`, `updateShop`, `getShopBuilder`, `updateShopBuilder`, `getShopDomain`, `updateShopDomain`, `refreshShopDomain`, coupons, orders | | Bookkeeping | summary, invoice, expense, document upload/OCR, and reconciliation methods | | Branding | `getBranding`, `updateBranding`, `refreshBrandingDomains` | -| Operations | `getSettings`, `updateSettings`, `listDestinationAssets`, `listWebhookRequests` | +| Operations | `getSettings`, `updateSettings`, `listDestinationAssets`, `listWebhookRequests`, current webhook subscription CRUD | | Webhooks | `verifyMakePayWebhook`, `parseMakePayWebhook` | ## TypeScript, Data Models, And Response Models @@ -397,8 +476,10 @@ secondary SDK package. import type { MakePayBookkeepingInvoicePayload, MakePayBookkeepingSummaryResponse, + MakePayAuthProvider, MakePayPaymentLinkPayload, MakePayPaymentLinkResponse, + MakePayWebhookSubscriptionResponse, } from "@makecrypto/makepay"; ``` @@ -421,6 +502,7 @@ Model conventions: | Model | Used by | Required fields | Common optional fields | | ----------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `MakePayPaymentLinkPayload` | `createPaymentLink` | `amount` | `title`, `description`, `currency`, `asset`, `orderId`, `customerEmail`, `clientId`, `returnUrl`, `successUrl`, `metadata` | +| `MakePayPaymentLinkUpdate` | `updatePaymentLink` | at least one update field | status/expiry controls or allowlisted Medusa order-correlation metadata | | `MakePayDonationLinkPayload` | `createDonationLink` | none | `defaultAmountUsd`, `minimumAmountUsd`, `donationSlug`, payment-link display and redirect fields | | `MakePayAnonymousPaymentLinkPayload` | `createAnonymousPaymentLink` | `amount`, `settlement.currency`, `settlement.priorities` | `title`, `customerEmail`, `orderId`, `metadata`, `branding`, `webhookUrl`, checkout redirect URLs | | `MakePayCustomerPayload` | `upsertCustomer` | one of `email`, `customerEmail`, `name`, `clientId` | `metadata` | @@ -449,7 +531,7 @@ Model conventions: | `getShop`, `updateShop`, `getShopBuilder`, `updateShopBuilder`, `getShopDomain`, `updateShopDomain`, `refreshShopDomain` | shop, builder, and domain response types | `shop`, `blocks`, `builder`, `domain`, `status`, `verification` | | `listShopCoupons`, `createShopCoupon`, `updateShopCoupon`, `archiveShopCoupon`, `listShopOrders` | coupon and order response types | `coupons`, `coupon`, `orders` | | `getBranding`, `updateBranding`, `refreshBrandingDomains`, `getSettings`, `updateSettings` | `MakePayBrandingResponse`, `MakePaySettingsResponse`, or `MakePaySettingsUpdateResponse` | `company`, `settings`, `ok` | -| `listDestinationAssets`, `listWebhookRequests` | operational response types | `assets`/`destinationAssets`, `webhookRequests`/`requests` | +| `listDestinationAssets`, `listWebhookRequests`, current webhook subscription methods | operational response types | assets, webhook request logs, subscription metadata, and one-time `signingSecret` | | `getBookkeepingSummary`, invoice, expense, document, OCR, and reconciliation methods | bookkeeping response types | `summary`, `invoices`, `invoice`, `expenses`, `expense`, `documents`, `url`, `reconciliationLinks`, `stats` | | `verifyMakePayWebhook`, `parseMakePayWebhook` | boolean or parsed event | `parseMakePayWebhook()` returns your supplied event type after signature verification | diff --git a/RELEASE.md b/RELEASE.md index ada70e3..6abf425 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,7 +3,17 @@ Releases are performed only by MakePay by MakeCrypto maintainers. - Public repositories accept issues and pull requests, but maintainers control release approval, versioning, tagging, and package publishing. -- Package-manager credentials, signing keys, and publish tokens must live only in private release repositories or private CI workflows. -- Public repositories must not store npm, PyPI, Maven Central, Packagist, RubyGems, GitHub release, or other publish secrets. +- npm publishing uses the package's GitHub Actions trusted publisher, short-lived OIDC credentials, and provenance. Do not add an npm token to repository or environment secrets. - Protected branch and tag rules must remain enabled for `main` and release tags. - Version bumps should be made only when maintainers intend to publish a new package or release artifact. + +## npm release sequence + +1. Merge a reviewed, conflict-free pull request after Node 18, 22, and 24 CI, tests, build, package inspection, and the production dependency audit pass. +2. Configure the npm trusted publisher for `makepay-io/makepay-npm-sdk`, workflow `publish.yml`, and GitHub environment `npm-release`. +3. Tag the exact merge commit as `v`. The release workflow validates main ancestry and publishes the immutable version to the `next` dist-tag with provenance. +4. Install and smoke-test the exact registry version, then move `latest` to that same version. Do not rebuild or republish for promotion. +5. After trusted publishing and promotion are verified, revoke any previous long-lived npm automation or access token. +6. Publish the GitHub release and `CHANGELOG.md` notes from the same tag. + +If candidate validation fails, leave `latest` unchanged. If a defect is found after promotion, move `latest` back to the prior stable version, deprecate the affected version with a useful message, and publish a new patch version rather than unpublishing it. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0d4afc1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@makecrypto/makepay", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@makecrypto/makepay", + "version": "0.4.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^25.3.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 589866c..8a3139d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@makecrypto/makepay", - "version": "0.3.2", - "description": "Official MakePay JavaScript and TypeScript SDK for payments, invoices, bookkeeping, POS, products, Simple Shop, branding, and webhooks.", + "version": "0.4.0", + "description": "Official MakePay JavaScript and TypeScript SDK for OAuth, payments, invoices, bookkeeping, POS, products, Simple Shop, branding, and webhooks.", "license": "MIT", "type": "module", "sideEffects": false, @@ -34,6 +34,8 @@ "makecrypto", "crypto-payments", "payment-links", + "oauth", + "dpop", "donations", "invoices", "bookkeeping", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..4d13ef4 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, "package.json"), "utf8"), +); +const expectedFiles = [ + "LICENSE", + "README.md", + "dist/index.d.ts", + "dist/index.js", + "package.json", +].sort(); +const secretPatterns = [ + ["npm access token", /\bnpm_[A-Za-z0-9]{36,}\b/g], + [ + "GitHub token", + /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/g, + ], + ["Vercel access token", /\bvcp_[A-Za-z0-9]{30,}\b/g], + ["Supabase access token", /\bsbp_[A-Za-z0-9]{30,}\b/g], + ["AWS access key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g], + ["live payment secret", /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b/g], + ["private key", /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/g], + [ + "registry authentication value", + /(?:_authToken|NODE_AUTH_TOKEN|NPM_TOKEN)\s*[:=]\s*["']?[^\s"']{8,}/g, + ], + ["MakeCrypto API secret", /\bmksec_[A-Za-z0-9_-]{20,}\b/g], + ["MakePay webhook secret", /\bmkwhsec_[A-Za-z0-9_-]{20,}\b/g], +]; + +function fail(message) { + throw new Error(message); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: "utf8", + ...options, + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + fail( + `${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout}`, + ); + } + + return result.stdout; +} + +function listFiles(root, baseRoot = root) { + const files = []; + + for (const entry of readdirSync(root)) { + const absolutePath = join(root, entry); + if (statSync(absolutePath).isDirectory()) { + files.push(...listFiles(absolutePath, baseRoot)); + } else { + files.push(relative(baseRoot, absolutePath).replaceAll("\\", "/")); + } + } + + return files; +} + +function assertExactFiles(actualFiles, source) { + const normalized = [...actualFiles].sort(); + if (JSON.stringify(normalized) !== JSON.stringify(expectedFiles)) { + const missing = expectedFiles.filter((file) => !normalized.includes(file)); + const unexpected = normalized.filter( + (file) => !expectedFiles.includes(file), + ); + fail( + `${source} does not match the release allowlist.` + + ` Missing: ${missing.join(", ") || "none"}.` + + ` Unexpected: ${unexpected.join(", ") || "none"}.`, + ); + } +} + +if (packageJson.name !== "@makecrypto/makepay") { + fail("package.json must publish @makecrypto/makepay."); +} +if (packageJson.version !== "0.4.0") { + fail("package.json must publish version 0.4.0."); +} +if ( + JSON.stringify(packageJson.files) !== + JSON.stringify(["dist", "README.md", "LICENSE"]) +) { + fail("package.json files must remain the exact reviewed release allowlist."); +} + +const compilerOptions = JSON.parse( + readFileSync(join(repositoryRoot, "tsconfig.json"), "utf8"), +).compilerOptions; +for (const option of [ + "declarationMap", + "sourceMap", + "inlineSourceMap", + "inlineSources", +]) { + if (compilerOptions[option] === true) { + fail(`tsconfig.json must not enable ${option}.`); + } +} + +const temporaryDirectory = mkdtempSync(join(tmpdir(), "makepay-sdk-pack-")); +let tarballPath = null; + +try { + const packed = JSON.parse( + run(process.platform === "win32" ? "npm.cmd" : "npm", [ + "pack", + "--json", + "--ignore-scripts", + ]), + ); + const result = packed[0]; + if (!result?.filename || !Array.isArray(result.files)) { + fail("npm pack did not return a valid package manifest."); + } + + assertExactFiles( + result.files.map((file) => file.path), + "npm pack manifest", + ); + tarballPath = join(repositoryRoot, result.filename); + run("tar", ["-xzf", tarballPath, "-C", temporaryDirectory]); + + const extractedRoot = join(temporaryDirectory, "package"); + const extractedFiles = listFiles(extractedRoot); + assertExactFiles(extractedFiles, "packed tarball"); + + for (const file of extractedFiles) { + if ( + file.endsWith(".map") || + file.startsWith("src/") || + file.startsWith("tests/") + ) { + fail(`Packed tarball contains a source or source-map artifact: ${file}`); + } + + const contents = readFileSync(join(extractedRoot, file), "utf8"); + if (/sourceMappingURL\s*=|sourceURL\s*=/.test(contents)) { + fail(`Packed tarball contains a source-map reference in ${file}.`); + } + + for (const [label, pattern] of secretPatterns) { + pattern.lastIndex = 0; + if (pattern.test(contents)) { + fail(`Packed tarball contains a possible ${label} in ${file}.`); + } + } + } + + console.log( + `Verified ${packageJson.name}@${packageJson.version}: exact artifact allowlist, no source maps, and no recognized secrets.`, + ); +} finally { + if (tarballPath) rmSync(tarballPath, { force: true }); + rmSync(temporaryDirectory, { force: true, recursive: true }); +} diff --git a/src/index.ts b/src/index.ts index 704abb3..41a42db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,102 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; - -export type MakePayClientOptions = { +import { + createHash, + createHmac, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + randomUUID, + sign, + timingSafeEqual, +} from "node:crypto"; + +export type MakePayClientBaseOptions = { baseUrl?: string; checkoutBaseUrl?: string; + fetch?: typeof fetch; +}; + +export type MakePayApiKeyClientOptions = MakePayClientBaseOptions & { keyId: string; keySecret: string; - fetch?: typeof fetch; + authProvider?: never; +}; + +export type MakePayOAuthClientOptions = MakePayClientBaseOptions & { + authProvider: MakePayAuthProvider; + keyId?: never; + keySecret?: never; }; +/** + * Configure the client with either a MakePay API key or an asynchronous OAuth + * provider. Authentication credentials are intentionally mutually exclusive. + */ +export type MakePayClientOptions = + | MakePayApiKeyClientOptions + | MakePayOAuthClientOptions; + +export type MakePayHttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + +export type MakePayAuthRequest = Readonly<{ + method: MakePayHttpMethod; + retry: boolean; + url: string; +}>; + +export type MakePayAuthRefreshRequest = MakePayAuthRequest & + Readonly<{ + response: Response; + }>; + +export type MakePayOAuthAuthorization = Readonly<{ + accessToken: string; + tokenType?: "Bearer" | "DPoP"; + dpopProof?: string; +}>; + +/** + * Supplies current OAuth credentials for each request. The host owns token + * storage, refresh locking, and persistence of rotated refresh tokens. + */ +export interface MakePayAuthProvider { + getAuthorization( + request: MakePayAuthRequest, + ): Promise; + refreshAuthorization?(request: MakePayAuthRefreshRequest): Promise; +} + +export type MakePayOAuthAuthProvider = MakePayAuthProvider; + +export type MakePayDpopPublicJwk = Readonly<{ + crv: "P-256"; + kty: "EC"; + x: string; + y: string; +}>; + +export type MakePayDpopKeyPair = Readonly<{ + privateKeyPem: string; + publicJwk: MakePayDpopPublicJwk; + thumbprint: string; +}>; + +export type MakePayDpopProofOptions = Readonly<{ + accessToken?: string; + issuedAt?: number; + jti?: string; + method: string; + privateKey: string; + url: string; +}>; + +const MAKEPAY_MODAL_SCRIPT_CDN_URL = + "https://cdn.makepay.io/modal/makepay.min.js"; + export type MakePayPaymentLinkPayload = { title?: string; description?: string; amount: string | number; + fiatCurrency?: string; currency?: string; asset?: string; orderId?: string; @@ -311,6 +396,8 @@ export type MakePayPaymentLink = { label?: string | null; description?: string | null; amount?: string | number | null; + fiatAmount?: string | number | null; + fiatCurrency?: string | null; amountUsd?: string | number | null; currency?: string | null; asset?: string | null; @@ -401,6 +488,49 @@ export type MakePayWebhookRequestsResponse = { [key: string]: unknown; }; +export type MakePayWebhookSubscriptionEvent = + | "makepay.payment.*" + | `makepay.payment.${string}`; + +export type MakePayWebhookSubscriptionPayload = { + url: string; + events?: MakePayWebhookSubscriptionEvent[]; + active?: boolean; + description?: string | null; + metadata?: Record; + rotateSecret?: boolean; + [key: string]: unknown; +}; + +export type MakePayWebhookSubscription = { + id: string; + oauthGrantId: string; + companyId: string; + url: string; + events: MakePayWebhookSubscriptionEvent[]; + active: boolean; + status: "active" | "disabled"; + description: string | null; + metadata: Record; + secretLast4: string | null; + secretCreatedAt: string | null; + secretUpdatedAt: string | null; + createdAt: string; + updatedAt: string; + [key: string]: unknown; +}; + +export type MakePayWebhookSubscriptionResponse = { + companyId: string; + ok?: boolean; + created?: boolean; + rotated?: boolean; + subscription: MakePayWebhookSubscription | null; + /** Returned only when a subscription is created or its secret is rotated. */ + signingSecret?: string; + [key: string]: unknown; +}; + export type MakePayPosTerminal = { id?: string; uid?: string; @@ -706,16 +836,41 @@ export type MakePayPublicRequestOptions = { fetch?: typeof fetch; }; -export type CreatePaymentLinkOptions = { +export type MakePayIdempotencyOptions = { + /** 8-200 URL-safe characters; reuse only for an identical mutation. */ + idempotencyKey?: string; +}; + +export type CreatePaymentLinkOptions = MakePayIdempotencyOptions & { status?: "active" | "paused" | "archived"; sendPaymentRequestEmail?: boolean; }; +export type CreateDonationLinkOptions = Omit< + CreatePaymentLinkOptions, + "idempotencyKey" +>; + export type PaymentLinkStatusUpdate = { status: "active" | "paused" | "archived"; }; -export type MakePayRequestOptions = { +export type MakePayMedusaCorrelationMetadata = { + medusaOrderId?: string; + medusaOrderDisplayId?: string; + medusaAdminUrl?: string; + medusaInstallationId?: string; +}; + +export type MakePayPaymentLinkUpdate = { + status?: PaymentLinkStatusUpdate["status"]; + extendExpirationTime?: "15m" | "1h" | "12h" | "24h" | "72h" | "never"; + invoicePdfUrl?: string; + skipQuoteAcceptance?: boolean; + metadata?: MakePayMedusaCorrelationMetadata; +}; + +export type MakePayRequestOptions = MakePayIdempotencyOptions & { query?: Record; }; @@ -787,32 +942,40 @@ export class MakePayError extends Error { export class MakePayClient { static readonly defaultBaseUrl = "https://www.makecrypto.io"; - static readonly defaultCheckoutBaseUrl = "https://makepay.io"; - static readonly version = "0.3.2"; + static readonly defaultCheckoutBaseUrl = "https://www.makepay.io"; + static readonly version = "0.4.0"; private readonly baseUrl: string; + private readonly baseOrigin: string; private readonly checkoutBaseUrl: string; - private readonly keyId: string; - private readonly keySecret: string; + private readonly keyId?: string; + private readonly keySecret?: string; + private readonly authProvider?: MakePayAuthProvider; private readonly fetchImpl: typeof fetch; constructor(options: MakePayClientOptions) { - this.baseUrl = (options.baseUrl ?? MakePayClient.defaultBaseUrl).replace( - /\/+$/, - "", + const parsedBaseUrl = parseMakePayApiBaseUrl( + options.baseUrl ?? MakePayClient.defaultBaseUrl, ); + this.baseUrl = parsedBaseUrl.toString().replace(/\/+$/, ""); + this.baseOrigin = parsedBaseUrl.origin; this.checkoutBaseUrl = ( options.checkoutBaseUrl ?? MakePayClient.defaultCheckoutBaseUrl ).replace(/\/+$/, ""); this.keyId = options.keyId; this.keySecret = options.keySecret; + this.authProvider = options.authProvider; this.fetchImpl = options.fetch ?? globalThis.fetch; if (!this.fetchImpl) { throw new MakePayError("A fetch implementation is required."); } - if (!this.keyId || !this.keySecret) { - throw new MakePayError("MakePay keyId and keySecret are required."); + const hasApiKey = Boolean(this.keyId && this.keySecret); + const hasOAuthProvider = Boolean(this.authProvider); + if (hasApiKey === hasOAuthProvider) { + throw new MakePayError( + "Configure either MakePay keyId/keySecret or authProvider.", + ); } } @@ -820,11 +983,16 @@ export class MakePayClient { payload: MakePayPaymentLinkPayload, options: CreatePaymentLinkOptions = {}, ): Promise { - return this.request("POST", "/api/partner/v1/makepay/payment-links", { - status: options.status ?? "active", - sendPaymentRequestEmail: options.sendPaymentRequestEmail ?? false, - payload, - }); + return this.request( + "POST", + "/api/partner/v1/makepay/payment-links", + { + status: options.status ?? "active", + sendPaymentRequestEmail: options.sendPaymentRequestEmail ?? false, + payload, + }, + { idempotencyKey: options.idempotencyKey }, + ); } listPaymentLinks( @@ -851,7 +1019,8 @@ export class MakePayClient { updatePaymentLink( uid: string, - updates: PaymentLinkStatusUpdate, + updates: MakePayPaymentLinkUpdate, + options: MakePayIdempotencyOptions = {}, ): Promise { assertNonEmpty(uid, "Payment link UID is required."); @@ -859,6 +1028,7 @@ export class MakePayClient { "PATCH", `/api/partner/v1/makepay/payment-links/${encodeURIComponent(uid)}`, updates, + options, ); } @@ -877,16 +1047,20 @@ export class MakePayClient { createDonationLink( payload: MakePayDonationLinkPayload, - options: CreatePaymentLinkOptions = {}, + options: CreateDonationLinkOptions = {}, ): Promise { - return this.request("POST", "/api/partner/v1/makepay/donations", { - status: options.status ?? "active", - sendPaymentRequestEmail: options.sendPaymentRequestEmail ?? false, - payload: { - ...payload, - type: "donation", + return this.request( + "POST", + "/api/partner/v1/makepay/donations", + { + status: options.status ?? "active", + sendPaymentRequestEmail: options.sendPaymentRequestEmail ?? false, + payload: { + ...payload, + type: "donation", + }, }, - }); + ); } listDonationLinks(): Promise { @@ -969,6 +1143,38 @@ export class MakePayClient { ); } + getCurrentWebhookSubscription(): Promise { + return this.request( + "GET", + "/api/partner/v1/makepay/webhook-subscriptions/current", + ); + } + + upsertCurrentWebhookSubscription( + payload: MakePayWebhookSubscriptionPayload, + options: MakePayIdempotencyOptions = {}, + ): Promise { + assertNonEmpty(payload.url, "Webhook subscription URL is required."); + + return this.request( + "PUT", + "/api/partner/v1/makepay/webhook-subscriptions/current", + payload, + options, + ); + } + + deleteCurrentWebhookSubscription( + options: MakePayIdempotencyOptions = {}, + ): Promise { + return this.request( + "DELETE", + "/api/partner/v1/makepay/webhook-subscriptions/current", + undefined, + options, + ); + } + listPosTerminals(): Promise { return this.request("GET", "/api/partner/v1/makepay/pos-terminals"); } @@ -1409,36 +1615,27 @@ export class MakePayClient { } async request( - method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", + method: MakePayHttpMethod, path: string, body?: unknown, options: MakePayRequestOptions = {}, ): Promise { - const url = new URL(`${this.baseUrl}${path}`); + const url = this.buildApiUrl(path); for (const [key, value] of Object.entries(options.query ?? {})) { if (value !== null && value !== undefined) { url.searchParams.set(key, String(value)); } } - const headers = new Headers({ - accept: "application/json", - "user-agent": `MakePayJS/${MakePayClient.version}`, - "x-makecrypto-key-id": this.keyId, - "x-makecrypto-key-secret": this.keySecret, - }); - - const init: RequestInit = { - headers, + const requestBody = + body !== undefined && method !== "GET" ? JSON.stringify(body) : undefined; + const response = await this.authenticatedFetch( method, - }; - - if (body !== undefined && method !== "GET") { - headers.set("content-type", "application/json"); - init.body = JSON.stringify(body); - } - - const response = await this.fetchImpl(url, init); + url, + requestBody, + requestBody === undefined ? undefined : "application/json", + options, + ); return decodeMakePayResponse(response); } @@ -1448,28 +1645,130 @@ export class MakePayClient { body: FormData, options: MakePayRequestOptions = {}, ): Promise { - const url = new URL(`${this.baseUrl}${path}`); + const url = this.buildApiUrl(path); for (const [key, value] of Object.entries(options.query ?? {})) { if (value !== null && value !== undefined) { url.searchParams.set(key, String(value)); } } - const headers = new Headers({ - accept: "application/json", - "user-agent": `MakePayJS/${MakePayClient.version}`, - "x-makecrypto-key-id": this.keyId, - "x-makecrypto-key-secret": this.keySecret, - }); - - const response = await this.fetchImpl(url, { - body, - headers, + const response = await this.authenticatedFetch( method, - }); + url, + body, + undefined, + options, + ); return decodeMakePayResponse(response); } + + private buildApiUrl(path: string): URL { + if (!path.startsWith("/")) { + throw new MakePayError("MakePay API path must start with '/'."); + } + + const url = new URL(`${this.baseUrl}${path}`); + if ( + url.origin !== this.baseOrigin || + Boolean(url.username) || + Boolean(url.password) + ) { + throw new MakePayError("MakePay API path must remain on the base origin."); + } + + return url; + } + + private async authenticatedFetch( + method: MakePayHttpMethod, + url: URL, + body: BodyInit | undefined, + contentType: string | undefined, + options: MakePayRequestOptions, + ): Promise { + const idempotencyKey = options.idempotencyKey?.trim(); + if ( + options.idempotencyKey !== undefined && + (!idempotencyKey || !MAKEPAY_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) + ) { + throw new MakePayError( + "Idempotency key must contain 8 to 200 URL-safe characters.", + { status: 400 }, + ); + } + + for (let attempt = 0; attempt < 2; attempt += 1) { + const retry = attempt === 1; + const requestContext: MakePayAuthRequest = { + method, + retry, + url: url.toString(), + }; + const headers = new Headers({ + accept: "application/json", + "user-agent": `MakePayJS/${MakePayClient.version}`, + }); + + if (contentType) { + headers.set("content-type", contentType); + } + if (idempotencyKey) { + headers.set("idempotency-key", idempotencyKey); + } + + if (this.authProvider) { + const authorization = + await this.authProvider.getAuthorization(requestContext); + const accessToken = authorization.accessToken?.trim(); + if (!accessToken) { + throw new MakePayError( + "OAuth authProvider returned an empty access token.", + ); + } + + const tokenType = authorization.tokenType ?? "Bearer"; + if (tokenType === "DPoP" && !authorization.dpopProof?.trim()) { + throw new MakePayError( + "OAuth authProvider must return a DPoP proof for DPoP tokens.", + ); + } + + headers.set("authorization", `${tokenType} ${accessToken}`); + if (authorization.dpopProof) { + headers.set("dpop", authorization.dpopProof); + } + } else { + headers.set("x-makecrypto-key-id", this.keyId!); + headers.set("x-makecrypto-key-secret", this.keySecret!); + } + + const response = await this.fetchImpl(url, { + body, + headers, + method, + // Custom API-key and DPoP headers are not guaranteed to be stripped + // by every fetch implementation when following a cross-origin redirect. + redirect: "manual", + }); + + if ( + response.status === 401 && + attempt === 0 && + this.authProvider?.refreshAuthorization + ) { + await this.authProvider.refreshAuthorization({ + ...requestContext, + response, + }); + continue; + } + + return response; + } + + throw new MakePayError("MakePay authentication retry failed."); + } } export async function createAnonymousPaymentLink( @@ -1493,6 +1792,7 @@ export async function createAnonymousPaymentLink( body: JSON.stringify(payload), headers, method: "POST", + redirect: "manual", }); return decodeMakePayResponse(response) as Promise; @@ -1500,6 +1800,103 @@ export async function createAnonymousPaymentLink( export const createAnonymousMakePayPaymentLink = createAnonymousPaymentLink; +/** Generate a P-256 key pair suitable for MakeCrypto native OAuth DPoP. */ +export function generateMakePayDpopKeyPair(): MakePayDpopKeyPair { + const { privateKey, publicKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const publicJwk = normalizeMakePayDpopPublicJwk( + publicKey.export({ format: "jwk" }), + ); + + return { + privateKeyPem: privateKey + .export({ format: "pem", type: "pkcs8" }) + .toString(), + publicJwk, + thumbprint: calculateMakePayDpopJwkThumbprint(publicJwk), + }; +} + +/** Calculate the RFC 7638 SHA-256 thumbprint used as the DPoP `jkt`. */ +export function calculateMakePayDpopJwkThumbprint( + publicJwk: MakePayDpopPublicJwk, +): string { + const jwk = normalizeMakePayDpopPublicJwk(publicJwk); + const canonicalJwk = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); + + return createHash("sha256").update(canonicalJwk).digest("base64url"); +} + +/** Create a fresh ES256 DPoP proof for an API or OAuth token request. */ +export function createMakePayDpopProof( + options: MakePayDpopProofOptions, +): string { + const method = options.method.trim().toUpperCase(); + assertNonEmpty(method, "DPoP HTTP method is required."); + assertNonEmpty(options.privateKey, "DPoP private key is required."); + + let url: URL; + try { + url = new URL(options.url); + } catch { + throw new MakePayError("DPoP URL must be an absolute URL."); + } + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password + ) { + throw new MakePayError( + "DPoP URL must be an HTTP URL without embedded credentials.", + ); + } + // RFC 9449 section 4.2 defines `htu` without query and fragment parts. + url.search = ""; + url.hash = ""; + + let privateKey: ReturnType; + let publicJwk: MakePayDpopPublicJwk; + try { + privateKey = createPrivateKey(options.privateKey); + publicJwk = normalizeMakePayDpopPublicJwk( + createPublicKey(privateKey).export({ format: "jwk" }), + ); + } catch { + throw new MakePayError("Invalid P-256 DPoP private key."); + } + + const header = { + typ: "dpop+jwt", + alg: "ES256", + jwk: publicJwk, + }; + const payload: Record = { + htu: url.toString(), + htm: method, + iat: options.issuedAt ?? Math.floor(Date.now() / 1000), + jti: options.jti ?? randomUUID(), + }; + if (options.accessToken) { + payload.ath = createHash("sha256") + .update(options.accessToken) + .digest("base64url"); + } + + const signingInput = `${encodeBase64UrlJson(header)}.${encodeBase64UrlJson(payload)}`; + const signature = sign("sha256", Buffer.from(signingInput), { + dsaEncoding: "ieee-p1363", + key: privateKey, + }); + + return `${signingInput}.${signature.toString("base64url")}`; +} + export function buildMakePayHostedCheckoutUrl( paymentUid: string, options: MakePayCheckoutUrlOptions = {}, @@ -1565,10 +1962,17 @@ export function buildMakePayEmbeddedDonationUrl( export function buildMakePayModalScriptUrl( options: Pick = {}, ): string { - return new URL( - "/modal/makepay.js", - `${normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl)}/`, - ).toString(); + const baseUrl = options.baseUrl ? normalizeBaseUrl(options.baseUrl) : null; + + if ( + !baseUrl || + baseUrl === normalizeBaseUrl(MakePayClient.defaultCheckoutBaseUrl) || + baseUrl === "https://makepay.io" + ) { + return MAKEPAY_MODAL_SCRIPT_CDN_URL; + } + + return new URL("/modal/makepay.min.js", `${baseUrl}/`).toString(); } export function buildMakePayEmbedButtonHtml( @@ -1837,6 +2241,57 @@ function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); } +const MAKEPAY_IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._~:+\/=\-]{8,200}$/; + +function parseMakePayApiBaseUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new MakePayError("MakePay baseUrl must be an absolute HTTP URL."); + } + + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.search || + url.hash + ) { + throw new MakePayError( + "MakePay baseUrl must be an HTTP URL without credentials, query, or fragment.", + ); + } + + return url; +} + +function normalizeMakePayDpopPublicJwk( + value: JsonWebKey | MakePayDpopPublicJwk, +): MakePayDpopPublicJwk { + if ( + value.kty !== "EC" || + value.crv !== "P-256" || + typeof value.x !== "string" || + !value.x || + typeof value.y !== "string" || + !value.y + ) { + throw new MakePayError("DPoP key must be an EC P-256 key."); + } + + return { + crv: "P-256", + kty: "EC", + x: value.x, + y: value.y, + }; +} + +function encodeBase64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + function normalizeMakePayEmbedViewType( viewType: MakePayEmbedViewType | undefined, ): MakePayEmbedViewType | null { diff --git a/tests/run.mjs b/tests/run.mjs index 47db0ee..c37dc6c 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; -import { createHmac } from "node:crypto"; +import { + createHash, + createHmac, + createPublicKey, + verify as verifySignature, +} from "node:crypto"; import { MakePayClient, @@ -11,7 +16,10 @@ import { buildMakePayHostedDonationUrl, buildMakePayIframeHtml, buildMakePayModalScriptUrl, + calculateMakePayDpopJwkThumbprint, + createMakePayDpopProof, createAnonymousPaymentLink, + generateMakePayDpopKeyPair, parseMakePayWebhook, verifyMakePayWebhook, } from "../dist/index.js"; @@ -39,17 +47,32 @@ const client = new MakePayClient({ return new Response( JSON.stringify({ ok: true, - paymentLink: { publicUrl: "https://makepay.io/payment/test" }, + paymentLink: { publicUrl: "https://www.makepay.io/payment/test" }, }), { headers: { "content-type": "application/json" }, status: 201 }, ); }, }); -const response = await client.createPaymentLink({ - amount: "12.50", - currency: "USDT", -}); +const response = await client.createPaymentLink( + { + amount: "12.50", + currency: "USDT", + }, + { idempotencyKey: "order_123:payment-link:v1" }, +); +await client.updatePaymentLink( + "pay_123", + { + metadata: { + medusaAdminUrl: "https://merchant.example/app/orders/order_123", + medusaInstallationId: "installation_123", + medusaOrderDisplayId: "1042", + medusaOrderId: "order_123", + }, + }, + { idempotencyKey: "order_123:correlation:v1" }, +); await client.createDonationLink({ title: "Spring campaign", defaultAmountUsd: "25", @@ -70,6 +93,12 @@ await client.createSubscription({ }); await client.listDestinationAssets(); await client.listWebhookRequests({ limit: 10 }); +await client.getCurrentWebhookSubscription(); +await client.upsertCurrentWebhookSubscription( + { url: "https://merchant.example/webhooks/makepay" }, + { idempotencyKey: "installation_123:webhook:v1" }, +); +await client.deleteCurrentWebhookSubscription(); await client.listPosTerminals(); await client.createPosTerminal({ name: "Front counter", pin: "1234" }); await client.getPosTerminal("pos_123"); @@ -148,18 +177,38 @@ await client.createBookkeepingReconciliation({ }); assert.equal(response.ok, true); -assert.equal(requests.length, 53); +assert.equal(requests.length, 57); assert.match(requests[0].url, /\/api\/partner\/v1\/makepay\/payment-links$/); assert.equal(requests[0].init.method, "POST"); +assert.equal(requests[0].init.redirect, "manual"); assert.equal(requests[0].init.headers.get("x-makecrypto-key-id"), "mk_test"); assert.equal( requests[0].init.headers.get("x-makecrypto-key-secret"), "mksec_test", ); +assert.equal( + requests[0].init.headers.get("idempotency-key"), + "order_123:payment-link:v1", +); +assert.equal( + requests[1].init.headers.get("idempotency-key"), + "order_123:correlation:v1", +); +assert.deepEqual(JSON.parse(requests[1].init.body), { + metadata: { + medusaAdminUrl: "https://merchant.example/app/orders/order_123", + medusaInstallationId: "installation_123", + medusaOrderDisplayId: "1042", + medusaOrderId: "order_123", + }, +}); const requestRoutes = requests.map((request) => { const url = new URL(request.url); return `${request.init.method} ${url.pathname}${url.search}`; }); +assert.ok( + requestRoutes.includes("PATCH /api/partner/v1/makepay/payment-links/pay_123"), +); assert.ok(requestRoutes.includes("POST /api/partner/v1/makepay/donations")); assert.ok(requestRoutes.includes("GET /api/partner/v1/makepay/donations")); assert.ok(requestRoutes.includes("GET /api/partner/v1/makepay/customers")); @@ -172,6 +221,32 @@ assert.ok( "GET /api/partner/v1/makepay/webhook-requests?limit=10", ), ); +assert.ok( + requestRoutes.includes( + "GET /api/partner/v1/makepay/webhook-subscriptions/current", + ), +); +assert.ok( + requestRoutes.includes( + "PUT /api/partner/v1/makepay/webhook-subscriptions/current", + ), +); +const webhookSubscriptionPut = requests.find((request) => { + const url = new URL(request.url); + return ( + request.init.method === "PUT" && + url.pathname === "/api/partner/v1/makepay/webhook-subscriptions/current" + ); +}); +assert.equal( + webhookSubscriptionPut?.init.headers.get("idempotency-key"), + "installation_123:webhook:v1", +); +assert.ok( + requestRoutes.includes( + "DELETE /api/partner/v1/makepay/webhook-subscriptions/current", + ), +); assert.ok(requestRoutes.includes("POST /api/partner/v1/makepay/pos-terminals")); assert.ok( requestRoutes.includes("PATCH /api/partner/v1/makepay/pos-terminals/pos_123"), @@ -264,27 +339,30 @@ assert.equal(documentUploadRequest.init.body instanceof FormData, true); assert.equal(documentUploadRequest.init.headers.has("content-type"), false); assert.equal( client.hostedCheckoutUrl("pay_123"), - "https://makepay.io/payment/pay_123", + "https://www.makepay.io/payment/pay_123", ); assert.equal( client.hostedDonationUrl("spring-campaign"), - "https://makepay.io/donations/spring-campaign", + "https://www.makepay.io/donations/spring-campaign", ); assert.equal( client.embeddedCheckoutUrl("pay_123", { parentOrigin: "https://merchant.example", viewType: "minimal", }), - "https://makepay.io/embed/payment/pay_123?parentOrigin=https%3A%2F%2Fmerchant.example&viewType=minimal", + "https://www.makepay.io/embed/payment/pay_123?parentOrigin=https%3A%2F%2Fmerchant.example&viewType=minimal", ); assert.equal( client.embeddedDonationUrl("spring-campaign", { parentOrigin: "https://merchant.example", viewType: "minimal", }), - "https://makepay.io/embed/donations/spring-campaign?parentOrigin=https%3A%2F%2Fmerchant.example&viewType=minimal", + "https://www.makepay.io/embed/donations/spring-campaign?parentOrigin=https%3A%2F%2Fmerchant.example&viewType=minimal", +); +assert.equal( + client.modalScriptUrl(), + "https://cdn.makepay.io/modal/makepay.min.js", ); -assert.equal(client.modalScriptUrl(), "https://makepay.io/modal/makepay.js"); assert.equal( buildMakePayHostedCheckoutUrl("pay_123", { baseUrl: "https://pay.example/" }), "https://pay.example/payment/pay_123", @@ -313,7 +391,11 @@ assert.equal( ); assert.equal( buildMakePayModalScriptUrl({ baseUrl: "https://pay.example/" }), - "https://pay.example/modal/makepay.js", + "https://pay.example/modal/makepay.min.js", +); +assert.match( + buildMakePayEmbedButtonHtml("pay_123"), + /src="https:\/\/cdn\.makepay\.io\/modal\/makepay\.min\.js"/, ); assert.match( buildMakePayEmbedButtonHtml('pay_"<&', { @@ -334,7 +416,7 @@ assert.match( iframeTitle: "Secure checkout", viewType: "minimal", }), - /src="https:\/\/makepay\.io\/embed\/payment\/pay_123\?viewType=minimal"/, + /src="https:\/\/www\.makepay\.io\/embed\/payment\/pay_123\?viewType=minimal"/, ); const customCheckoutClient = new MakePayClient({ @@ -373,8 +455,249 @@ assert.match( /\/api\/partner\/v1\/makepay\/payment-links$/, ); assert.equal(anonymousRequest.init.method, "POST"); +assert.equal(anonymousRequest.init.redirect, "manual"); assert.equal(anonymousRequest.init.headers.get("x-makecrypto-key-id"), null); +const dpopKeyPair = generateMakePayDpopKeyPair(); +assert.match(dpopKeyPair.privateKeyPem, /BEGIN PRIVATE KEY/); +assert.equal(dpopKeyPair.publicJwk.kty, "EC"); +assert.equal(dpopKeyPair.publicJwk.crv, "P-256"); +assert.equal( + calculateMakePayDpopJwkThumbprint(dpopKeyPair.publicJwk), + dpopKeyPair.thumbprint, +); + +const dpopAccessToken = "mco_access_test"; +const dpopProof = createMakePayDpopProof({ + accessToken: dpopAccessToken, + issuedAt: 1_750_000_000, + jti: "proof_123", + method: "get", + privateKey: dpopKeyPair.privateKeyPem, + url: "https://www.makecrypto.io/api/partner/v1/makepay/payment-links?limit=10#ignored", +}); +const [dpopHeaderPart, dpopPayloadPart, dpopSignaturePart] = + dpopProof.split("."); +const dpopHeader = JSON.parse( + Buffer.from(dpopHeaderPart, "base64url").toString("utf8"), +); +const dpopPayload = JSON.parse( + Buffer.from(dpopPayloadPart, "base64url").toString("utf8"), +); +assert.deepEqual(dpopHeader, { + typ: "dpop+jwt", + alg: "ES256", + jwk: dpopKeyPair.publicJwk, +}); +assert.equal(dpopPayload.htm, "GET"); +assert.equal( + dpopPayload.htu, + "https://www.makecrypto.io/api/partner/v1/makepay/payment-links", +); +assert.equal(dpopPayload.iat, 1_750_000_000); +assert.equal(dpopPayload.jti, "proof_123"); +assert.equal( + dpopPayload.ath, + createHash("sha256").update(dpopAccessToken).digest("base64url"), +); +assert.equal( + verifySignature( + "sha256", + Buffer.from(`${dpopHeaderPart}.${dpopPayloadPart}`), + { + dsaEncoding: "ieee-p1363", + key: createPublicKey({ key: dpopKeyPair.publicJwk, format: "jwk" }), + }, + Buffer.from(dpopSignaturePart, "base64url"), + ), + true, +); + +const oauthRequests = []; +const authRequests = []; +let refreshCalls = 0; +const oauthClient = new MakePayClient({ + authProvider: { + async getAuthorization(request) { + authRequests.push(request); + const accessToken = request.retry ? "access_refreshed" : "access_stale"; + return { + accessToken, + tokenType: "DPoP", + dpopProof: createMakePayDpopProof({ + accessToken, + method: request.method, + privateKey: dpopKeyPair.privateKeyPem, + url: request.url, + }), + }; + }, + async refreshAuthorization(request) { + refreshCalls += 1; + assert.equal(request.retry, false); + assert.equal(request.response.status, 401); + }, + }, + fetch: async (url, init) => { + oauthRequests.push({ init, url: String(url) }); + if (oauthRequests.length === 1) { + return new Response(JSON.stringify({ error: "expired" }), { + headers: { "content-type": "application/json" }, + status: 401, + }); + } + + return new Response( + JSON.stringify({ ok: true, paymentLink: { uid: "pay_oauth" } }), + { headers: { "content-type": "application/json" }, status: 200 }, + ); + }, +}); +const oauthResponse = await oauthClient.getPaymentLink("pay_oauth"); +assert.equal(oauthResponse.paymentLink.uid, "pay_oauth"); +assert.equal(oauthRequests.length, 2); +assert.equal(authRequests.length, 2); +assert.deepEqual( + authRequests.map(({ retry }) => retry), + [false, true], +); +assert.equal(refreshCalls, 1); +assert.equal( + oauthRequests[0].init.headers.get("authorization"), + "DPoP access_stale", +); +assert.equal( + oauthRequests[1].init.headers.get("authorization"), + "DPoP access_refreshed", +); +assert.ok(oauthRequests[0].init.headers.get("dpop")); +assert.ok(oauthRequests[1].init.headers.get("dpop")); +assert.notEqual( + oauthRequests[0].init.headers.get("dpop"), + oauthRequests[1].init.headers.get("dpop"), +); +assert.equal(oauthRequests[0].init.headers.get("x-makecrypto-key-id"), null); + +let persistentUnauthorizedRequests = 0; +let persistentRefreshCalls = 0; +const persistentUnauthorizedClient = new MakePayClient({ + authProvider: { + async getAuthorization() { + return { accessToken: "still_expired" }; + }, + async refreshAuthorization() { + persistentRefreshCalls += 1; + }, + }, + fetch: async () => { + persistentUnauthorizedRequests += 1; + return new Response(JSON.stringify({ error: "unauthorized" }), { + headers: { "content-type": "application/json" }, + status: 401, + }); + }, +}); +await assert.rejects( + () => persistentUnauthorizedClient.listPaymentLinks(), + (error) => error instanceof MakePayError && error.status === 401, +); +assert.equal(persistentUnauthorizedRequests, 2); +assert.equal(persistentRefreshCalls, 1); + +await assert.rejects( + () => + new MakePayClient({ + authProvider: { + async getAuthorization() { + return { accessToken: "bound", tokenType: "DPoP" }; + }, + }, + fetch: async () => { + throw new Error("fetch must not be reached"); + }, + }).listPaymentLinks(), + /must return a DPoP proof/, +); + +await assert.rejects( + () => + client.createPaymentLink( + { amount: "5", currency: "USD" }, + { idempotencyKey: " " }, + ), + /8 to 200 URL-safe characters/, +); + +await assert.rejects( + () => + client.createPaymentLink( + { amount: "5", currency: "USD" }, + { idempotencyKey: "short" }, + ), + /8 to 200 URL-safe characters/, +); + +let escapedOriginFetches = 0; +const originGuardClient = new MakePayClient({ + keyId: "mk_guard", + keySecret: "mksec_guard", + fetch: async () => { + escapedOriginFetches += 1; + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }, +}); +await assert.rejects( + () => originGuardClient.request("GET", "@attacker.example/credentials"), + /path must start with '\/'/, +); +assert.equal(escapedOriginFetches, 0); + +assert.throws( + () => + new MakePayClient({ + baseUrl: "https://user:password@www.makecrypto.io", + keyId: "mk_guard", + keySecret: "mksec_guard", + }), + /without credentials, query, or fragment/, +); + +assert.throws( + () => + createMakePayDpopProof({ + method: "POST", + privateKey: "not-a-private-key", + url: "https://www.makecrypto.io/oauth/token", + }), + /Invalid P-256 DPoP private key/, +); + +assert.throws( + () => + createMakePayDpopProof({ + method: "POST", + privateKey: dpopKeyPair.privateKeyPem, + url: "https://user:password@www.makecrypto.io/oauth/token", + }), + /without embedded credentials/, +); + +assert.throws( + () => + new MakePayClient({ + authProvider: { + async getAuthorization() { + return { accessToken: "token" }; + }, + }, + keyId: "mk_test", + keySecret: "mksec_test", + }), + /either MakePay keyId\/keySecret or authProvider/, +); + assert.throws(() => { new MakePayClient({ keyId: "", diff --git a/tsconfig.json b/tsconfig.json index 39dfabc..eaf5622 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "declaration": true, - "declarationMap": true, + "declarationMap": false, "emitDeclarationOnly": false, "lib": ["ES2022", "DOM"], "module": "NodeNext", From 990ab3f3c7ef65168917404959a44fdc185d7163 Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 18:49:51 +0400 Subject: [PATCH 2/9] Harden SDK API contracts and packaging --- CHANGELOG.md | 3 + README.md | 22 ++- package.json | 6 +- scripts/verify-package.mjs | 18 ++- src/index.ts | 128 ++++++++++++--- tests/run.mjs | 311 ++++++++++++++++++++++++++++++++----- tests/types.ts | 64 ++++++++ tsconfig.types.json | 13 ++ 8 files changed, 494 insertions(+), 71 deletions(-) create mode 100644 tests/types.ts create mode 100644 tsconfig.types.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f888dcf..4830b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ All notable changes to `@makecrypto/makepay` are documented here. - The default hosted checkout and embedded checkout URLs use the canonical `www.makepay.io` origin; the production modal loader uses the MakePay CDN. - Published JavaScript and declarations no longer include source maps. +- Authenticated payment-link detail, update, and list response types now match + the partner-v1 envelope and retain the typed nested payment-link payload. +- Packaging always rebuilds through `prepack` before npm creates an artifact. ### Compatibility diff --git a/README.md b/README.md index 2665cd8..287413a 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,8 @@ await makepay.listWebhookRequests({ limit: 25 }); OAuth integrations should use a grant-scoped webhook subscription rather than changing a company-global callback URL. The signing secret is returned only on -creation or explicit rotation, so persist it immediately. +creation or explicit rotation, so persist it immediately. PUT and DELETE +require an idempotency key. ```ts const created = await makepay.upsertCurrentWebhookSubscription( @@ -420,7 +421,9 @@ const created = await makepay.upsertCurrentWebhookSubscription( ); await makepay.getCurrentWebhookSubscription(); -await makepay.deleteCurrentWebhookSubscription(); +await makepay.deleteCurrentWebhookSubscription({ + idempotencyKey: "installation_123:webhook-delete:v1", +}); ``` ## Webhook Verification @@ -474,11 +477,14 @@ secondary SDK package. ```ts import type { + MakePayAnonymousPaymentLinkResponse, MakePayBookkeepingInvoicePayload, MakePayBookkeepingSummaryResponse, MakePayAuthProvider, + MakePayDonationLinksResponse, MakePayPaymentLinkPayload, MakePayPaymentLinkResponse, + MakePayPaymentRequestEmailResponse, MakePayWebhookSubscriptionResponse, } from "@makecrypto/makepay"; ``` @@ -493,6 +499,10 @@ Model conventions: use `YYYY-MM-DD`. - IDs are usually public `uid` values. Bookkeeping detail endpoints accept an internal UUID or public UID. +- Authenticated partner-v1 payment links retain the original values under + `paymentLink.payload` and also expose normalized `amount`, `fiatCurrency`, + `metadata`, correlation fields, latest session, and timeline fields directly + on `paymentLink`. - API methods throw `MakePayError` for non-2xx responses. Successful responses are typed envelopes with index signatures, so production can add fields without breaking TypeScript consumers. @@ -521,9 +531,11 @@ Model conventions: | Functions | Resolves to | Key fields | | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `createPaymentLink`, `getPaymentLink`, `updatePaymentLink`, `sendPaymentRequestEmail`, donation variants | `MakePayPaymentLinkResponse` | `ok`, `companyId`, `paymentLink`, `paymentRequestEmailSent`, `paymentRequestEmailError` | -| `listPaymentLinks`, `listDonationLinks` | `MakePayPaymentLinksResponse` | `companyId`, `paymentLinks` | -| `createAnonymousPaymentLink` | `MakePayPaymentLinkResponse` | `paymentLink`, plus public-link metadata returned by the API | +| `createPaymentLink`, `getPaymentLink`, `updatePaymentLink`, donation create/detail/update | `MakePayPaymentLinkResponse` | required `companyId`; normalized `paymentLink` plus retained `paymentLink.payload` | +| `listPaymentLinks` | `MakePayPaymentLinksResponse` | required `companyId`; `paymentLinks[]` uses the same canonical partner-v1 link shape | +| `listDonationLinks` | `MakePayDonationLinksResponse` | required `companyId`, `donations` | +| `sendPaymentRequestEmail` | `MakePayPaymentRequestEmailResponse` | `ok`, `email`, and the updated payment-link email payload | +| `createAnonymousPaymentLink` | `MakePayAnonymousPaymentLinkResponse` | `anonymous`, `requestId`, public `paymentLink`, and optional one-time webhook secret | | `listCustomers`, `upsertCustomer`, `createCustomerPortal` | `MakePayCustomersResponse` or `MakePayCustomerResponse` | `customers`, `customer`, `portalUrl` or `url` | | `listSubscriptions`, `createSubscription` | `MakePaySubscriptionsResponse` or `MakePaySubscriptionResponse` | `subscriptions`, `subscription` | | `listPosTerminals`, `createPosTerminal`, `getPosTerminal`, `updatePosTerminal` | `MakePayPosTerminalsResponse` or `MakePayPosTerminalResponse` | `terminals`/`posTerminals`, `terminal`/`posTerminal` | diff --git a/package.json b/package.json index 8a3139d..45fa8ba 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "test": "node tests/run.mjs", - "pack:dry-run": "npm pack --dry-run" + "test": "node tests/run.mjs && npm run test:types", + "test:types": "tsc -p tsconfig.types.json", + "pack:dry-run": "npm pack --dry-run", + "prepack": "npm run build" } } diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 4d13ef4..f00d48d 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -10,7 +10,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse( @@ -169,8 +169,22 @@ try { } } + const exported = await import( + `${pathToFileURL(join(extractedRoot, "dist/index.js")).href}?verify=${Date.now()}` + ); + for (const exportName of [ + "MakePayClient", + "createAnonymousPaymentLink", + "createMakePayDpopProof", + "generateMakePayDpopKeyPair", + ]) { + if (typeof exported[exportName] !== "function") { + fail(`Packed tarball is missing working export ${exportName}.`); + } + } + console.log( - `Verified ${packageJson.name}@${packageJson.version}: exact artifact allowlist, no source maps, and no recognized secrets.`, + `Verified ${packageJson.name}@${packageJson.version}: exact artifact allowlist, working runtime exports, no source maps, and no recognized secrets.`, ); } finally { if (tarballPath) rmSync(tarballPath, { force: true }); diff --git a/src/index.ts b/src/index.ts index 41a42db..1770805 100644 --- a/src/index.ts +++ b/src/index.ts @@ -385,27 +385,49 @@ export type MakePayCompanyReference = { [key: string]: unknown; }; +/** The original merchant payload retained by the partner-v1 serializer. */ +export type MakePayPaymentLinkResponsePayload = { + amount?: string | number; + fiatCurrency?: string; + metadata?: Record; + [key: string]: unknown; +}; + +/** + * Canonical authenticated partner-v1 payment-link shape. The API retains the + * original payload and also exposes normalized correlation fields at the link + * level for back-office consumers. + */ export type MakePayPaymentLink = { - id?: string; - uid?: string; - publicUrl?: string; + id: string; + uid: string; + publicUrl: string; checkoutUrl?: string; - status?: string; + status: string; type?: string; - title?: string | null; - label?: string | null; - description?: string | null; - amount?: string | number | null; - fiatAmount?: string | number | null; - fiatCurrency?: string | null; + donation_slug: string | null; + link_type: string | null; + source: string | null; + title: string | null; + label: string | null; + description: string | null; + amount: string | null; + fiatAmount: string | null; + fiatCurrency: string | null; amountUsd?: string | number | null; - currency?: string | null; - asset?: string | null; - orderId?: string | null; - clientId?: string | null; - customerEmail?: string | null; + currency: string | null; + asset: string | null; + orderId: string | null; + clientId: string | null; + customerEmail: string | null; donationSlug?: string | null; - metadata?: Record; + metadata: Record; + payload: MakePayPaymentLinkResponsePayload; + latestSession: Record | null; + timelineEvents: unknown[]; + created_at: string; + updated_at: string | null; + expires_at: string | null; createdAt?: string; updatedAt?: string; [key: string]: unknown; @@ -413,7 +435,7 @@ export type MakePayPaymentLink = { export type MakePayPaymentLinkResponse = { ok?: boolean; - companyId?: string; + companyId: string; paymentLink: MakePayPaymentLink; paymentRequestEmailSent?: boolean; paymentRequestEmailError?: string | null; @@ -421,11 +443,62 @@ export type MakePayPaymentLinkResponse = { }; export type MakePayPaymentLinksResponse = { - companyId?: string; + companyId: string; paymentLinks: MakePayPaymentLink[]; [key: string]: unknown; }; +export type MakePayDonationLinksResponse = { + companyId: string; + donations: MakePayPaymentLink[]; + [key: string]: unknown; +}; + +export type MakePayPaymentRequestEmailResponse = { + ok: boolean; + email: string; + paymentLink: { + id: string; + uid: string; + donation_slug: string | null; + link_type: string; + status: string; + payload: MakePayPaymentLinkResponsePayload; + publicUrl: string; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +export type MakePayAnonymousPaymentLinkResponse = { + ok: boolean; + anonymous: true; + paymentRequestEmailSent: false; + paymentRequestEmailError: null; + paymentLink: { + id: string; + uid: string; + status: string; + link_type: "one_time"; + payload: MakePayPaymentLinkResponsePayload; + settlement: Record; + branding: Record; + publicUrl: string; + expiresAt: string | null; + expires_at: string | null; + created_at: string; + updated_at: string; + webhook: { + url: string; + secret: string; + secretLast4: string; + } | null; + [key: string]: unknown; + }; + requestId: string; + [key: string]: unknown; +}; + export type MakePayCustomer = { id?: string; uid?: string; @@ -841,6 +914,11 @@ export type MakePayIdempotencyOptions = { idempotencyKey?: string; }; +export type MakePayRequiredIdempotencyOptions = { + /** 8-200 URL-safe characters; reuse only for an identical mutation. */ + idempotencyKey: string; +}; + export type CreatePaymentLinkOptions = MakePayIdempotencyOptions & { status?: "active" | "paused" | "archived"; sendPaymentRequestEmail?: boolean; @@ -1035,7 +1113,7 @@ export class MakePayClient { sendPaymentRequestEmail( uid: string, email?: string, - ): Promise { + ): Promise { assertNonEmpty(uid, "Payment link UID is required."); return this.request( @@ -1063,7 +1141,7 @@ export class MakePayClient { ); } - listDonationLinks(): Promise { + listDonationLinks(): Promise { return this.request("GET", "/api/partner/v1/makepay/donations"); } @@ -1152,7 +1230,7 @@ export class MakePayClient { upsertCurrentWebhookSubscription( payload: MakePayWebhookSubscriptionPayload, - options: MakePayIdempotencyOptions = {}, + options: MakePayRequiredIdempotencyOptions, ): Promise { assertNonEmpty(payload.url, "Webhook subscription URL is required."); @@ -1165,7 +1243,7 @@ export class MakePayClient { } deleteCurrentWebhookSubscription( - options: MakePayIdempotencyOptions = {}, + options: MakePayRequiredIdempotencyOptions, ): Promise { return this.request( "DELETE", @@ -1774,7 +1852,7 @@ export class MakePayClient { export async function createAnonymousPaymentLink( payload: MakePayAnonymousPaymentLinkPayload, options: MakePayPublicRequestOptions = {}, -): Promise { +): Promise { const fetchImpl = options.fetch ?? globalThis.fetch; if (!fetchImpl) { throw new MakePayError("A fetch implementation is required."); @@ -1795,7 +1873,9 @@ export async function createAnonymousPaymentLink( redirect: "manual", }); - return decodeMakePayResponse(response) as Promise; + return decodeMakePayResponse( + response, + ) as Promise; } export const createAnonymousMakePayPaymentLink = createAnonymousPaymentLink; diff --git a/tests/run.mjs b/tests/run.mjs index c37dc6c..25ee066 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -93,12 +93,6 @@ await client.createSubscription({ }); await client.listDestinationAssets(); await client.listWebhookRequests({ limit: 10 }); -await client.getCurrentWebhookSubscription(); -await client.upsertCurrentWebhookSubscription( - { url: "https://merchant.example/webhooks/makepay" }, - { idempotencyKey: "installation_123:webhook:v1" }, -); -await client.deleteCurrentWebhookSubscription(); await client.listPosTerminals(); await client.createPosTerminal({ name: "Front counter", pin: "1234" }); await client.getPosTerminal("pos_123"); @@ -177,7 +171,7 @@ await client.createBookkeepingReconciliation({ }); assert.equal(response.ok, true); -assert.equal(requests.length, 57); +assert.equal(requests.length, 54); assert.match(requests[0].url, /\/api\/partner\/v1\/makepay\/payment-links$/); assert.equal(requests[0].init.method, "POST"); assert.equal(requests[0].init.redirect, "manual"); @@ -221,32 +215,6 @@ assert.ok( "GET /api/partner/v1/makepay/webhook-requests?limit=10", ), ); -assert.ok( - requestRoutes.includes( - "GET /api/partner/v1/makepay/webhook-subscriptions/current", - ), -); -assert.ok( - requestRoutes.includes( - "PUT /api/partner/v1/makepay/webhook-subscriptions/current", - ), -); -const webhookSubscriptionPut = requests.find((request) => { - const url = new URL(request.url); - return ( - request.init.method === "PUT" && - url.pathname === "/api/partner/v1/makepay/webhook-subscriptions/current" - ); -}); -assert.equal( - webhookSubscriptionPut?.init.headers.get("idempotency-key"), - "installation_123:webhook:v1", -); -assert.ok( - requestRoutes.includes( - "DELETE /api/partner/v1/makepay/webhook-subscriptions/current", - ), -); assert.ok(requestRoutes.includes("POST /api/partner/v1/makepay/pos-terminals")); assert.ok( requestRoutes.includes("PATCH /api/partner/v1/makepay/pos-terminals/pos_123"), @@ -337,6 +305,111 @@ const documentUploadRequest = requests.find((request) => { assert.ok(documentUploadRequest); assert.equal(documentUploadRequest.init.body instanceof FormData, true); assert.equal(documentUploadRequest.init.headers.has("content-type"), false); + +const canonicalPayload = { + amount: "12.50", + fiatCurrency: "USD", + metadata: { + medusaOrderId: "order_123", + source: "medusa", + }, + title: "Order #123", +}; +const canonicalPaymentLink = { + id: "4d2a248a-4636-4c98-8c68-87db803a2f7e", + uid: "pay_contract", + donation_slug: null, + link_type: "one_time", + source: "api", + publicUrl: "https://www.makepay.io/payment/pay_contract", + status: "active", + amount: "12.50", + fiatAmount: "12.50", + fiatCurrency: "USD", + currency: "USDT", + asset: "ETH.USDT-0xcontract", + title: "Order #123", + label: "Order #123", + description: null, + orderId: "order_123", + customerEmail: null, + clientId: null, + metadata: canonicalPayload.metadata, + payload: canonicalPayload, + latestSession: { id: "session_123", status: "pending" }, + timelineEvents: [{ type: "payment_link_created" }], + created_at: "2026-07-19T10:00:00.000Z", + updated_at: "2026-07-19T10:01:00.000Z", + expires_at: "2026-07-20T10:00:00.000Z", +}; +const partnerContractRequests = []; +const partnerContractClient = new MakePayClient({ + keyId: "mk_contract", + keySecret: "mksec_contract", + fetch: async (url, init) => { + const parsedUrl = new URL(String(url)); + partnerContractRequests.push({ init, url: parsedUrl }); + const isList = parsedUrl.pathname.endsWith("/payment-links"); + return new Response( + JSON.stringify( + isList + ? { + companyId: "company_contract", + paymentLinks: [canonicalPaymentLink], + } + : { + ...(init.method === "PATCH" ? { ok: true } : {}), + companyId: "company_contract", + paymentLink: canonicalPaymentLink, + }, + ), + { headers: { "content-type": "application/json" }, status: 200 }, + ); + }, +}); + +const contractList = await partnerContractClient.listPaymentLinks(); +const contractDetail = await partnerContractClient.getPaymentLink( + "pay_contract", +); +const contractUpdate = await partnerContractClient.updatePaymentLink( + "pay_contract", + { status: "paused" }, + { idempotencyKey: "order_123:update:v1" }, +); + +assert.equal(contractList.companyId, "company_contract"); +assert.equal(contractDetail.companyId, "company_contract"); +assert.equal(contractUpdate.companyId, "company_contract"); +assert.equal(contractUpdate.ok, true); +for (const paymentLink of [ + contractList.paymentLinks[0], + contractDetail.paymentLink, + contractUpdate.paymentLink, +]) { + assert.equal(paymentLink.payload.amount, "12.50"); + assert.equal(paymentLink.payload.fiatCurrency, "USD"); + assert.deepEqual(paymentLink.payload.metadata, canonicalPayload.metadata); + assert.equal(paymentLink.amount, paymentLink.payload.amount); + assert.equal(paymentLink.fiatCurrency, paymentLink.payload.fiatCurrency); + assert.deepEqual(paymentLink.metadata, paymentLink.payload.metadata); + assert.equal(paymentLink.latestSession.id, "session_123"); + assert.equal(paymentLink.timelineEvents.length, 1); +} +assert.deepEqual( + partnerContractRequests.map( + ({ init, url }) => `${init.method} ${url.pathname}`, + ), + [ + "GET /api/partner/v1/makepay/payment-links", + "GET /api/partner/v1/makepay/payment-links/pay_contract", + "PATCH /api/partner/v1/makepay/payment-links/pay_contract", + ], +); +assert.equal( + partnerContractRequests[2].init.headers.get("idempotency-key"), + "order_123:update:v1", +); assert.equal( client.hostedCheckoutUrl("pay_123"), "https://www.makepay.io/payment/pay_123", @@ -442,14 +515,47 @@ const anonymousResponse = await createAnonymousPaymentLink( { fetch: async (url, init) => { anonymousRequest = { init, url: String(url) }; - return new Response(JSON.stringify({ ok: true, anonymous: true }), { - headers: { "content-type": "application/json" }, - status: 201, - }); + return new Response( + JSON.stringify({ + ok: true, + anonymous: true, + paymentRequestEmailSent: false, + paymentRequestEmailError: null, + paymentLink: { + id: "anonymous-link-id", + uid: "anonymous-link-uid", + status: "active", + link_type: "one_time", + payload: { amount: "5", fiatCurrency: "USD" }, + settlement: { + currency: "USDT", + priorities: [ + { chain: "ETH", address: "0xabc", asset: "ETH.USDT-0xabc" }, + ], + }, + branding: {}, + publicUrl: + "https://www.makepay.io/payment/anonymous-link-uid", + expiresAt: "2026-07-20T10:00:00.000Z", + expires_at: "2026-07-20T10:00:00.000Z", + created_at: "2026-07-19T10:00:00.000Z", + updated_at: "2026-07-19T10:00:00.000Z", + webhook: null, + }, + requestId: "anonymous-request-id", + }), + { + headers: { "content-type": "application/json" }, + status: 201, + }, + ); }, }, ); assert.equal(anonymousResponse.anonymous, true); +assert.equal(anonymousResponse.requestId, "anonymous-request-id"); +assert.equal(anonymousResponse.paymentLink.payload.amount, "5"); +assert.equal(anonymousResponse.paymentLink.webhook, null); assert.match( anonymousRequest.url, /\/api\/partner\/v1\/makepay\/payment-links$/, @@ -548,13 +654,21 @@ const oauthClient = new MakePayClient({ } return new Response( - JSON.stringify({ ok: true, paymentLink: { uid: "pay_oauth" } }), + JSON.stringify({ + companyId: "company_oauth", + paymentLink: { + ...canonicalPaymentLink, + uid: "pay_oauth", + }, + }), { headers: { "content-type": "application/json" }, status: 200 }, ); }, }); const oauthResponse = await oauthClient.getPaymentLink("pay_oauth"); assert.equal(oauthResponse.paymentLink.uid, "pay_oauth"); +assert.equal(oauthResponse.companyId, "company_oauth"); +assert.equal(oauthResponse.paymentLink.payload.amount, "12.50"); assert.equal(oauthRequests.length, 2); assert.equal(authRequests.length, 2); assert.deepEqual( @@ -578,6 +692,127 @@ assert.notEqual( ); assert.equal(oauthRequests[0].init.headers.get("x-makecrypto-key-id"), null); +const webhookSubscription = { + id: "7f74e4ca-014e-4cf7-a17a-f8ef6f2eabf2", + oauthGrantId: "01c3075c-35f2-4afb-8461-603fb6b7f489", + companyId: "company_oauth", + url: "https://merchant.example/webhooks/makepay", + events: ["makepay.payment.*"], + active: true, + status: "active", + description: "Medusa store", + metadata: { integration: "medusa" }, + secretLast4: "d1f0", + secretCreatedAt: "2026-07-19T10:00:00.000Z", + secretUpdatedAt: "2026-07-19T10:00:00.000Z", + createdAt: "2026-07-19T10:00:00.000Z", + updatedAt: "2026-07-19T10:00:00.000Z", +}; +const webhookSubscriptionRequests = []; +const webhookSubscriptionClient = new MakePayClient({ + authProvider: { + async getAuthorization(request) { + const accessToken = "webhook_access"; + return { + accessToken, + tokenType: "DPoP", + dpopProof: createMakePayDpopProof({ + accessToken, + method: request.method, + privateKey: dpopKeyPair.privateKeyPem, + url: request.url, + }), + }; + }, + }, + fetch: async (url, init) => { + webhookSubscriptionRequests.push({ init, url: String(url) }); + const responseSubscription = + init.method === "DELETE" + ? { + ...webhookSubscription, + active: false, + status: "disabled", + secretLast4: null, + } + : webhookSubscription; + return new Response( + JSON.stringify({ + ...(init.method === "PUT" + ? { + ok: true, + created: true, + rotated: false, + signingSecret: "mkwhsec_test_once", + } + : init.method === "DELETE" + ? { ok: true } + : {}), + companyId: "company_oauth", + subscription: responseSubscription, + }), + { headers: { "content-type": "application/json" }, status: 200 }, + ); + }, +}); + +const currentWebhookSubscription = + await webhookSubscriptionClient.getCurrentWebhookSubscription(); +const createdWebhookSubscription = + await webhookSubscriptionClient.upsertCurrentWebhookSubscription( + { + url: webhookSubscription.url, + events: ["makepay.payment.*"], + metadata: { integration: "medusa" }, + }, + { idempotencyKey: "installation_123:webhook:v1" }, + ); +const deletedWebhookSubscription = + await webhookSubscriptionClient.deleteCurrentWebhookSubscription({ + idempotencyKey: "installation_123:webhook-delete:v1", + }); + +assert.equal(currentWebhookSubscription.companyId, "company_oauth"); +assert.equal( + currentWebhookSubscription.subscription?.url, + webhookSubscription.url, +); +assert.equal(currentWebhookSubscription.signingSecret, undefined); +assert.equal(createdWebhookSubscription.created, true); +assert.equal(createdWebhookSubscription.signingSecret, "mkwhsec_test_once"); +assert.equal(deletedWebhookSubscription.subscription?.status, "disabled"); +assert.equal(deletedWebhookSubscription.signingSecret, undefined); +assert.deepEqual( + webhookSubscriptionRequests.map((request) => { + const url = new URL(request.url); + return `${request.init.method} ${url.pathname}`; + }), + [ + "GET /api/partner/v1/makepay/webhook-subscriptions/current", + "PUT /api/partner/v1/makepay/webhook-subscriptions/current", + "DELETE /api/partner/v1/makepay/webhook-subscriptions/current", + ], +); +for (const request of webhookSubscriptionRequests) { + assert.equal(request.init.headers.get("authorization"), "DPoP webhook_access"); + assert.ok(request.init.headers.get("dpop")); + assert.equal(request.init.headers.get("x-makecrypto-key-id"), null); + assert.equal(request.init.redirect, "manual"); +} +assert.equal( + webhookSubscriptionRequests[1].init.headers.get("idempotency-key"), + "installation_123:webhook:v1", +); +assert.equal( + webhookSubscriptionRequests[2].init.headers.get("idempotency-key"), + "installation_123:webhook-delete:v1", +); +assert.deepEqual(JSON.parse(webhookSubscriptionRequests[1].init.body), { + url: webhookSubscription.url, + events: ["makepay.payment.*"], + metadata: { integration: "medusa" }, +}); + let persistentUnauthorizedRequests = 0; let persistentRefreshCalls = 0; const persistentUnauthorizedClient = new MakePayClient({ diff --git a/tests/types.ts b/tests/types.ts new file mode 100644 index 0000000..6d3751b --- /dev/null +++ b/tests/types.ts @@ -0,0 +1,64 @@ +import type { + MakePayClient, + MakePayPaymentLinkResponse, + MakePayPaymentLinksResponse, + MakePayWebhookSubscriptionResponse, +} from "../dist/index.js"; + +declare const client: MakePayClient; + +async function assertPartnerV1ResponseTypes() { + const detail: MakePayPaymentLinkResponse = + await client.getPaymentLink("pay_123"); + const updated: MakePayPaymentLinkResponse = await client.updatePaymentLink( + "pay_123", + { status: "paused" }, + { idempotencyKey: "order_123:update:v1" }, + ); + const list: MakePayPaymentLinksResponse = await client.listPaymentLinks(); + + const companyIds: string[] = [ + detail.companyId, + updated.companyId, + list.companyId, + ]; + const amount: string | number | undefined = detail.paymentLink.payload.amount; + const fiatCurrency: string | undefined = + detail.paymentLink.payload.fiatCurrency; + const metadata: Record | undefined = + detail.paymentLink.payload.metadata; + const normalizedAmount: string | null = detail.paymentLink.amount; + const normalizedMetadata: Record = + detail.paymentLink.metadata; + const paymentLinkId: string = detail.paymentLink.id; + const paymentLinkUid: string = detail.paymentLink.uid; + const paymentLinkStatus: string = detail.paymentLink.status; + const paymentLinkPublicUrl: string = detail.paymentLink.publicUrl; + const paymentLinkCreatedAt: string = detail.paymentLink.created_at; + const listedAmount: string | number | undefined = + list.paymentLinks[0]?.payload.amount; + + const webhook: MakePayWebhookSubscriptionResponse = + await client.getCurrentWebhookSubscription(); + const webhookCompanyId: string = webhook.companyId; + const subscriptionUrl: string | undefined = webhook.subscription?.url; + + return { + amount, + companyIds, + fiatCurrency, + listedAmount, + metadata, + normalizedAmount, + normalizedMetadata, + paymentLinkCreatedAt, + paymentLinkId, + paymentLinkPublicUrl, + paymentLinkStatus, + paymentLinkUid, + subscriptionUrl, + webhookCompanyId, + }; +} + +void assertPartnerV1ResponseTypes; diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..3182192 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["tests/types.ts"] +} From 19ebb391a5da94573439e6f2f6cf171cb6290d3e Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 20:40:29 +0400 Subject: [PATCH 3/9] Pin GitHub Actions to immutable SHAs --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffef6b2..a8c6674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: ${{ matrix.node }} cache: npm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d3004f4..f47295b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,12 +22,12 @@ jobs: steps: - name: Checkout the release commit - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Setup Node.js for trusted publishing - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: 24 package-manager-cache: false From a5428553337c9963ab3c8060a1354e4b19c8a836 Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 21:38:33 +0400 Subject: [PATCH 4/9] Harden SDK network transport validation --- CHANGELOG.md | 7 ++ README.md | 15 ++- src/index.ts | 144 ++++++++++++++++------ tests/run.mjs | 329 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 454 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4830b49..654ae22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ All notable changes to `@makecrypto/makepay` are documented here. - Requests refuse cross-origin path escapes and use manual redirect handling so credentials and DPoP proofs are never automatically forwarded to a redirect target. +- API, anonymous, checkout, script, iframe, and DPoP transport URLs require + HTTPS; HTTP is limited to exact loopback hosts for local testing, and base + URLs remain origin-only. +- Embedded-checkout parent origins are normalized and validated before they are + serialized or used for browser message targeting. +- Webhook verification rejects non-finite or non-positive timestamp tolerances + instead of allowing them to disable freshness checks. - The default hosted checkout and embedded checkout URLs use the canonical `www.makepay.io` origin; the production modal loader uses the MakePay CDN. - Published JavaScript and declarations no longer include source maps. diff --git a/README.md b/README.md index 287413a..a10ec3c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,16 @@ const makepay = new MakePayClient({ The client sends `x-makecrypto-key-id` and `x-makecrypto-key-secret` headers to the MakePay partner API. +Custom API and checkout base URLs must be origin-only HTTPS URLs. For local +tests, HTTP is accepted only with the exact hosts `localhost`, `127.0.0.1`, or +`[::1]` (an explicit port is allowed). Userinfo, paths, query strings, +fragments, lookalike hostnames, and alternate numeric IP encodings are rejected. +The same policy applies to anonymous requests and every hosted, embedded, +modal-script, button, and iframe URL helper. DPoP proof target URLs use the same +HTTPS-or-exact-loopback transport rule while retaining their required path. +Embedded-checkout `parentOrigin` values are independently validated as strict +merchant origins before being serialized or used as the browser default. + ### OAuth and DPoP Native integrations can instead supply OAuth credentials asynchronously. The @@ -449,7 +459,10 @@ export async function POST(request: Request) { } ``` -Use `verifyMakePayWebhook` when you only need a boolean result. +Use `verifyMakePayWebhook` when you only need a boolean result. Webhook +timestamps use a 300-second freshness window by default. A custom +`toleranceSeconds` must be finite and greater than zero; zero, negative, `NaN`, +and infinite values fail verification. ## Method Coverage diff --git a/src/index.ts b/src/index.ts index 1770805..52e64ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,9 @@ import { } from "node:crypto"; export type MakePayClientBaseOptions = { + /** Origin-only HTTPS URL; exact loopback hosts may use HTTP for local tests. */ baseUrl?: string; + /** Origin-only HTTPS URL; exact loopback hosts may use HTTP for local tests. */ checkoutBaseUrl?: string; fetch?: typeof fetch; }; @@ -905,6 +907,7 @@ export type MakePayBookkeepingDocumentDownloadResponse = { }; export type MakePayPublicRequestOptions = { + /** Origin-only HTTPS URL; exact loopback hosts may use HTTP for local tests. */ baseUrl?: string; fetch?: typeof fetch; }; @@ -953,13 +956,16 @@ export type MakePayRequestOptions = MakePayIdempotencyOptions & { }; export type MakePayWebhookVerificationOptions = { + /** Finite positive freshness window in seconds. Defaults to 300. */ toleranceSeconds?: number; }; export type MakePayEmbedViewType = "full" | "minimal"; export type MakePayCheckoutUrlOptions = { + /** Origin-only HTTPS URL; exact loopback hosts may use HTTP for local tests. */ baseUrl?: string; + /** Embedding merchant origin; follows the same HTTPS/loopback policy. */ parentOrigin?: string; viewType?: MakePayEmbedViewType; }; @@ -1035,11 +1041,12 @@ export class MakePayClient { const parsedBaseUrl = parseMakePayApiBaseUrl( options.baseUrl ?? MakePayClient.defaultBaseUrl, ); - this.baseUrl = parsedBaseUrl.toString().replace(/\/+$/, ""); + const parsedCheckoutBaseUrl = parseMakePayCheckoutBaseUrl( + options.checkoutBaseUrl ?? MakePayClient.defaultCheckoutBaseUrl, + ); + this.baseUrl = parsedBaseUrl.origin; this.baseOrigin = parsedBaseUrl.origin; - this.checkoutBaseUrl = ( - options.checkoutBaseUrl ?? MakePayClient.defaultCheckoutBaseUrl - ).replace(/\/+$/, ""); + this.checkoutBaseUrl = parsedCheckoutBaseUrl.origin; this.keyId = options.keyId; this.keySecret = options.keySecret; this.authProvider = options.authProvider; @@ -1858,9 +1865,12 @@ export async function createAnonymousPaymentLink( throw new MakePayError("A fetch implementation is required."); } + const baseUrl = parseMakePayApiBaseUrl( + options.baseUrl ?? MakePayClient.defaultBaseUrl, + ); const url = new URL( "/api/partner/v1/makepay/payment-links", - `${normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultBaseUrl)}/`, + `${baseUrl.origin}/`, ); const headers = new Headers({ accept: "application/json", @@ -1927,13 +1937,9 @@ export function createMakePayDpopProof( } catch { throw new MakePayError("DPoP URL must be an absolute URL."); } - if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - url.username || - url.password - ) { + if (!isMakePaySecureTransportUrl(options.url, url)) { throw new MakePayError( - "DPoP URL must be an HTTP URL without embedded credentials.", + "DPoP URL must be an HTTPS URL without embedded credentials; HTTP is allowed only for exact loopback hosts localhost, 127.0.0.1, or [::1].", ); } // RFC 9449 section 4.2 defines `htu` without query and fragment parts. @@ -1983,9 +1989,12 @@ export function buildMakePayHostedCheckoutUrl( ): string { assertNonEmpty(paymentUid, "Payment link UID is required."); + const baseUrl = parseMakePayCheckoutBaseUrl( + options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl, + ); return new URL( `/payment/${encodeURIComponent(paymentUid)}`, - `${normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl)}/`, + `${baseUrl.origin}/`, ).toString(); } @@ -1995,9 +2004,12 @@ export function buildMakePayHostedDonationUrl( ): string { assertNonEmpty(donationSlug, "Donation slug is required."); + const baseUrl = parseMakePayCheckoutBaseUrl( + options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl, + ); return new URL( `/donations/${encodeURIComponent(donationSlug)}`, - `${normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl)}/`, + `${baseUrl.origin}/`, ).toString(); } @@ -2007,13 +2019,19 @@ export function buildMakePayEmbeddedCheckoutUrl( ): string { assertNonEmpty(paymentUid, "Payment link UID is required."); + const baseUrl = parseMakePayCheckoutBaseUrl( + options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl, + ); const url = new URL( `/embed/payment/${encodeURIComponent(paymentUid)}`, - `${normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl)}/`, + `${baseUrl.origin}/`, ); - if (options.parentOrigin) { - url.searchParams.set("parentOrigin", options.parentOrigin); + if (options.parentOrigin !== undefined) { + url.searchParams.set( + "parentOrigin", + parseMakePayParentOrigin(options.parentOrigin).origin, + ); } appendMakePayEmbedViewType(url, options.viewType); @@ -2026,13 +2044,19 @@ export function buildMakePayEmbeddedDonationUrl( ): string { assertNonEmpty(donationSlug, "Donation slug is required."); + const baseUrl = parseMakePayCheckoutBaseUrl( + options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl, + ); const url = new URL( `/embed/donations/${encodeURIComponent(donationSlug)}`, - `${normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl)}/`, + `${baseUrl.origin}/`, ); - if (options.parentOrigin) { - url.searchParams.set("parentOrigin", options.parentOrigin); + if (options.parentOrigin !== undefined) { + url.searchParams.set( + "parentOrigin", + parseMakePayParentOrigin(options.parentOrigin).origin, + ); } appendMakePayEmbedViewType(url, options.viewType); @@ -2042,17 +2066,18 @@ export function buildMakePayEmbeddedDonationUrl( export function buildMakePayModalScriptUrl( options: Pick = {}, ): string { - const baseUrl = options.baseUrl ? normalizeBaseUrl(options.baseUrl) : null; + const baseUrl = parseMakePayCheckoutBaseUrl( + options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl, + ); if ( - !baseUrl || - baseUrl === normalizeBaseUrl(MakePayClient.defaultCheckoutBaseUrl) || - baseUrl === "https://makepay.io" + baseUrl.origin === MakePayClient.defaultCheckoutBaseUrl || + baseUrl.origin === "https://makepay.io" ) { return MAKEPAY_MODAL_SCRIPT_CDN_URL; } - return new URL("/modal/makepay.min.js", `${baseUrl}/`).toString(); + return new URL("/modal/makepay.min.js", `${baseUrl.origin}/`).toString(); } export function buildMakePayEmbedButtonHtml( @@ -2161,15 +2186,20 @@ export async function openMakePayCheckout( export function mountMakePayCheckout( options: MountMakePayCheckoutOptions, ): MountedMakePayCheckout { + const parentOrigin = options.parentOrigin ?? globalThis.location?.origin; + const normalizedParentOrigin = + parentOrigin === undefined + ? undefined + : parseMakePayParentOrigin(parentOrigin).origin; const container = resolveContainer(options.container); - const allowedOrigin = new URL( - normalizeBaseUrl(options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl), + const allowedOrigin = parseMakePayCheckoutBaseUrl( + options.baseUrl ?? MakePayClient.defaultCheckoutBaseUrl, ).origin; const iframe = document.createElement("iframe"); iframe.title = options.iframeTitle ?? "MakePay checkout"; iframe.src = buildMakePayEmbeddedCheckoutUrl(options.paymentUid, { baseUrl: options.baseUrl, - parentOrigin: options.parentOrigin ?? globalThis.location?.origin, + parentOrigin: normalizedParentOrigin, viewType: options.viewType, }); iframe.style.width = "100%"; @@ -2225,7 +2255,8 @@ export function verifyMakePayWebhook( const toleranceSeconds = options.toleranceSeconds ?? 300; if ( - toleranceSeconds > 0 && + !Number.isFinite(toleranceSeconds) || + toleranceSeconds <= 0 || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds ) { return false; @@ -2317,35 +2348,70 @@ function assertNonEmpty(value: string, message: string): void { } } -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ""); -} - const MAKEPAY_IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._~:+\/=\-]{8,200}$/; +const MAKEPAY_NETWORK_ORIGIN_PATTERN = + /^([a-z][a-z\d+.-]*):\/\/(\[[^\]]+\]|[^:/?#]+)(?::[0-9]+)?\/?$/i; +const MAKEPAY_URL_AUTHORITY_PATTERN = + /^[a-z][a-z\d+.-]*:\/\/(\[[^\]]+\]|[^:/?#@]+)(?::[0-9]+)?(?=\/|[?#]|$)/i; +const MAKEPAY_HTTP_LOOPBACK_HOSTS = new Set([ + "127.0.0.1", + "localhost", + "[::1]", +]); function parseMakePayApiBaseUrl(value: string): URL { + return parseMakePayNetworkOrigin(value, "MakePay baseUrl"); +} + +function parseMakePayCheckoutBaseUrl(value: string): URL { + return parseMakePayNetworkOrigin(value, "MakePay checkout baseUrl"); +} + +function parseMakePayParentOrigin(value: string): URL { + return parseMakePayNetworkOrigin(value, "MakePay parentOrigin"); +} + +function parseMakePayNetworkOrigin(value: string, label: string): URL { let url: URL; try { url = new URL(value); } catch { - throw new MakePayError("MakePay baseUrl must be an absolute HTTP URL."); + throw invalidMakePayNetworkOrigin(label); } if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - url.username || - url.password || + !MAKEPAY_NETWORK_ORIGIN_PATTERN.test(value) || + !isMakePaySecureTransportUrl(value, url) || + url.pathname !== "/" || url.search || url.hash ) { - throw new MakePayError( - "MakePay baseUrl must be an HTTP URL without credentials, query, or fragment.", - ); + throw invalidMakePayNetworkOrigin(label); } return url; } +function isMakePaySecureTransportUrl(value: string, url: URL): boolean { + const rawHostname = + MAKEPAY_URL_AUTHORITY_PATTERN.exec(value)?.[1]?.toLowerCase(); + return Boolean( + rawHostname && + !/\s/.test(value) && + (url.protocol === "https:" || + (url.protocol === "http:" && + MAKEPAY_HTTP_LOOPBACK_HOSTS.has(rawHostname))) && + !url.username && + !url.password, + ); +} + +function invalidMakePayNetworkOrigin(label: string): MakePayError { + return new MakePayError( + `${label} must be an HTTPS origin without credentials, path, query, or fragment; HTTP is allowed only for exact loopback hosts localhost, 127.0.0.1, or [::1].`, + ); +} + function normalizeMakePayDpopPublicJwk( value: JsonWebKey | MakePayDpopPublicJwk, ): MakePayDpopPublicJwk { diff --git a/tests/run.mjs b/tests/run.mjs index 25ee066..290725f 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -20,6 +20,7 @@ import { createMakePayDpopProof, createAnonymousPaymentLink, generateMakePayDpopKeyPair, + mountMakePayCheckout, parseMakePayWebhook, verifyMakePayWebhook, } from "../dist/index.js"; @@ -38,6 +39,27 @@ assert.deepEqual(parseMakePayWebhook(body, header, secret), { event: { type: "status_changed" }, }); +const staleTimestamp = timestamp - 3_600; +const staleSignature = createHmac("sha256", secret) + .update(`${staleTimestamp}.${body}`) + .digest("hex"); +const staleHeader = `t=${staleTimestamp},v1=${staleSignature}`; +assert.equal(verifyMakePayWebhook(body, staleHeader, secret), false); +assert.equal( + verifyMakePayWebhook(body, staleHeader, secret, { toleranceSeconds: 7_200 }), + true, +); +for (const toleranceSeconds of [0, -1, Number.NaN, Infinity, -Infinity]) { + assert.equal( + verifyMakePayWebhook(body, header, secret, { toleranceSeconds }), + false, + ); +} +assert.throws( + () => parseMakePayWebhook(body, header, secret, { toleranceSeconds: 0 }), + (error) => error instanceof MakePayError && error.status === 401, +); + const requests = []; const client = new MakePayClient({ keyId: "mk_test", @@ -889,6 +911,311 @@ await assert.rejects( ); assert.equal(escapedOriginFetches, 0); +const networkPolicyAnonymousPayload = { + amount: "5", + settlement: { + currency: "USDT", + priorities: [{ chain: "ETH", address: "0xabc", asset: "ETH.USDT-0xabc" }], + }, +}; +const allowedLoopbackBaseUrls = [ + { + input: "http://127.0.0.1:4311", + origin: "http://127.0.0.1:4311", + }, + { + input: "http://localhost:4312/", + origin: "http://localhost:4312", + }, + { + input: "http://[::1]:4313/", + origin: "http://[::1]:4313", + }, +]; + +for (const { input, origin } of allowedLoopbackBaseUrls) { + const authenticatedTargets = []; + const loopbackClient = new MakePayClient({ + baseUrl: input, + checkoutBaseUrl: input, + keyId: "mk_loopback", + keySecret: "mksec_loopback", + fetch: async (url, init) => { + authenticatedTargets.push({ init, url: String(url) }); + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }, + }); + await loopbackClient.listPaymentLinks(); + assert.equal( + authenticatedTargets[0].url, + `${origin}/api/partner/v1/makepay/payment-links`, + ); + assert.equal(authenticatedTargets[0].init.redirect, "manual"); + assert.equal( + loopbackClient.hostedCheckoutUrl("pay_loopback"), + `${origin}/payment/pay_loopback`, + ); + assert.equal( + loopbackClient.hostedDonationUrl("donation-loopback"), + `${origin}/donations/donation-loopback`, + ); + + let anonymousTarget = ""; + await createAnonymousPaymentLink(networkPolicyAnonymousPayload, { + baseUrl: input, + fetch: async (url, init) => { + anonymousTarget = String(url); + assert.equal(init.redirect, "manual"); + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }, + }); + assert.equal( + anonymousTarget, + `${origin}/api/partner/v1/makepay/payment-links`, + ); + assert.equal( + buildMakePayHostedCheckoutUrl("pay_loopback", { baseUrl: input }), + `${origin}/payment/pay_loopback`, + ); + assert.equal( + buildMakePayHostedDonationUrl("donation-loopback", { baseUrl: input }), + `${origin}/donations/donation-loopback`, + ); + assert.equal( + buildMakePayEmbeddedCheckoutUrl("pay_loopback", { baseUrl: input }), + `${origin}/embed/payment/pay_loopback`, + ); + assert.equal( + buildMakePayEmbeddedCheckoutUrl("pay_loopback", { + baseUrl: input, + parentOrigin: input, + }), + `${origin}/embed/payment/pay_loopback?parentOrigin=${encodeURIComponent(origin)}`, + ); + assert.equal( + buildMakePayEmbeddedDonationUrl("donation-loopback", { baseUrl: input }), + `${origin}/embed/donations/donation-loopback`, + ); + assert.equal( + buildMakePayModalScriptUrl({ baseUrl: input }), + `${origin}/modal/makepay.min.js`, + ); + assert.ok( + buildMakePayEmbedButtonHtml("pay_loopback", { + baseUrl: input, + }).includes(`src="${origin}/modal/makepay.min.js"`), + ); + assert.ok( + buildMakePayIframeHtml("pay_loopback", { + baseUrl: input, + }).includes(`src="${origin}/embed/payment/pay_loopback"`), + ); +} + +const rejectedNetworkBaseUrls = [ + "http://non-loopback.example", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "http://localhost.", + "http://127.0.0.1.", + "http://127.1", + "http://2130706433", + "http://0x7f000001", + "http://0177.0.0.1", + "http://[0:0:0:0:0:0:0:1]", + "http://[::ffff:127.0.0.1]", + "http://127.0.0.1@evil.example", + "ftp://localhost", + "ws://localhost", + "https://user:password@pay.example", + "https://pay.example/base-path", + "https://pay.example?environment=test", + "https://pay.example#fragment", + " https://pay.example", +]; + +for (const baseUrl of rejectedNetworkBaseUrls) { + assert.throws( + () => + new MakePayClient({ + baseUrl, + keyId: "mk_guard", + keySecret: "mksec_guard", + fetch: async () => new Response("{}"), + }), + /must be an HTTPS origin/, + ); + assert.throws( + () => + new MakePayClient({ + checkoutBaseUrl: baseUrl, + keyId: "mk_guard", + keySecret: "mksec_guard", + fetch: async () => new Response("{}"), + }), + /must be an HTTPS origin/, + ); + + let anonymousFetches = 0; + await assert.rejects( + () => + createAnonymousPaymentLink(networkPolicyAnonymousPayload, { + baseUrl, + fetch: async () => { + anonymousFetches += 1; + return new Response("{}"); + }, + }), + /must be an HTTPS origin/, + ); + assert.equal(anonymousFetches, 0); + + for (const buildUrl of [ + () => buildMakePayHostedCheckoutUrl("pay_guard", { baseUrl }), + () => buildMakePayHostedDonationUrl("donation-guard", { baseUrl }), + () => buildMakePayEmbeddedCheckoutUrl("pay_guard", { baseUrl }), + () => buildMakePayEmbeddedDonationUrl("donation-guard", { baseUrl }), + () => buildMakePayModalScriptUrl({ baseUrl }), + () => buildMakePayEmbedButtonHtml("pay_guard", { baseUrl }), + () => buildMakePayIframeHtml("pay_guard", { baseUrl }), + ]) { + assert.throws(buildUrl, /must be an HTTPS origin/); + } +} + +for (const parentOrigin of [ + ...rejectedNetworkBaseUrls, + "", + "*", + "null", +]) { + for (const buildEmbed of [ + () => + buildMakePayEmbeddedCheckoutUrl("pay_guard", { parentOrigin }), + () => + buildMakePayEmbeddedDonationUrl("donation-guard", { parentOrigin }), + () => buildMakePayIframeHtml("pay_guard", { parentOrigin }), + ]) { + assert.throws(buildEmbed, /parentOrigin must be an HTTPS origin/); + } +} + +const browserGlobalDescriptors = Object.fromEntries( + ["document", "location", "window"].map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), +); +const mountedIframe = { + removed: false, + remove() { + this.removed = true; + }, + setAttribute() {}, + style: {}, +}; +let appendedIframe; +Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + createElement(tagName) { + assert.equal(tagName, "iframe"); + return mountedIframe; + }, + }, +}); +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + addEventListener() {}, + removeEventListener() {}, + }, +}); +Object.defineProperty(globalThis, "location", { + configurable: true, + value: { origin: "http://LOCALHOST:4312" }, +}); +try { + const container = { + append(iframe) { + appendedIframe = iframe; + }, + }; + const mounted = mountMakePayCheckout({ + container, + paymentUid: "pay_parent_origin", + }); + assert.equal(appendedIframe, mountedIframe); + assert.equal( + mounted.iframe.src, + "https://www.makepay.io/embed/payment/pay_parent_origin?parentOrigin=http%3A%2F%2Flocalhost%3A4312", + ); + mounted.unmount(); + assert.equal(mountedIframe.removed, true); + + Object.defineProperty(globalThis, "location", { + configurable: true, + value: { origin: "http://merchant.example" }, + }); + assert.throws( + () => + mountMakePayCheckout({ + container, + paymentUid: "pay_unsafe_parent_origin", + }), + /parentOrigin must be an HTTPS origin/, + ); +} finally { + for (const [name, descriptor] of Object.entries(browserGlobalDescriptors)) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + delete globalThis[name]; + } + } +} + +for (const { origin } of allowedLoopbackBaseUrls) { + assert.match( + createMakePayDpopProof({ + accessToken: "loopback_access", + method: "POST", + privateKey: dpopKeyPair.privateKeyPem, + url: `${origin}/oauth/token?test=true#ignored`, + }), + /^[^.]+\.[^.]+\.[^.]+$/, + ); +} + +for (const url of [ + "http://non-loopback.example/oauth/token", + "http://localhost.evil.example/oauth/token", + "http://127.0.0.1.evil.example/oauth/token", + "http://127.1/oauth/token", + "http://2130706433/oauth/token", + "http://0x7f000001/oauth/token", + "http://0177.0.0.1/oauth/token", + "http://[0:0:0:0:0:0:0:1]/oauth/token", + "http://[::ffff:127.0.0.1]/oauth/token", + "ftp://localhost/oauth/token", + "ws://localhost/oauth/token", +]) { + assert.throws( + () => + createMakePayDpopProof({ + accessToken: "guarded_access", + method: "POST", + privateKey: dpopKeyPair.privateKeyPem, + url, + }), + /must be an HTTPS URL/, + ); +} + assert.throws( () => new MakePayClient({ @@ -896,7 +1223,7 @@ assert.throws( keyId: "mk_guard", keySecret: "mksec_guard", }), - /without credentials, query, or fragment/, + /must be an HTTPS origin/, ); assert.throws( From d4e57cbc03e9a2861e6522215ca22f1d725f8afa Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 21:40:45 +0400 Subject: [PATCH 5/9] Isolate mounted checkout messages --- CHANGELOG.md | 2 ++ README.md | 3 ++- src/index.ts | 2 ++ tests/run.mjs | 40 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654ae22..b33d254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ All notable changes to `@makecrypto/makepay` are documented here. URLs remain origin-only. - Embedded-checkout parent origins are normalized and validated before they are serialized or used for browser message targeting. +- Mounted checkout events must come from both the configured MakePay origin and + that mount's iframe window, isolating sibling checkout frames. - Webhook verification rejects non-finite or non-positive timestamp tolerances instead of allowing them to disable freshness checks. - The default hosted checkout and embedded checkout URLs use the canonical diff --git a/README.md b/README.md index a10ec3c..c074a07 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,8 @@ const mounted = mountMakePayCheckout({ Embedded checkout supports `viewType: "full" | "minimal"`. The default `"full"` view matches the hosted payment page layout. Use `"minimal"` when the checkout is already inside your own page or modal and should show only the -compact payment form. +compact payment form. The mounted helper accepts checkout events only when both +the configured MakePay origin and the mounted iframe window match. Donation pages also have URL helpers: diff --git a/src/index.ts b/src/index.ts index 52e64ac..4c9c70f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2211,6 +2211,8 @@ export function mountMakePayCheckout( const handleMessage = (event: MessageEvent) => { if ( event.origin !== allowedOrigin || + !iframe.contentWindow || + event.source !== iframe.contentWindow || !event.data || typeof event.data.type !== "string" ) { diff --git a/tests/run.mjs b/tests/run.mjs index 290725f..f762d54 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -1110,7 +1110,9 @@ const browserGlobalDescriptors = Object.fromEntries( Object.getOwnPropertyDescriptor(globalThis, name), ]), ); +const mountedFrameWindow = {}; const mountedIframe = { + contentWindow: mountedFrameWindow, removed: false, remove() { this.removed = true; @@ -1119,6 +1121,8 @@ const mountedIframe = { style: {}, }; let appendedIframe; +let messageListener; +let removedMessageListener; Object.defineProperty(globalThis, "document", { configurable: true, value: { @@ -1131,8 +1135,14 @@ Object.defineProperty(globalThis, "document", { Object.defineProperty(globalThis, "window", { configurable: true, value: { - addEventListener() {}, - removeEventListener() {}, + addEventListener(type, listener) { + assert.equal(type, "message"); + messageListener = listener; + }, + removeEventListener(type, listener) { + assert.equal(type, "message"); + removedMessageListener = listener; + }, }, }); Object.defineProperty(globalThis, "location", { @@ -1145,8 +1155,12 @@ try { appendedIframe = iframe; }, }; + const checkoutEvents = []; const mounted = mountMakePayCheckout({ container, + onEvent(event) { + checkoutEvents.push(event); + }, paymentUid: "pay_parent_origin", }); assert.equal(appendedIframe, mountedIframe); @@ -1154,8 +1168,30 @@ try { mounted.iframe.src, "https://www.makepay.io/embed/payment/pay_parent_origin?parentOrigin=http%3A%2F%2Flocalhost%3A4312", ); + assert.equal(typeof messageListener, "function"); + const acceptedCheckoutEvent = { + payload: { status: "complete" }, + type: "makepay.payment.completed", + }; + messageListener({ + data: acceptedCheckoutEvent, + origin: "https://www.makepay.io", + source: mountedFrameWindow, + }); + messageListener({ + data: { type: "makepay.payment.sibling" }, + origin: "https://www.makepay.io", + source: {}, + }); + messageListener({ + data: { type: "makepay.payment.null_source" }, + origin: "https://www.makepay.io", + source: null, + }); + assert.deepEqual(checkoutEvents, [acceptedCheckoutEvent]); mounted.unmount(); assert.equal(mountedIframe.removed, true); + assert.equal(removedMessageListener, messageListener); Object.defineProperty(globalThis, "location", { configurable: true, From 8fb1f7dd884324a2d698f1f59aa7477f5e6c47ad Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 22:30:04 +0400 Subject: [PATCH 6/9] fix: sanitize remote API errors --- README.md | 8 ++- src/index.ts | 58 ++++++++++++------ tests/run.mjs | 163 ++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 203 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c074a07..c3be1e8 100644 --- a/README.md +++ b/README.md @@ -567,7 +567,11 @@ document, reconciliation, and stat views from a single response. ## Errors -API calls throw `MakePayError` with `status` and `responseBody` fields. +API calls throw `MakePayError` with a numeric HTTP `status`. Remote error +bodies are intentionally not attached or reflected in the message, so the +error is safe to pass through normal application logging boundaries. The +deprecated `responseBody` property remains for source compatibility but is +always `undefined`. ```ts import { MakePayError } from "@makecrypto/makepay"; @@ -576,7 +580,7 @@ try { await makepay.getPaymentLink("PAYMENT_LINK_UID"); } catch (error) { if (error instanceof MakePayError) { - console.error(error.status, error.responseBody); + console.error(error.status, error.message); } } ``` diff --git a/src/index.ts b/src/index.ts index 4c9c70f..4b9c18a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1011,6 +1011,7 @@ type MakePayBrowserWindow = Window & { export class MakePayError extends Error { readonly status: number; + /** @deprecated Remote response bodies are intentionally never retained. */ readonly responseBody: unknown; constructor( @@ -1020,7 +1021,8 @@ export class MakePayError extends Error { super(message); this.name = "MakePayError"; this.status = options.status ?? 0; - this.responseBody = options.responseBody; + // Keep the pre-0.4 public shape without retaining untrusted response data. + this.responseBody = undefined; } } @@ -1803,8 +1805,13 @@ export class MakePayClient { } if (this.authProvider) { - const authorization = - await this.authProvider.getAuthorization(requestContext); + let authorization: MakePayOAuthAuthorization; + try { + authorization = + await this.authProvider.getAuthorization(requestContext); + } catch { + throw new MakePayError("MakePay OAuth authorization failed."); + } const accessToken = authorization.accessToken?.trim(); if (!accessToken) { throw new MakePayError( @@ -1842,10 +1849,16 @@ export class MakePayClient { attempt === 0 && this.authProvider?.refreshAuthorization ) { - await this.authProvider.refreshAuthorization({ - ...requestContext, - response, - }); + try { + await this.authProvider.refreshAuthorization({ + ...requestContext, + response: sanitizeOAuthRefreshResponse(response), + }); + } catch { + throw new MakePayError("MakePay OAuth refresh failed.", { + status: response.status, + }); + } continue; } @@ -2327,23 +2340,20 @@ async function decodeMakePayResponse( const decoded = text ? safeJsonParse(text) : {}; if (!response.ok) { - throw new MakePayError(readErrorMessage(decoded, response.status), { - responseBody: decoded, - status: response.status, - }); + // Remote error text is untrusted and can contain reflected credentials, + // customer data, or request diagnostics. Keep SDK errors safe to log by + // exposing only the HTTP status and a stable local message. + throw new MakePayError( + `MakePay API request failed with HTTP ${response.status}.`, + { + status: response.status, + }, + ); } return isRecord(decoded) ? decoded : {}; } -function readErrorMessage(decoded: unknown, status: number): string { - if (isRecord(decoded) && typeof decoded.error === "string") { - return decoded.error; - } - - return `MakePay API request failed with HTTP ${status}.`; -} - function assertNonEmpty(value: string, message: string): void { if (!value.trim()) { throw new MakePayError(message, { status: 400 }); @@ -2351,6 +2361,7 @@ function assertNonEmpty(value: string, message: string): void { } const MAKEPAY_IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._~:+\/=\-]{8,200}$/; +const MAKEPAY_DPOP_NONCE_PATTERN = /^[A-Za-z0-9._~\-]{1,512}$/; const MAKEPAY_NETWORK_ORIGIN_PATTERN = /^([a-z][a-z\d+.-]*):\/\/(\[[^\]]+\]|[^:/?#]+)(?::[0-9]+)?\/?$/i; const MAKEPAY_URL_AUTHORITY_PATTERN = @@ -2361,6 +2372,15 @@ const MAKEPAY_HTTP_LOOPBACK_HOSTS = new Set([ "[::1]", ]); +function sanitizeOAuthRefreshResponse(response: Response): Response { + const headers = new Headers(); + const nonce = response.headers.get("dpop-nonce")?.trim(); + if (nonce && MAKEPAY_DPOP_NONCE_PATTERN.test(nonce)) { + headers.set("dpop-nonce", nonce); + } + return new Response(null, { headers, status: response.status }); +} + function parseMakePayApiBaseUrl(value: string): URL { return parseMakePayNetworkOrigin(value, "MakePay baseUrl"); } diff --git a/tests/run.mjs b/tests/run.mjs index f762d54..061bfb9 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -5,6 +5,7 @@ import { createPublicKey, verify as verifySignature, } from "node:crypto"; +import { inspect } from "node:util"; import { MakePayClient, @@ -837,6 +838,7 @@ assert.deepEqual(JSON.parse(webhookSubscriptionRequests[1].init.body), { let persistentUnauthorizedRequests = 0; let persistentRefreshCalls = 0; +let persistentUnauthorizedError; const persistentUnauthorizedClient = new MakePayClient({ authProvider: { async getAuthorization() { @@ -848,18 +850,169 @@ const persistentUnauthorizedClient = new MakePayClient({ }, fetch: async () => { persistentUnauthorizedRequests += 1; - return new Response(JSON.stringify({ error: "unauthorized" }), { - headers: { "content-type": "application/json" }, - status: 401, - }); + return new Response( + JSON.stringify({ + error: + "sdk_access_token_sentinel sdk_refresh_token_sentinel buyer-sentinel@example.test", + nested: { authorization: "Bearer sdk_bearer_sentinel" }, + }), + { + headers: { "content-type": "application/json" }, + status: 401, + }, + ); }, }); await assert.rejects( () => persistentUnauthorizedClient.listPaymentLinks(), - (error) => error instanceof MakePayError && error.status === 401, + (error) => { + persistentUnauthorizedError = error; + return error instanceof MakePayError && error.status === 401; + }, ); assert.equal(persistentUnauthorizedRequests, 2); assert.equal(persistentRefreshCalls, 1); +assert.equal( + persistentUnauthorizedError.message, + "MakePay API request failed with HTTP 401.", +); +assert.equal(persistentUnauthorizedError.responseBody, undefined); +const visibleUnauthorizedError = [ + String(persistentUnauthorizedError), + JSON.stringify(persistentUnauthorizedError), + inspect(persistentUnauthorizedError, { depth: 10, showHidden: true }), + ...Reflect.ownKeys(persistentUnauthorizedError).map((key) => + String(persistentUnauthorizedError[key]), + ), +].join("\n"); +for (const sentinel of [ + "sdk_access_token_sentinel", + "sdk_refresh_token_sentinel", + "buyer-sentinel@example.test", + "sdk_bearer_sentinel", +]) { + assert.equal(visibleUnauthorizedError.includes(sentinel), false, sentinel); +} + +const ignoredConstructorBody = new MakePayError("safe constructor error", { + responseBody: { + token: "sdk_constructor_body_sentinel", + }, + status: 409, +}); +assert.equal(ignoredConstructorBody.status, 409); +assert.equal(ignoredConstructorBody.responseBody, undefined); +assert.equal( + inspect(ignoredConstructorBody, { depth: 10, showHidden: true }).includes( + "sdk_constructor_body_sentinel", + ), + false, +); + +let throwingRefreshError; +const throwingRefreshClient = new MakePayClient({ + authProvider: { + async getAuthorization() { + return { accessToken: "sdk_refresh_access_token_sentinel" }; + }, + async refreshAuthorization(request) { + const body = await request.response.text(); + assert.equal(body, ""); + assert.equal(request.response.headers.get("x-reflected-secret"), null); + assert.equal(request.response.headers.get("dpop-nonce"), "safe_nonce-1"); + throw new Error( + `sdk_refresh_callback_sentinel ${body} ${request.response.headers.get("x-reflected-secret")}`, + ); + }, + }, + fetch: async () => + new Response( + JSON.stringify({ + error: "sdk_refresh_response_body_sentinel", + }), + { + headers: { + "dpop-nonce": "safe_nonce-1", + "x-reflected-secret": "sdk_refresh_header_sentinel", + }, + status: 401, + }, + ), +}); +await assert.rejects( + () => throwingRefreshClient.listPaymentLinks(), + (error) => { + throwingRefreshError = error; + return ( + error instanceof MakePayError && + error.status === 401 && + error.message === "MakePay OAuth refresh failed." + ); + }, +); +const visibleThrowingRefreshError = inspect(throwingRefreshError, { + depth: 10, + showHidden: true, +}); +for (const sentinel of [ + "sdk_refresh_access_token_sentinel", + "sdk_refresh_callback_sentinel", + "sdk_refresh_response_body_sentinel", + "sdk_refresh_header_sentinel", +]) { + assert.equal(visibleThrowingRefreshError.includes(sentinel), false, sentinel); +} + +let throwingAuthorizationError; +await assert.rejects( + () => + new MakePayClient({ + authProvider: { + async getAuthorization() { + throw new Error("sdk_authorization_callback_sentinel"); + }, + }, + fetch: async () => { + throw new Error("fetch must not be reached"); + }, + }).listPaymentLinks(), + (error) => { + throwingAuthorizationError = error; + return ( + error instanceof MakePayError && + error.message === "MakePay OAuth authorization failed." + ); + }, +); +assert.equal( + inspect(throwingAuthorizationError, { depth: 10, showHidden: true }).includes( + "sdk_authorization_callback_sentinel", + ), + false, +); + +let malformedRemoteError; +await assert.rejects( + () => + new MakePayClient({ + keyId: "mk_error_sentinel", + keySecret: "mksec_error_sentinel", + fetch: async () => + new Response("not-json sdk_plaintext_error_sentinel", { + status: 502, + }), + }).listPaymentLinks(), + (error) => { + malformedRemoteError = error; + return error instanceof MakePayError && error.status === 502; + }, +); +assert.equal( + inspect(malformedRemoteError, { depth: 10, showHidden: true }).includes( + "sdk_plaintext_error_sentinel", + ), + false, +); await assert.rejects( () => From 197fb61fff3661b6ae171c5c532e1f1945159f1e Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Sun, 19 Jul 2026 23:14:51 +0400 Subject: [PATCH 7/9] ci: isolate npm trusted publishing --- .github/workflows/publish.yml | 156 +++++++++++++++++++++++++++++++--- scripts/verify-package.mjs | 51 +++++++++++ 2 files changed, 194 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f47295b..2fdabea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,13 +12,11 @@ concurrency: permissions: contents: read - id-token: write jobs: - publish-next: - name: Publish immutable candidate to next + prepare-candidate: + name: Validate and pack without OIDC runs-on: ubuntu-latest - environment: npm-release steps: - name: Checkout the release commit @@ -26,15 +24,11 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js for trusted publishing + - name: Setup Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: 24 package-manager-cache: false - registry-url: https://registry.npmjs.org - - - name: Install trusted-publishing capable npm - run: npm install --global npm@11.6.2 - name: Verify tag, version, and main ancestry shell: bash @@ -63,16 +57,152 @@ jobs: - name: Audit production dependencies run: npm audit --omit=dev --audit-level=high + - name: Pack the exact reviewed build + shell: bash + run: | + set -euo pipefail + rm -rf release-candidate + mkdir release-candidate + npm pack --ignore-scripts --json --pack-destination release-candidate > "${RUNNER_TEMP}/makepay-pack.json" + PACK_JSON="${RUNNER_TEMP}/makepay-pack.json" node --input-type=module <<'NODE' + import { createHash } from "node:crypto" + import { readFileSync, writeFileSync } from "node:fs" + import { join } from "node:path" + + const [packed] = JSON.parse(readFileSync(process.env.PACK_JSON, "utf8")) + const pkg = JSON.parse(readFileSync("package.json", "utf8")) + if (!packed?.filename || packed.name !== pkg.name || packed.version !== pkg.version) { + throw new Error("npm pack identity does not match package.json") + } + const tarball = join("release-candidate", packed.filename) + const bytes = readFileSync(tarball) + writeFileSync( + join("release-candidate", "candidate.json"), + `${JSON.stringify( + { + commit: process.env.GITHUB_SHA, + filename: packed.filename, + integrity: packed.integrity, + name: packed.name, + sha1: packed.shasum, + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.length, + version: packed.version, + }, + null, + 2, + )}\n`, + ) + NODE + + - name: Upload immutable npm candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-release-candidate + if-no-files-found: error + path: release-candidate + retention-days: 1 + + publish-next: + name: Publish immutable candidate to next + runs-on: ubuntu-latest + needs: prepare-candidate + environment: npm-release + permissions: + actions: read + contents: read + id-token: write + + steps: + - name: Setup Node.js for trusted publishing + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + package-manager-cache: false + registry-url: https://registry.npmjs.org + + - name: Install trusted-publishing capable npm without lifecycle scripts + run: npm install --global npm@11.6.2 --ignore-scripts --no-audit --no-fund + + - name: Download the validated candidate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir release-candidate + gh run download "${GITHUB_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name npm-release-candidate \ + --dir release-candidate + + - name: Verify downloaded tarball identity and digest + shell: bash + run: | + set -euo pipefail + candidate="release-candidate/candidate.json" + test -f "${candidate}" + filename="$(CANDIDATE="${candidate}" node -p "require('./' + process.env.CANDIDATE).filename")" + tarball="release-candidate/${filename}" + test -f "${tarball}" + CANDIDATE="${candidate}" \ + TARBALL="${tarball}" \ + node --input-type=module <<'NODE' + import assert from "node:assert/strict" + import { createHash } from "node:crypto" + import { readFileSync } from "node:fs" + import { basename } from "node:path" + + const candidate = JSON.parse(readFileSync(process.env.CANDIDATE, "utf8")) + assert.equal(candidate.filename, basename(candidate.filename)) + assert.match(candidate.filename, /^[a-z0-9][a-z0-9._-]*\.tgz$/) + const tarball = readFileSync(process.env.TARBALL) + assert.equal(candidate.commit, process.env.GITHUB_SHA) + assert.equal(process.env.GITHUB_REF_TYPE, "tag") + assert.equal(process.env.GITHUB_REF_NAME, `v${candidate.version}`) + assert.equal(tarball.length, candidate.size) + assert.equal( + createHash("sha256").update(tarball).digest("hex"), + candidate.sha256, + ) + assert.equal( + createHash("sha1").update(tarball).digest("hex"), + candidate.sha1, + ) + assert.equal( + `sha512-${createHash("sha512").update(tarball).digest("base64")}`, + candidate.integrity, + ) + NODE + tar -xOf "${tarball}" package/package.json > "${RUNNER_TEMP}/packed-package.json" + CANDIDATE="${candidate}" \ + PACKED_PACKAGE="${RUNNER_TEMP}/packed-package.json" \ + node --input-type=module <<'NODE' + import assert from "node:assert/strict" + import { readFileSync } from "node:fs" + + const candidate = JSON.parse(readFileSync(process.env.CANDIDATE, "utf8")) + const packed = JSON.parse(readFileSync(process.env.PACKED_PACKAGE, "utf8")) + assert.equal(packed.name, candidate.name) + assert.equal(packed.version, candidate.version) + NODE + - name: Refuse to overwrite an existing version shell: bash run: | set -euo pipefail - package="$(node -p "require('./package.json').name")" - version="$(node -p "require('./package.json').version")" + package="$(node -p "require('./release-candidate/candidate.json').name")" + version="$(node -p "require('./release-candidate/candidate.json').version")" if npm view "${package}@${version}" version >/dev/null 2>&1; then - echo "${package}@${version} is already published and immutable." >&2 + echo "${package}@${version} is already published and is immutable." >&2 exit 1 fi - name: Publish candidate with npm trusted publishing - run: npm publish --provenance --access public --tag next + run: | + set -euo pipefail + filename="$(node -p "require('./release-candidate/candidate.json').filename")" + npm publish "release-candidate/${filename}" \ + --ignore-scripts \ + --provenance \ + --access public \ + --tag next diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index f00d48d..218ea92 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -106,6 +106,57 @@ if ( fail("package.json files must remain the exact reviewed release allowlist."); } +for (const workflowPath of [ + ".github/workflows/ci.yml", + ".github/workflows/publish.yml", +]) { + const workflow = readFileSync(join(repositoryRoot, workflowPath), "utf8"); + const actionReferences = workflow + .split(/\r?\n/) + .filter((line) => /^\s*uses:/.test(line)); + if (!actionReferences.length) { + fail(`${workflowPath} must use at least one pinned action.`); + } + for (const reference of actionReferences) { + if (!/^\s*uses:\s+[^\s@]+@[a-f0-9]{40}\s+#\s+v\d+\.\d+\.\d+\s*$/.test(reference)) { + fail(`${workflowPath} contains an unpinned action: ${reference.trim()}`); + } + } +} + +const publishWorkflow = readFileSync( + join(repositoryRoot, ".github/workflows/publish.yml"), + "utf8", +); +if ((publishWorkflow.match(/id-token:\s*write/g) ?? []).length !== 1) { + fail("Only the minimal npm publish job may receive OIDC permission."); +} +for (const requiredReleaseControl of [ + "prepare-candidate:", + "needs: prepare-candidate", + "environment: npm-release", + "npm pack --ignore-scripts --json", + "Upload immutable npm candidate", + "Download the validated candidate", + "candidate.sha256", + "candidate.sha1", + "candidate.integrity", + 'npm publish "release-candidate/${filename}"', + "--ignore-scripts", + "--provenance", + "--tag next", +]) { + if (!publishWorkflow.includes(requiredReleaseControl)) { + fail(`Publish workflow is missing ${requiredReleaseControl}.`); + } +} +const publishJob = publishWorkflow.slice( + publishWorkflow.indexOf(" publish-next:"), +); +if (publishJob.includes("actions/checkout@")) { + fail("The OIDC npm publish job must not checkout repository-controlled code."); +} + const compilerOptions = JSON.parse( readFileSync(join(repositoryRoot, "tsconfig.json"), "utf8"), ).compilerOptions; From 5ff733f3daed884baa81e951dce8f00a546c58ea Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Mon, 20 Jul 2026 00:04:29 +0400 Subject: [PATCH 8/9] docs: keep SDK release date pending --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b33d254..75ad6bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `@makecrypto/makepay` are documented here. -## 0.4.0 - 2026-07-19 +## 0.4.0 - Unreleased ### Added From 248f23a4c71bc781b0b03ae76f5c11ae9c65223b Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Thu, 23 Jul 2026 17:33:57 +0400 Subject: [PATCH 9/9] docs: date SDK 0.4.0 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ad6bd..16ea93f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `@makecrypto/makepay` are documented here. -## 0.4.0 - Unreleased +## 0.4.0 - 2026-07-23 ### Added