openpay-x402-sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Reserve session and daily capacity immediately before exposing a signed
6
+ authorization. Non-2xx responses, timeouts, and connection failures retain
7
+ the reservation; successful 2xx responses keep the existing confirmed-spend
8
+ accounting.
9
+ - Make the file daily store cross-process atomic with an exclusive lock, reject
10
+ UTC-crossing authorizations, and fail closed when a configured store is
11
+ unavailable.
12
+ - Enforce host/catalog admission before target I/O, block private and rebinding
13
+ destinations, require exact catalog URLs, stop redirects, and bound buyer
14
+ requests with a timeout.
15
+ - Bind supported networks to the canonical JPYC v3 contract/domain, cap
16
+ seller-declared authorization lifetimes, bind signature destinations to a
17
+ known or catalog-reviewed forwarder, and locally reserve seller-gate
18
+ authorizations across verify and settle without requiring facilitator tokens.
19
+ - Verify facilitator-signed payment receipts against the advertised signer and
20
+ bind every money field and authorization nonce before returning them.
21
+
3
22
  ## 0.4.0
4
23
 
5
24
  - Add an opt-in persistent daily buyer limit with UTC signer/date keys, file and
package/README.md CHANGED
@@ -3,6 +3,11 @@
3
3
  Node.js 20+ SDK for discovering, quoting, and buying OpenPay x402 resources priced
4
4
  in JPYC. It ships as plain ESM and has no build step.
5
5
 
6
+ Wire compatibility: x402 v1 transport (JSON 402 body with `x402Version: 1`, plus the
7
+ `X-PAYMENT` / `X-PAYMENT-RESPONSE` headers) with the OpenPay `extra.openpay`
8
+ forwarder-split extension. OpenPay's first-party resources also accept the v2 header
9
+ transport; this SDK speaks v1.
10
+
6
11
  ## Quick start
7
12
 
8
13
  ```bash
@@ -17,6 +22,7 @@ const client = createOpenPayClient({
17
22
  maxPerCallJpyc: '10',
18
23
  maxSessionJpyc: '100',
19
24
  maxDailyJpyc: '250',
25
+ maxTimeoutSeconds: 600,
20
26
  allowedHosts: 'open-pay.jp',
21
27
  });
22
28
 
@@ -35,6 +41,12 @@ concurrent calls so every call sees the latest session total.
35
41
 
36
42
  ## Sell with the SDK
37
43
 
44
+ **An AI model is never the security boundary for payment state.** Do not unlock on any
45
+ model- or tool-produced claim of payment — a "paid" string, a success message, or a
46
+ transaction id appearing in text. The gate unlocks only on the facilitator's `verify` and
47
+ `settle` responses, which are backed by on-chain settlement; replayed or already-used
48
+ authorizations are refused at that layer.
49
+
38
50
  Create a gate with the exact resource URL registered in OpenPay discovery. For
39
51
  inexpensive content, `handle()` verifies and settles the payment in one call:
40
52
 
@@ -43,6 +55,7 @@ import { createJpycGate } from 'openpay-x402-sdk';
43
55
 
44
56
  const gate = createJpycGate({
45
57
  resourceUrl: process.env.MY_RESOURCE_URL,
58
+ maxUpstreamSeconds: 60,
46
59
  });
47
60
 
48
61
  export async function GET(request) {
@@ -91,6 +104,20 @@ minutes. Until `resourceUrl` is listed with a non-empty `accepts`, `handle()` an
91
104
  `verify()` throw; map that bootstrap condition to an HTTP 500 response. Pass
92
105
  `openpayOrigin` to use an origin other than `https://open-pay.jp`.
93
106
 
107
+ Before `verify()` contacts the facilitator, each gate instance claims a canonical
108
+ authorization identity until its `validBefore` time. Set `maxUpstreamSeconds`
109
+ (default `60`) to the seller's worst-case upstream duration; the gate also
110
+ retains `settlementGraceSeconds` (default `30`) and rejects a duplicate before
111
+ upstream work begins. A failed facilitator verification releases the tentative
112
+ claim, while a successful verification keeps it through settlement so an
113
+ unknown settlement result cannot expose the same authorization twice.
114
+
115
+ The facilitator reservation is an optional additional defense. The gate forwards
116
+ its token when one is returned and continues on the established token-less wire
117
+ when it is not. The local claim covers concurrent requests sharing one gate
118
+ instance; separate processes or serverless isolates do not share its in-memory
119
+ ledger.
120
+
94
121
  The copy-paste paywall snippet generated by OpenPay provides the same one-shot
95
122
  gate; `createJpycGate` is its importable SDK counterpart with split settlement.
96
123
 
@@ -99,37 +126,61 @@ gate; `createJpycGate` is its importable SDK counterpart with split settlement.
99
126
  | Option | Default | Guard |
100
127
  |---|---:|---|
101
128
  | `maxPerCallJpyc` | `10` | Upper bound for the caller-provided `maxTotalJpyc`. |
102
- | `maxSessionJpyc` | `100` | Cumulative cap for successful payments made by this client instance. |
129
+ | `maxSessionJpyc` | `100` | Cumulative cap for successful payments plus authorizations exposed to a seller. |
103
130
  | `maxDailyJpyc` | Not set | Persistent cumulative cap per signer and UTC calendar date. |
131
+ | `maxTimeoutSeconds` | `600` | Reject seller-declared authorization lifetimes above this many seconds (maximum configurable value: `1200`). |
104
132
  | `allowedHosts` | `open-pay.jp` | Comma-separated bare host allowlist. |
105
- | `catalogTrust` | `true` | Also allows catalog URLs after the live challenge matches the catalog challenge. |
133
+ | `catalogTrust` | `true` | Also allows exact catalog URLs after the live challenge matches the catalog challenge. |
106
134
  | `discoveryUrl` | `https://open-pay.jp/api/discovery` | Catalog and OpenPay origin used by the client. |
107
135
 
108
- Query string variants of a query-free listed URL are trusted after the same
109
- money-field verification. Exact query-bearing catalog entries remain exact-only.
136
+ Catalog admission is exact URL only, including the query string. A query
137
+ variant must have its own reviewed listing or an explicitly allowlisted host.
110
138
 
111
139
  `pay(url, { maxTotalJpyc })` always requires `maxTotalJpyc`. It is the maximum
112
140
  total—including the resource price and x402 fee—that this individual call is
113
141
  authorized to pay. It does not disable or raise `maxPerCallJpyc` or
114
142
  `maxSessionJpyc`; every configured limit must allow the payment.
115
143
 
116
- `maxDailyJpyc` is opt-in. When set, the client stores successful 2xx unlocks in
117
- `~/.openpay-x402/spend.json`, keyed by the lower-cased signer address and UTC
118
- date. A missing entry starts at zero. A corrupt/unreadable store or a custom
119
- store returning `null` rejects quotes and payments with `daily_spend_unavailable`
120
- (fail-closed). Use `spendStore` to inject another implementation of
121
- `{ load(key), save(key, atomicString) }`; `MAX_DAILY_JPYC` is the equivalent
122
- optional setting for the exported environment config readers.
123
-
124
- The file store uses best-effort read-modify-write across processes: payments are
125
- serialized within one client process, but separate processes can race and lose
126
- an increment. Use an atomic shared store when multiple processes share a signer.
127
- Persistence runs only after a successful unlock; a save failure cannot change an
128
- already completed payment response.
144
+ `maxDailyJpyc` is opt-in. When set, the client atomically reserves the amount in
145
+ `~/.openpay-x402/spend.json` immediately before sending a signed authorization,
146
+ keyed by the lower-cased signer address and UTC date. A non-2xx response,
147
+ connection loss, or timeout does not release that reservation: the seller may
148
+ already have settled it. Authorizations that could remain valid across UTC
149
+ midnight are refused instead of being charged to the wrong day.
150
+
151
+ A missing entry starts at zero. A corrupt/unreadable store, a failed reservation,
152
+ or a custom store returning `null` rejects payments with
153
+ `daily_spend_unavailable` (fail-closed). The built-in file store uses an
154
+ exclusive lock plus atomic replacement, so separate processes sharing a signer
155
+ cannot both reserve the same remaining daily capacity. Custom stores can add an
156
+ atomic `reserve(key, amount, limit, reservation)` method; legacy
157
+ `{ load, save }` stores remain accepted with a verified pre-send write.
158
+ `MAX_DAILY_JPYC` is the equivalent optional setting for the exported environment
159
+ config readers.
160
+
161
+ An abrupt process or machine stop can leave `spend.json.lock`; reservations then
162
+ fail closed instead of paying without a limit. Stop every process that uses the
163
+ same store before inspecting and manually removing a stale lock. Never remove
164
+ the lock while another process may still own it.
129
165
 
130
166
  The client also rejects non-JPYC metadata, unsupported networks or schemes,
131
- non-OpenPay forwarder splits, amount inconsistencies, resource URL mismatches,
132
- and catalog bait-and-switches before requesting a signature.
167
+ non-canonical JPYC contracts or EIP-712 domains, seller timeouts above
168
+ `maxTimeoutSeconds`, unreviewed forwarder destinations, non-OpenPay forwarder
169
+ splits, amount inconsistencies,
170
+ resource URL mismatches, and catalog bait-and-switches before requesting a
171
+ signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
172
+ environment config readers. Target host/catalog admission and private-address
173
+ checks run before buyer target requests. Those requests require HTTPS, do not
174
+ follow redirects, and have a 15-second timeout. The default Node transport also
175
+ validates DNS before and during connection to block rebinding; an explicitly
176
+ injected custom `fetchImpl` is a trusted transport boundary and is responsible
177
+ for equivalent connect-time enforcement.
178
+
179
+ `pay()` returns a non-null `receipt` only when the facilitator signer advertised
180
+ by `/api/facilitator/supported` signed it and the transaction, payer, network,
181
+ asset, merchant amount, fee, chain, and authorization nonce all match this
182
+ payment. A missing, malformed, forged, or mismatched seller response header
183
+ becomes `receipt: null` without discarding the unlocked response body.
133
184
 
134
185
  ## Signers
135
186
 
package/index.d.ts CHANGED
@@ -44,10 +44,43 @@ export interface SpendStore {
44
44
  * so an absent entry must be reported as `'0'`, never `null`.
45
45
  */
46
46
  load(key: string): Promise<string | null>;
47
+ /**
48
+ * Atomically check the limit and reserve an authorization before it is sent.
49
+ * Custom legacy stores may omit this method; new cross-process stores should
50
+ * implement it to avoid lost updates.
51
+ */
52
+ reserve?(
53
+ key: string,
54
+ amountAtomic: string,
55
+ limitAtomic: string,
56
+ reservation: SpendReservation,
57
+ ): Promise<SpendReservationResult>;
58
+ /** Mark reservation metadata confirmed without reducing the reserved total. */
59
+ confirm?(id: string): Promise<boolean>;
47
60
  /** Persist the new cumulative atomic amount. Failures must not throw. */
48
61
  save(key: string, atomicString: string): Promise<void>;
49
62
  }
50
63
 
64
+ export interface SpendReservation {
65
+ id: string;
66
+ payer: Address;
67
+ network: string;
68
+ asset: Address;
69
+ validBefore: string;
70
+ }
71
+
72
+ export type SpendReservationResult =
73
+ | { ok: true; totalAtomic: string }
74
+ | {
75
+ ok: false;
76
+ reason: 'limit_exceeded' | 'unavailable';
77
+ totalAtomic?: string;
78
+ };
79
+
80
+ export type PaymentLookup = (
81
+ hostname: string,
82
+ ) => Promise<Array<{ address: string; family?: number }>>;
83
+
51
84
  export interface FileSpendStoreOptions {
52
85
  path?: string;
53
86
  fsImpl?: {
@@ -58,6 +91,12 @@ export interface FileSpendStoreOptions {
58
91
  data: string,
59
92
  encoding: 'utf8',
60
93
  ): Promise<unknown>;
94
+ open?(
95
+ path: string,
96
+ flags: 'wx',
97
+ ): Promise<{ close(): Promise<unknown> }>;
98
+ rename?(from: string, to: string): Promise<unknown>;
99
+ unlink?(path: string): Promise<unknown>;
61
100
  };
62
101
  }
63
102
 
@@ -65,11 +104,14 @@ interface ClientCommonOptions {
65
104
  maxPerCallJpyc?: JpycAmount;
66
105
  maxSessionJpyc?: JpycAmount;
67
106
  maxDailyJpyc?: JpycAmount;
107
+ maxTimeoutSeconds?: number;
68
108
  spendStore?: SpendStore;
69
109
  allowedHosts?: string;
70
110
  catalogTrust?: boolean;
71
111
  discoveryUrl?: string;
72
112
  fetchImpl?: typeof globalThis.fetch;
113
+ lookup?: PaymentLookup;
114
+ requestTimeoutMs?: number;
73
115
  nowSec?: () => number;
74
116
  now?: () => Date | number;
75
117
  }
@@ -114,6 +156,11 @@ export interface RuntimeConfig {
114
156
  maxPerCallAtomic: bigint;
115
157
  maxSessionAtomic: bigint;
116
158
  maxDailyAtomic: bigint | null;
159
+ /**
160
+ * Maximum seller-declared authorization lifetime. Optional here so existing
161
+ * consumers that construct RuntimeConfig manually remain source-compatible.
162
+ */
163
+ maxTimeoutSeconds?: number;
117
164
  allowedHosts: string[];
118
165
  catalogTrust: boolean;
119
166
  discoveryUrl: string;
@@ -228,6 +275,10 @@ export interface JpycGateOptions {
228
275
  openpayOrigin?: string;
229
276
  fetchImpl?: typeof globalThis.fetch;
230
277
  now?: () => number;
278
+ /** Worst-case seller upstream duration protected by the local claim. Default: 60. */
279
+ maxUpstreamSeconds?: number;
280
+ /** Extra validity retained for settlement after upstream work. Default: 30. */
281
+ settlementGraceSeconds?: number;
231
282
  }
232
283
 
233
284
  export interface JpycGatePaymentResponse {
@@ -248,6 +299,7 @@ export function createJpycGate(options: JpycGateOptions): JpycGate;
248
299
  export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
249
300
  ReceiveWithAuthorization: Array<{ name: string; type: string }>;
250
301
  };
302
+ export const MAX_AUTHORIZATION_TIMEOUT_SECONDS: 1200;
251
303
 
252
304
  export interface NormalizedPaymentRequirements {
253
305
  scheme: 'exact';
@@ -331,6 +383,8 @@ export function paymentPayloadFor(
331
383
  export const JPYC_DECIMALS: 18;
332
384
  export const DEFAULT_MAX_PER_CALL_JPYC: '10';
333
385
  export const DEFAULT_MAX_SESSION_JPYC: '100';
386
+ export const DEFAULT_MAX_TIMEOUT_SECONDS: 600;
387
+ export const MAX_SUPPORTED_TIMEOUT_SECONDS: 1200;
334
388
  export const DEFAULT_ALLOWED_HOSTS: 'open-pay.jp';
335
389
  export const DEFAULT_CATALOG_TRUST: true;
336
390
  export const DEFAULT_DISCOVERY_URL: 'https://open-pay.jp/api/discovery';
@@ -340,8 +394,10 @@ export const REASONS: {
340
394
  unsupportedScheme: 'unsupported_scheme';
341
395
  unsupportedNetwork: 'unsupported_network';
342
396
  invalidOpenpayMode: 'invalid_openpay_mode';
397
+ invalidOpenpayForwarder: 'invalid_openpay_forwarder';
343
398
  amountMismatch: 'amount_mismatch';
344
399
  invalidJpycAsset: 'invalid_jpyc_asset';
400
+ timeoutTooLong: 'timeout_too_long';
345
401
  resourceMismatch: 'resource_mismatch';
346
402
  invalidAccept: 'invalid_accept';
347
403
  maxTotalRequired: 'max_total_required';
@@ -352,12 +408,62 @@ export const REASONS: {
352
408
  sessionLimitExceeded: 'session_limit_exceeded';
353
409
  dailyLimitExceeded: 'daily_limit_exceeded';
354
410
  dailySpendUnavailable: 'daily_spend_unavailable';
411
+ dailyAuthorizationCrossesUtcDay: 'daily_authorization_crosses_utc_day';
355
412
  buyerPrivateKeyMissing: 'buyer_private_key_missing';
356
413
  stewardSignerUnconfigured: 'steward_signer_unconfigured';
357
414
  catalogAcceptMismatch: 'catalog_accept_mismatch';
358
415
  };
416
+ export const SUPPORTED_JPYC_ASSETS: Readonly<
417
+ Record<
418
+ string,
419
+ Readonly<{
420
+ address: Address;
421
+ name: 'JPY Coin';
422
+ version: '1';
423
+ decimals: 18;
424
+ }>
425
+ >
426
+ >;
427
+ export const SUPPORTED_JPYC_FORWARDERS: Readonly<
428
+ Partial<Record<string, Address>>
429
+ >;
359
430
  export const SUPPORTED_NETWORKS: Set<string>;
360
431
 
432
+ export const DEFAULT_PAYMENT_FETCH_TIMEOUT_MS: 15000;
433
+ export function isPrivatePaymentHost(hostname: string): boolean;
434
+ export function parseSafePaymentUrl(raw: unknown): URL | null;
435
+ export function fetchPaymentTarget(
436
+ url: string,
437
+ options?: {
438
+ fetchImpl?: typeof globalThis.fetch;
439
+ headers?: HeadersInit;
440
+ lookup?: PaymentLookup;
441
+ timeoutMs?: number;
442
+ },
443
+ ): Promise<Response>;
444
+
445
+ export type ReceiptSignerResolver = () => Promise<Address | null>;
446
+ export function createReceiptSignerResolver(options: {
447
+ discoveryUrl: string;
448
+ fetchImpl?: typeof globalThis.fetch;
449
+ lookup?: PaymentLookup;
450
+ requestTimeoutMs?: number;
451
+ }): ReceiptSignerResolver;
452
+ export function verifyBoundPaymentResponse(
453
+ paymentResponse: unknown,
454
+ expected: {
455
+ expectedSigner: Address;
456
+ payer: Address;
457
+ network: string;
458
+ asset: Address;
459
+ chainId: number;
460
+ merchant: Address;
461
+ merchantValue: bigint;
462
+ feeValue: bigint;
463
+ nonce: Hex;
464
+ },
465
+ ): Promise<boolean>;
466
+
361
467
  export interface AcceptSummary {
362
468
  priceAtomic: bigint;
363
469
  feeAtomic: bigint;
@@ -459,12 +565,16 @@ export function createCatalogCache(): CatalogCache;
459
565
  export function resolveCatalogListings(options: {
460
566
  config: Pick<RuntimeConfig, 'catalogTrust' | 'discoveryUrl'>;
461
567
  fetchImpl?: typeof globalThis.fetch;
568
+ lookup?: PaymentLookup;
569
+ requestTimeoutMs?: number;
462
570
  now?: () => number;
463
571
  cache?: CatalogCache;
464
572
  }): Promise<Map<string, unknown> | null>;
465
573
  export function createCatalogResolver(options: {
466
574
  config: Pick<RuntimeConfig, 'catalogTrust' | 'discoveryUrl'>;
467
575
  fetchImpl?: typeof globalThis.fetch;
576
+ lookup?: PaymentLookup;
577
+ requestTimeoutMs?: number;
468
578
  now?: () => number;
469
579
  }): () => Promise<Map<string, unknown> | null>;
470
580
 
@@ -482,7 +592,10 @@ export function createPaymentExecutor(options: {
482
592
  signerAddress?: Address | null;
483
593
  spendStore?: SpendStore | null;
484
594
  fetchImpl?: typeof globalThis.fetch;
595
+ lookup?: PaymentLookup;
596
+ requestTimeoutMs?: number;
485
597
  nowSec?: () => number;
486
598
  now?: () => Date | number;
487
599
  resolveCatalogListings?: () => Promise<Map<string, unknown> | null>;
600
+ resolveReceiptSigner?: ReceiptSignerResolver;
488
601
  }): PaymentExecutor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/catalog.mjs CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
3
+ fetchPaymentTarget,
4
+ } from './network.mjs';
5
+
1
6
  export const CATALOG_CACHE_MS = 5 * 60_000;
2
7
 
3
8
  function isObject(value) {
@@ -21,6 +26,8 @@ export function createCatalogCache() {
21
26
  export async function resolveCatalogListings({
22
27
  config,
23
28
  fetchImpl = fetch,
29
+ lookup,
30
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
24
31
  now = Date.now,
25
32
  cache = createCatalogCache(),
26
33
  }) {
@@ -33,7 +40,10 @@ export async function resolveCatalogListings({
33
40
  }
34
41
 
35
42
  try {
36
- const response = await fetchImpl(config.discoveryUrl, {
43
+ const response = await fetchPaymentTarget(config.discoveryUrl, {
44
+ fetchImpl,
45
+ lookup,
46
+ timeoutMs: requestTimeoutMs,
37
47
  headers: { accept: 'application/json' },
38
48
  });
39
49
  const body = await readJson(response);
@@ -64,7 +74,21 @@ export async function resolveCatalogListings({
64
74
  }
65
75
  }
66
76
 
67
- export function createCatalogResolver({ config, fetchImpl = fetch, now = Date.now }) {
77
+ export function createCatalogResolver({
78
+ config,
79
+ fetchImpl = fetch,
80
+ lookup,
81
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
82
+ now = Date.now,
83
+ }) {
68
84
  const cache = createCatalogCache();
69
- return () => resolveCatalogListings({ config, fetchImpl, now, cache });
85
+ return () =>
86
+ resolveCatalogListings({
87
+ config,
88
+ fetchImpl,
89
+ lookup,
90
+ requestTimeoutMs,
91
+ now,
92
+ cache,
93
+ });
70
94
  }
package/src/client.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from './guards.mjs';
9
9
  import { createSignerFromOptions } from './signer.mjs';
10
10
  import { createFileSpendStore } from './spendStore.mjs';
11
+ import { createReceiptSignerResolver } from './receipt.mjs';
11
12
 
12
13
  function isObject(value) {
13
14
  return typeof value === 'object' && value !== null;
@@ -68,7 +69,18 @@ export function createOpenPayClient(options = {}) {
68
69
  config.maxDailyAtomic === null
69
70
  ? null
70
71
  : options.spendStore ?? createFileSpendStore();
71
- const resolveCatalogListings = createCatalogResolver({ config, fetchImpl });
72
+ const resolveCatalogListings = createCatalogResolver({
73
+ config,
74
+ fetchImpl,
75
+ lookup: options.lookup,
76
+ requestTimeoutMs: options.requestTimeoutMs,
77
+ });
78
+ const resolveReceiptSigner = createReceiptSignerResolver({
79
+ discoveryUrl: config.discoveryUrl,
80
+ fetchImpl,
81
+ lookup: options.lookup,
82
+ requestTimeoutMs: options.requestTimeoutMs,
83
+ });
72
84
  const executor = createPaymentExecutor({
73
85
  config,
74
86
  session,
@@ -76,9 +88,12 @@ export function createOpenPayClient(options = {}) {
76
88
  signerAddress: signer?.address ?? null,
77
89
  spendStore,
78
90
  fetchImpl,
91
+ lookup: options.lookup,
92
+ requestTimeoutMs: options.requestTimeoutMs,
79
93
  nowSec: options.nowSec,
80
94
  now: options.now,
81
95
  resolveCatalogListings,
96
+ resolveReceiptSigner,
82
97
  });
83
98
 
84
99
  async function withSafeErrors(operation) {