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 +16 -0
- package/README.md +61 -0
- package/index.d.ts +22 -0
- package/package.json +1 -1
- package/src/gate.mjs +119 -0
- package/src/guards.mjs +18 -1
- package/src/index.mjs +1 -0
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
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.
|
|
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
|
}
|