openpay-x402-sdk 0.2.0 → 0.3.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Add `createJpycGate` for seller-side x402 gates backed by the OpenPay catalog,
6
+ including five-minute `accepts` caching and request-specific resource URLs.
7
+ - Support both one-shot verify-to-settle handling and split verification followed
8
+ by settlement after an expensive upstream operation succeeds.
9
+ - Use Edge-compatible UTF-8 base64 handling for payment and settlement headers.
10
+
11
+ ## 0.2.1
12
+
13
+ - Compare `accept.resource` against the requested URL using decoded query
14
+ canonicalization (ordered `URLSearchParams` pairs) instead of byte equality.
15
+ Hosts such as Vercel/Next.js normalize `%20` to `+` before the app sees the
16
+ request, which made honest sellers fail `resource_mismatch`. Distinct decoded
17
+ values (`%2B`, double encoding, reordered or extra params) still mismatch.
18
+
3
19
  ## 0.2.0
4
20
 
5
21
  - Trust query-string variants of a query-free catalog URL after the live
package/README.md CHANGED
@@ -32,6 +32,67 @@ inside `{ ok, status, body }`. `quote()` fetches and validates a 402 challenge b
32
32
  does not need a signer and never pays. `pay()` requires a signer and serializes
33
33
  concurrent calls so every call sees the latest session total.
34
34
 
35
+ ## Sell with the SDK
36
+
37
+ Create a gate with the exact resource URL registered in OpenPay discovery. For
38
+ inexpensive content, `handle()` verifies and settles the payment in one call:
39
+
40
+ ```js
41
+ import { createJpycGate } from 'openpay-x402-sdk';
42
+
43
+ const gate = createJpycGate({
44
+ resourceUrl: process.env.MY_RESOURCE_URL,
45
+ });
46
+
47
+ export async function GET(request) {
48
+ const payment = await gate.handle(request);
49
+ if (payment instanceof Response) return payment;
50
+
51
+ const response = Response.json({ your: 'paid content' });
52
+ response.headers.set(
53
+ 'X-PAYMENT-RESPONSE',
54
+ payment.paymentResponseHeader,
55
+ );
56
+ return response;
57
+ }
58
+ ```
59
+
60
+ For an expensive upstream operation, verify first and settle only after the
61
+ operation succeeds. If `callUpstream()` fails, return an error before calling
62
+ `settle()` so the buyer remains uncharged:
63
+
64
+ ```js
65
+ export async function GET(request) {
66
+ const payment = await gate.verify(request);
67
+ if (payment instanceof Response) return payment;
68
+
69
+ let data;
70
+ try {
71
+ data = await callUpstream();
72
+ } catch {
73
+ return Response.json({ error: 'upstream_failed' }, { status: 502 });
74
+ }
75
+
76
+ const settlement = await payment.settle();
77
+ if (settlement instanceof Response) return settlement;
78
+
79
+ const response = Response.json(data);
80
+ response.headers.set(
81
+ 'X-PAYMENT-RESPONSE',
82
+ settlement.paymentResponseHeader,
83
+ );
84
+ return response;
85
+ }
86
+ ```
87
+
88
+ `createJpycGate` fetches `accepts` from `/api/discovery` and caches it for five
89
+ minutes. Until `resourceUrl` is listed with a non-empty `accepts`, `handle()` and
90
+ `verify()` throw; map that bootstrap condition to an HTTP 500 response. Pass
91
+ `openpayOrigin` to use an origin other than `https://open-pay.jp`.
92
+
93
+ The copy-paste paywall snippet generated by OpenPay provides the same one-shot
94
+ gate; `createJpycGate` is its importable SDK counterpart with split settlement.
95
+
35
96
  ## Money guards
36
97
 
37
98
  | Option | Default | Guard |
package/index.d.ts CHANGED
@@ -194,6 +194,28 @@ export function createOpenPayClient(
194
194
  options?: OpenPayClientOptions,
195
195
  ): OpenPayClient;
196
196
 
197
+ export interface JpycGateOptions {
198
+ resourceUrl: string;
199
+ openpayOrigin?: string;
200
+ fetchImpl?: typeof globalThis.fetch;
201
+ now?: () => number;
202
+ }
203
+
204
+ export interface JpycGatePaymentResponse {
205
+ paymentResponseHeader: string;
206
+ }
207
+
208
+ export interface VerifiedJpycPayment {
209
+ settle(): Promise<Response | JpycGatePaymentResponse>;
210
+ }
211
+
212
+ export interface JpycGate {
213
+ handle(request: Request): Promise<Response | JpycGatePaymentResponse>;
214
+ verify(request: Request): Promise<Response | VerifiedJpycPayment>;
215
+ }
216
+
217
+ export function createJpycGate(options: JpycGateOptions): JpycGate;
218
+
197
219
  export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
198
220
  ReceiveWithAuthorization: Array<{ name: string; type: string }>;
199
221
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.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/gate.mjs ADDED
@@ -0,0 +1,119 @@
1
+ const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
2
+ const ACCEPTS_CACHE_MS = 5 * 60_000;
3
+
4
+ function json402(accepts, error) {
5
+ return new Response(JSON.stringify({ x402Version: 1, accepts, error }), {
6
+ status: 402,
7
+ headers: { 'content-type': 'application/json' },
8
+ });
9
+ }
10
+
11
+ function decodeBase64Json(value) {
12
+ const binary = atob(value);
13
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
14
+ return JSON.parse(new TextDecoder().decode(bytes));
15
+ }
16
+
17
+ function encodeBase64Json(value) {
18
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
19
+ let binary = '';
20
+ for (const byte of bytes) binary += String.fromCharCode(byte);
21
+ return btoa(binary);
22
+ }
23
+
24
+ export function createJpycGate({
25
+ resourceUrl,
26
+ openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
27
+ fetchImpl = globalThis.fetch,
28
+ now = Date.now,
29
+ }) {
30
+ const origin = openpayOrigin.replace(/\/+$/, '');
31
+ let acceptsCache = null;
32
+ let acceptsCachedAt = 0;
33
+
34
+ async function catalogAccepts() {
35
+ if (
36
+ acceptsCache !== null &&
37
+ now() - acceptsCachedAt < ACCEPTS_CACHE_MS
38
+ ) {
39
+ return acceptsCache;
40
+ }
41
+
42
+ const response = await fetchImpl(`${origin}/api/discovery`);
43
+ const { items } = await response.json();
44
+ const mine = (items || []).find((item) => item.resource === resourceUrl);
45
+ if (!mine || !mine.accepts || mine.accepts.length === 0) {
46
+ throw new Error(`resource not found in OpenPay catalog: ${resourceUrl}`);
47
+ }
48
+ acceptsCache = mine.accepts;
49
+ acceptsCachedAt = now();
50
+ return acceptsCache;
51
+ }
52
+
53
+ async function facilitator(path, paymentPayload, paymentRequirements) {
54
+ const response = await fetchImpl(`${origin}/api/facilitator/${path}`, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json' },
57
+ body: JSON.stringify({
58
+ x402Version: 1,
59
+ paymentPayload,
60
+ paymentRequirements,
61
+ }),
62
+ });
63
+ return response.json();
64
+ }
65
+
66
+ async function verify(request) {
67
+ const accepts = (await catalogAccepts()).map((accept) => ({
68
+ ...accept,
69
+ resource: request.url,
70
+ }));
71
+ const header = request.headers.get('x-payment');
72
+ if (!header) return json402(accepts, 'payment_required');
73
+
74
+ let paymentPayload;
75
+ try {
76
+ paymentPayload = decodeBase64Json(header);
77
+ } catch {
78
+ return json402(accepts, 'invalid_payment_payload');
79
+ }
80
+
81
+ const paymentRequirements = accepts[0];
82
+ const verification = await facilitator(
83
+ 'verify',
84
+ paymentPayload,
85
+ paymentRequirements,
86
+ );
87
+ if (verification.isValid !== true) {
88
+ return json402(
89
+ accepts,
90
+ verification.invalidReason || 'payment_invalid',
91
+ );
92
+ }
93
+
94
+ return {
95
+ async settle() {
96
+ const settlement = await facilitator(
97
+ 'settle',
98
+ paymentPayload,
99
+ paymentRequirements,
100
+ );
101
+ if (settlement.success !== true) {
102
+ return json402(
103
+ accepts,
104
+ settlement.errorReason || 'settlement_failed',
105
+ );
106
+ }
107
+ return { paymentResponseHeader: encodeBase64Json(settlement) };
108
+ },
109
+ };
110
+ }
111
+
112
+ async function handle(request) {
113
+ const verification = await verify(request);
114
+ if (verification instanceof Response) return verification;
115
+ return verification.settle();
116
+ }
117
+
118
+ return { handle, verify };
119
+ }
package/src/guards.mjs CHANGED
@@ -251,6 +251,19 @@ function addAssetReasons(reasons, rawAccept) {
251
251
  }
252
252
  }
253
253
 
254
+ // クエリの正準比較: URLSearchParams でデコードした (key, value) 列の順序付き一致。
255
+ // Vercel/Next 系ホストは request.url の時点でスペースを `+` に正規化するため (`%20` の原文は
256
+ // サーバー側で復元不可能)、accept.resource と要求 URL のバイト一致要求は正当な売り手を
257
+ // 恒常的に落とす (gateway.open-pay.jp で実害)。`%20` と `+` は form-urlencoding で同一の
258
+ // スペースにデコードされる一方、`%2B` (リテラル +) や二重エンコードは異なる値にデコード
259
+ // されるので、この比較は同義エンコーディングだけを同一視し resource 束縛は緩めない。
260
+ function sameQuery(a, b) {
261
+ const ap = [...a.searchParams];
262
+ const bp = [...b.searchParams];
263
+ if (ap.length !== bp.length) return false;
264
+ return ap.every(([k, v], i) => bp[i][0] === k && bp[i][1] === v);
265
+ }
266
+
254
267
  function addResourceReason(reasons, rawAccept, requestUrl) {
255
268
  const requested = parseHttpUrl(requestUrl, 'url');
256
269
  const resource =
@@ -261,7 +274,11 @@ function addResourceReason(reasons, rawAccept, requestUrl) {
261
274
  requested === null ||
262
275
  resource === null ||
263
276
  resource.hostname.toLowerCase() !== requested.hostname.toLowerCase() ||
264
- resource.toString() !== requested.toString()
277
+ resource.origin !== requested.origin ||
278
+ resource.pathname !== requested.pathname ||
279
+ resource.username !== requested.username ||
280
+ resource.password !== requested.password ||
281
+ !sameQuery(resource, requested)
265
282
  ) {
266
283
  reasons.push(REASONS.resourceMismatch);
267
284
  }
package/src/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './catalog.mjs';
2
2
  export * from './client.mjs';
3
3
  export * from './executor.mjs';
4
+ export * from './gate.mjs';
4
5
  export * from './guards.mjs';
5
6
  export * from './payment.mjs';
6
7
  export * from './signer.mjs';