openpay-x402-sdk 0.5.0 → 0.7.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 +66 -0
- package/README.md +226 -6
- package/index.d.ts +247 -1
- package/package.json +4 -1
- package/src/dualGate.mjs +196 -0
- package/src/executor.mjs +38 -20
- package/src/guards.mjs +14 -3
- package/src/index.mjs +4 -0
- package/src/license.mjs +123 -0
- package/src/licenseCommon.mjs +53 -0
- package/src/licenseGate.mjs +150 -0
- package/src/listing.mjs +173 -0
- package/src/network.mjs +19 -0
- package/src/spendStore.mjs +166 -6
package/src/dualGate.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// dual-rail 出品者ゲート: createJpycGate (JPYC・Polygon) に USDC (Base・標準 x402) の面を重ねる。
|
|
2
|
+
//
|
|
3
|
+
// USDC 面は OpenPay のリレー (/api/x402/relay/*) から取得・中継する:
|
|
4
|
+
// - requirements: 402 に並記する完成形 (v1Accepts + PAYMENT-REQUIRED ヘッダ) を配布
|
|
5
|
+
// - verify/settle: CDP facilitator への中継 (支払いは購入者 → 出品者 payTo へ直接)
|
|
6
|
+
//
|
|
7
|
+
// 隔離 (最重要): USDC 面の取得失敗 (リレー未点灯・障害) は null に落とし、JPYC ゲートだけで
|
|
8
|
+
// 継続する — 付帯面 (USDC) の障害が決済本体 (JPYC) を止めない。
|
|
9
|
+
//
|
|
10
|
+
// レール振り分け: PAYMENT-SIGNATURE ヘッダ (v2 = USDC クライアント)、または x-payment (v1) の
|
|
11
|
+
// network が USDC 面と一致するときだけ USDC レール。その他は従来の JPYC ゲートへ委譲する。
|
|
12
|
+
// JPYC レールの 402 には USDC accepts を追記 (decorate) して両面を常に見せる。
|
|
13
|
+
|
|
14
|
+
import { createJpycGate } from './gate.mjs';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
17
|
+
const USDC_FACE_CACHE_MS = 5 * 60_000;
|
|
18
|
+
|
|
19
|
+
function decodeBase64Json(value) {
|
|
20
|
+
const binary = atob(value);
|
|
21
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
22
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function encodeBase64Json(value) {
|
|
26
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
27
|
+
let binary = '';
|
|
28
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
29
|
+
return btoa(binary);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createDualGate({
|
|
33
|
+
resourceUrl,
|
|
34
|
+
resourceId,
|
|
35
|
+
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
36
|
+
fetchImpl = globalThis.fetch,
|
|
37
|
+
now = Date.now,
|
|
38
|
+
maxUpstreamSeconds,
|
|
39
|
+
settlementGraceSeconds,
|
|
40
|
+
}) {
|
|
41
|
+
if (typeof resourceId !== 'string' || resourceId.length === 0) {
|
|
42
|
+
throw new Error('resourceId is required (shown as MY_RESOURCE_ID in your OpenPay listing)');
|
|
43
|
+
}
|
|
44
|
+
const jpyc = createJpycGate({
|
|
45
|
+
resourceUrl,
|
|
46
|
+
openpayOrigin,
|
|
47
|
+
fetchImpl,
|
|
48
|
+
now,
|
|
49
|
+
...(maxUpstreamSeconds === undefined ? {} : { maxUpstreamSeconds }),
|
|
50
|
+
...(settlementGraceSeconds === undefined ? {} : { settlementGraceSeconds }),
|
|
51
|
+
});
|
|
52
|
+
const origin = openpayOrigin.replace(/\/+$/, '');
|
|
53
|
+
|
|
54
|
+
let usdcCache = null;
|
|
55
|
+
let usdcCachedAt = 0;
|
|
56
|
+
async function usdcFace() {
|
|
57
|
+
if (usdcCache !== null && now() - usdcCachedAt < USDC_FACE_CACHE_MS) {
|
|
58
|
+
return usdcCache;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetchImpl(
|
|
62
|
+
`${origin}/api/x402/relay/requirements?resourceId=${encodeURIComponent(resourceId)}`,
|
|
63
|
+
);
|
|
64
|
+
if (!response.ok) return null;
|
|
65
|
+
const face = await response.json();
|
|
66
|
+
if (!face || typeof face !== 'object' || !face.v1Accepts) return null;
|
|
67
|
+
usdcCache = face;
|
|
68
|
+
usdcCachedAt = now();
|
|
69
|
+
return face;
|
|
70
|
+
} catch {
|
|
71
|
+
// リレー未点灯/障害 → USDC 面なしで継続 (JPYC 本体を止めない)。キャッシュしない
|
|
72
|
+
// (復旧したら次のリクエストで拾う)。
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// JPYC ゲートが返した 402 に USDC 面 (accepts + PAYMENT-REQUIRED ヘッダ) を追記する。
|
|
78
|
+
// 402 以外・USDC 面なし・body が読めない場合はそのまま返す (壊さない)。
|
|
79
|
+
async function decorate402(response, usdc) {
|
|
80
|
+
if (!usdc || !(response instanceof Response) || response.status !== 402) {
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
let body;
|
|
84
|
+
try {
|
|
85
|
+
body = await response.clone().json();
|
|
86
|
+
} catch {
|
|
87
|
+
return response;
|
|
88
|
+
}
|
|
89
|
+
if (!body || !Array.isArray(body.accepts)) return response;
|
|
90
|
+
const headers = new Headers(response.headers);
|
|
91
|
+
headers.set('content-type', 'application/json');
|
|
92
|
+
if (typeof usdc.paymentRequiredHeader === 'string') {
|
|
93
|
+
headers.set('PAYMENT-REQUIRED', usdc.paymentRequiredHeader);
|
|
94
|
+
}
|
|
95
|
+
return new Response(
|
|
96
|
+
JSON.stringify({ ...body, accepts: [...body.accepts, usdc.v1Accepts] }),
|
|
97
|
+
{ status: 402, headers },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// USDC レールの 402。JPYC accepts の取得失敗は握って USDC 面だけで返す
|
|
102
|
+
// (challenge の失敗で支払いエラーの伝達自体を落とさない)。
|
|
103
|
+
async function usdcChallenge(usdc, error) {
|
|
104
|
+
let jpycAccepts = [];
|
|
105
|
+
try {
|
|
106
|
+
const headerless = { url: resourceUrl, headers: { get: () => null } };
|
|
107
|
+
const challenge = await jpyc.verify(headerless);
|
|
108
|
+
if (challenge instanceof Response && challenge.status === 402) {
|
|
109
|
+
const body = await challenge.json();
|
|
110
|
+
if (Array.isArray(body.accepts)) jpycAccepts = body.accepts;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* JPYC カタログ未掲載などは USDC のみで継続 */
|
|
114
|
+
}
|
|
115
|
+
const headers = { 'content-type': 'application/json' };
|
|
116
|
+
if (typeof usdc.paymentRequiredHeader === 'string') {
|
|
117
|
+
headers['PAYMENT-REQUIRED'] = usdc.paymentRequiredHeader;
|
|
118
|
+
}
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
x402Version: 1,
|
|
122
|
+
accepts: [...jpycAccepts, usdc.v1Accepts],
|
|
123
|
+
error,
|
|
124
|
+
}),
|
|
125
|
+
{ status: 402, headers },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function verify(request) {
|
|
130
|
+
const usdc = await usdcFace();
|
|
131
|
+
const signatureHeader = request.headers.get('payment-signature');
|
|
132
|
+
const v1Header = request.headers.get('x-payment');
|
|
133
|
+
let v1Network = null;
|
|
134
|
+
if (v1Header) {
|
|
135
|
+
try {
|
|
136
|
+
const decoded = decodeBase64Json(v1Header);
|
|
137
|
+
if (decoded && typeof decoded === 'object') v1Network = decoded.network;
|
|
138
|
+
} catch {
|
|
139
|
+
/* 不正 header は下のレール判定で JPYC 側に流し、そこで 402 になる */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const usdcRail =
|
|
144
|
+
usdc !== null &&
|
|
145
|
+
(Boolean(signatureHeader) ||
|
|
146
|
+
(typeof v1Network === 'string' && v1Network === usdc.v1Accepts.network));
|
|
147
|
+
|
|
148
|
+
if (usdcRail) {
|
|
149
|
+
const relay = async (path) => {
|
|
150
|
+
const response = await fetchImpl(`${origin}/api/x402/relay/${path}`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'content-type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
resourceId,
|
|
155
|
+
...(signatureHeader
|
|
156
|
+
? { paymentSignatureHeader: signatureHeader }
|
|
157
|
+
: { paymentHeader: v1Header }),
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
return response.json();
|
|
161
|
+
};
|
|
162
|
+
const verification = await relay('verify');
|
|
163
|
+
if (verification.isValid !== true) {
|
|
164
|
+
return usdcChallenge(usdc, verification.invalidReason || 'payment_invalid');
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
async settle() {
|
|
168
|
+
const settlement = await relay('settle');
|
|
169
|
+
if (settlement.success !== true) {
|
|
170
|
+
return usdcChallenge(usdc, settlement.errorReason || 'settlement_failed');
|
|
171
|
+
}
|
|
172
|
+
return { paymentResponseHeader: encodeBase64Json(settlement) };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const result = await jpyc.verify(request);
|
|
178
|
+
if (result instanceof Response) return decorate402(result, usdc);
|
|
179
|
+
return {
|
|
180
|
+
async settle() {
|
|
181
|
+
const settlement = await result.settle();
|
|
182
|
+
return settlement instanceof Response
|
|
183
|
+
? decorate402(settlement, usdc)
|
|
184
|
+
: settlement;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handle(request) {
|
|
190
|
+
const verification = await verify(request);
|
|
191
|
+
if (verification instanceof Response) return verification;
|
|
192
|
+
return verification.settle();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { handle, verify };
|
|
196
|
+
}
|
package/src/executor.mjs
CHANGED
|
@@ -127,36 +127,53 @@ function utcDate(value) {
|
|
|
127
127
|
return instant.toISOString().slice(0, 10);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Settlement truth for one paid call. HTTP 200 only means the seller returned a body; it is not
|
|
131
|
+
// evidence that the facilitator settled on chain, and an LLM reading `status: 200` will assume it is.
|
|
132
|
+
// 'verified' — a receipt header was present and the facilitator signature bound it to this payment
|
|
133
|
+
// 'unverified' — a receipt header was present but unsigned, malformed, forged, or mismatched
|
|
134
|
+
// 'receipt_unavailable' — no receipt header, or the facilitator signer could not be resolved
|
|
135
|
+
export const SETTLEMENT = Object.freeze({
|
|
136
|
+
verified: 'verified',
|
|
137
|
+
unverified: 'unverified',
|
|
138
|
+
unavailable: 'receipt_unavailable',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function settlementOutcome(
|
|
131
142
|
raw,
|
|
132
143
|
reservation,
|
|
133
144
|
normalizedAccept,
|
|
134
145
|
resolveReceiptSigner,
|
|
135
146
|
) {
|
|
147
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
148
|
+
return { receipt: null, settlement: SETTLEMENT.unavailable };
|
|
149
|
+
}
|
|
136
150
|
try {
|
|
137
151
|
const decoded = decodePaymentResponse(raw);
|
|
138
|
-
|
|
152
|
+
// A header that cannot be decoded is a seller claim we could not check, not an absent claim.
|
|
153
|
+
if (decoded === null) {
|
|
154
|
+
return { receipt: null, settlement: SETTLEMENT.unverified };
|
|
155
|
+
}
|
|
139
156
|
const expectedSigner = await resolveReceiptSigner();
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
!(await verifyBoundPaymentResponse(decoded, {
|
|
143
|
-
expectedSigner,
|
|
144
|
-
payer: reservation.payer,
|
|
145
|
-
network: reservation.network,
|
|
146
|
-
asset: reservation.asset,
|
|
147
|
-
chainId: normalizedAccept.chainId,
|
|
148
|
-
merchant: normalizedAccept.extra.openpay.merchant,
|
|
149
|
-
merchantValue: normalizedAccept.extra.openpay.merchantValue,
|
|
150
|
-
feeValue: normalizedAccept.extra.openpay.feeValue,
|
|
151
|
-
nonce: reservation.id,
|
|
152
|
-
}))
|
|
153
|
-
) {
|
|
154
|
-
return null;
|
|
157
|
+
if (expectedSigner === null) {
|
|
158
|
+
return { receipt: null, settlement: SETTLEMENT.unavailable };
|
|
155
159
|
}
|
|
156
|
-
|
|
160
|
+
const bound = await verifyBoundPaymentResponse(decoded, {
|
|
161
|
+
expectedSigner,
|
|
162
|
+
payer: reservation.payer,
|
|
163
|
+
network: reservation.network,
|
|
164
|
+
asset: reservation.asset,
|
|
165
|
+
chainId: normalizedAccept.chainId,
|
|
166
|
+
merchant: normalizedAccept.extra.openpay.merchant,
|
|
167
|
+
merchantValue: normalizedAccept.extra.openpay.merchantValue,
|
|
168
|
+
feeValue: normalizedAccept.extra.openpay.feeValue,
|
|
169
|
+
nonce: reservation.id,
|
|
170
|
+
});
|
|
171
|
+
return bound
|
|
172
|
+
? { receipt: decoded, settlement: SETTLEMENT.verified }
|
|
173
|
+
: { receipt: null, settlement: SETTLEMENT.unverified };
|
|
157
174
|
} catch {
|
|
158
175
|
// A seller-controlled receipt header must not erase an unlocked body and invite a duplicate payment.
|
|
159
|
-
return null;
|
|
176
|
+
return { receipt: null, settlement: SETTLEMENT.unverified };
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -533,7 +550,7 @@ export function createPaymentExecutor({
|
|
|
533
550
|
await confirmDailyReservation(reservation.id);
|
|
534
551
|
}
|
|
535
552
|
}
|
|
536
|
-
const receipt = await
|
|
553
|
+
const { receipt, settlement } = await settlementOutcome(
|
|
537
554
|
unlocked.headers.get('x-payment-response'),
|
|
538
555
|
reservation,
|
|
539
556
|
normalizedAccept,
|
|
@@ -543,6 +560,7 @@ export function createPaymentExecutor({
|
|
|
543
560
|
status: unlocked.status,
|
|
544
561
|
body: unlockedBody,
|
|
545
562
|
receipt,
|
|
563
|
+
settlement,
|
|
546
564
|
};
|
|
547
565
|
}
|
|
548
566
|
|
package/src/guards.mjs
CHANGED
|
@@ -167,9 +167,20 @@ function parseHttpUrl(raw, label) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
// DISCOVERY_URL は catalog trust の権威 (ここに載る URL は ALLOWED_HOSTS への手動追加なしで
|
|
171
|
+
// 支払える)。平文 http はネットワーク上で差し替え可能なので、攻撃者のカタログが「審査済み」に
|
|
172
|
+
// 化けて支払い先を乗っ取る波及を断つため https に限る。例外はローカル開発 (localhost /
|
|
173
|
+
// 127.0.0.1) のみ — 自機の facilitator/カタログを立てて配線を確かめる用途。
|
|
174
|
+
function requireDiscoveryUrl(raw, label) {
|
|
171
175
|
const url = parseHttpUrl(raw, label);
|
|
172
176
|
if (url === null) throw new Error(`${label} must be an http(s) URL`);
|
|
177
|
+
if (
|
|
178
|
+
url.protocol === 'http:' &&
|
|
179
|
+
url.hostname !== 'localhost' &&
|
|
180
|
+
url.hostname !== '127.0.0.1'
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(`${label} must use https (http is allowed only for localhost)`);
|
|
183
|
+
}
|
|
173
184
|
return url.toString();
|
|
174
185
|
}
|
|
175
186
|
|
|
@@ -206,7 +217,7 @@ export function readMoneyConfig(env = process.env) {
|
|
|
206
217
|
export function readRuntimeConfig(env = process.env) {
|
|
207
218
|
return {
|
|
208
219
|
...readMoneyConfig(env),
|
|
209
|
-
discoveryUrl:
|
|
220
|
+
discoveryUrl: requireDiscoveryUrl(
|
|
210
221
|
nonEmpty(env.DISCOVERY_URL) ?? DEFAULT_DISCOVERY_URL,
|
|
211
222
|
'DISCOVERY_URL',
|
|
212
223
|
),
|
|
@@ -264,7 +275,7 @@ export function parseClientOptions(options = {}) {
|
|
|
264
275
|
: parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
|
|
265
276
|
allowedHosts: parseAllowedHosts(options.allowedHosts),
|
|
266
277
|
catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
|
|
267
|
-
discoveryUrl:
|
|
278
|
+
discoveryUrl: requireDiscoveryUrl(
|
|
268
279
|
nonEmpty(options.discoveryUrl) ?? DEFAULT_DISCOVERY_URL,
|
|
269
280
|
'DISCOVERY_URL',
|
|
270
281
|
),
|
package/src/index.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export * from './catalog.mjs';
|
|
2
2
|
export * from './client.mjs';
|
|
3
|
+
export * from './dualGate.mjs';
|
|
4
|
+
export * from './listing.mjs';
|
|
3
5
|
export * from './executor.mjs';
|
|
4
6
|
export * from './gate.mjs';
|
|
7
|
+
export * from './license.mjs';
|
|
8
|
+
export * from './licenseGate.mjs';
|
|
5
9
|
export * from './guards.mjs';
|
|
6
10
|
export * from './network.mjs';
|
|
7
11
|
export * from './payment.mjs';
|
package/src/license.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createPublicClient, http, keccak256, parseAbi, toBytes } from 'viem';
|
|
2
|
+
import { polygon, polygonAmoy } from 'viem/chains';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LICENSE_ORIGIN, MAX_LICENSE_UINT256, LicenseError, LicenseRpcError,
|
|
5
|
+
licenseAddress, licenseIdentity, licenseOrigin,
|
|
6
|
+
} from './licenseCommon.mjs';
|
|
7
|
+
|
|
8
|
+
export { LicenseError, LicenseRpcError } from './licenseCommon.mjs';
|
|
9
|
+
|
|
10
|
+
const BALANCE_ABI = parseAbi(['function balanceOf(address account, uint256 id) view returns (uint256)']);
|
|
11
|
+
const NFT_STATUSES = new Set([
|
|
12
|
+
'awaiting_finality', 'pending', 'submitted', 'minted', 'registered',
|
|
13
|
+
'retryable', 'needs_repair', 'unknown',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export async function hasLicense({ address, chainId, contract, tokenId, rpcUrl, publicClient }) {
|
|
17
|
+
const holderAddress = licenseAddress(address);
|
|
18
|
+
const identity = licenseIdentity({ chainId, contract, tokenId });
|
|
19
|
+
if (rpcUrl !== undefined && publicClient !== undefined) {
|
|
20
|
+
throw new TypeError('Provide rpcUrl or publicClient, not both');
|
|
21
|
+
}
|
|
22
|
+
const chain = [polygon, polygonAmoy].find((value) => value.id === chainId);
|
|
23
|
+
if (publicClient === undefined && rpcUrl === undefined && !chain) {
|
|
24
|
+
throw new TypeError('rpcUrl or publicClient is required for this chainId');
|
|
25
|
+
}
|
|
26
|
+
if (rpcUrl !== undefined && (typeof rpcUrl !== 'string' || !/^https?:\/\//.test(rpcUrl))) {
|
|
27
|
+
throw new TypeError('rpcUrl must be an HTTP(S) URL');
|
|
28
|
+
}
|
|
29
|
+
const client = publicClient ?? createPublicClient({
|
|
30
|
+
chain, transport: http(rpcUrl, { retryCount: 0, timeout: 10_000 }),
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
// Verify the endpoint's chain, then pin balanceOf to the reported block. A wrong
|
|
34
|
+
// network or partial RPC response must never become a false ownership verdict.
|
|
35
|
+
if (await client.getChainId() !== chainId) throw new Error('RPC chainId mismatch');
|
|
36
|
+
const blockNumber = await client.getBlockNumber({ cacheTime: 0 });
|
|
37
|
+
if (typeof blockNumber !== 'bigint' || blockNumber < 0n) throw new Error('Invalid RPC block number');
|
|
38
|
+
const balance = await client.readContract({
|
|
39
|
+
address: identity.contract, abi: BALANCE_ABI, functionName: 'balanceOf',
|
|
40
|
+
args: [holderAddress, identity.tokenId], blockNumber,
|
|
41
|
+
});
|
|
42
|
+
if (typeof balance !== 'bigint' || balance < 0n || balance > MAX_LICENSE_UINT256) {
|
|
43
|
+
throw new Error('Invalid RPC balance');
|
|
44
|
+
}
|
|
45
|
+
return { holder: balance > 0n, balance, blockNumber };
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
throw new LicenseRpcError('Unable to read license ownership on the requested chain', { cause });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function object(value) {
|
|
52
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateResponse(body, address, product) {
|
|
56
|
+
try {
|
|
57
|
+
if (!object(body) || body.version !== 1 || licenseAddress(body.address) !== address ||
|
|
58
|
+
!object(body.license) || body.license.productId !== product ||
|
|
59
|
+
typeof body.license.tokenId !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(body.license.tokenId)) {
|
|
60
|
+
throw new Error('Invalid version, address echo or license identity');
|
|
61
|
+
}
|
|
62
|
+
const identity = licenseIdentity(body.license);
|
|
63
|
+
if (identity.tokenId !== BigInt(keccak256(toBytes(`openpay:license:${product}`))) ||
|
|
64
|
+
(typeof body.entitled !== 'boolean' && body.entitled !== null) ||
|
|
65
|
+
![null, 'purchase', 'holder'].includes(body.basis) ||
|
|
66
|
+
(body.entitled === null && body.basis !== null) ||
|
|
67
|
+
(body.entitled === true && body.basis === null) ||
|
|
68
|
+
!object(body.nft) || !NFT_STATUSES.has(body.nft.status) ||
|
|
69
|
+
(body.nft.mintTxHash !== undefined &&
|
|
70
|
+
(typeof body.nft.mintTxHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(body.nft.mintTxHash))) ||
|
|
71
|
+
(body.observedBlock !== undefined &&
|
|
72
|
+
(typeof body.observedBlock !== 'string' || !/^(0|[1-9][0-9]*)$/.test(body.observedBlock))) ||
|
|
73
|
+
typeof body.checkedAt !== 'string' || !Number.isFinite(Date.parse(body.checkedAt))) {
|
|
74
|
+
throw new Error('Invalid license status');
|
|
75
|
+
}
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
// Reject untrusted/malformed state rather than granting or denying access from it.
|
|
78
|
+
throw new LicenseError('invalid_response', 'Invalid license verify response', { cause });
|
|
79
|
+
}
|
|
80
|
+
// Project only the validated v1 fields; null remains the API's unknown state.
|
|
81
|
+
return {
|
|
82
|
+
version: 1, address: body.address,
|
|
83
|
+
license: { chainId: body.license.chainId, contract: body.license.contract,
|
|
84
|
+
tokenId: body.license.tokenId, productId: body.license.productId },
|
|
85
|
+
entitled: body.entitled, basis: body.basis,
|
|
86
|
+
nft: { status: body.nft.status, ...(body.nft.mintTxHash !== undefined ? { mintTxHash: body.nft.mintTxHash } : {}) },
|
|
87
|
+
...(body.observedBlock !== undefined ? { observedBlock: body.observedBlock } : {}),
|
|
88
|
+
checkedAt: body.checkedAt,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function verifyLicense({ address, product, origin = DEFAULT_LICENSE_ORIGIN, fetch: fetchImpl = globalThis.fetch }) {
|
|
93
|
+
const expectedAddress = licenseAddress(address);
|
|
94
|
+
if (typeof product !== 'string' || !/^h_[0-9a-f]{32}$/.test(product)) {
|
|
95
|
+
throw new TypeError('product must be an OpenPay product ID (h_ plus 32 lowercase hex digits)');
|
|
96
|
+
}
|
|
97
|
+
const trustedOrigin = licenseOrigin(origin);
|
|
98
|
+
const url = new URL('/api/license/verify', trustedOrigin);
|
|
99
|
+
url.searchParams.set('address', expectedAddress);
|
|
100
|
+
url.searchParams.set('product', product);
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetchImpl(url.toString(), {
|
|
104
|
+
method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(15_000),
|
|
105
|
+
headers: { accept: 'application/json' },
|
|
106
|
+
});
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
throw new LicenseError('network_error', 'License verify request failed', { cause });
|
|
109
|
+
}
|
|
110
|
+
// Never send the query to a redirect destination. Also reject an injected fetch
|
|
111
|
+
// that reports following a redirect or returning a different origin's response.
|
|
112
|
+
if (response.redirected || response.type === 'opaqueredirect' ||
|
|
113
|
+
(response.status >= 300 && response.status < 400) ||
|
|
114
|
+
(response.url && new URL(response.url).origin !== trustedOrigin)) {
|
|
115
|
+
throw new LicenseError('redirect', 'License verify redirects are not allowed');
|
|
116
|
+
}
|
|
117
|
+
if (!response.ok) throw new LicenseError('http_error', `License verify failed: HTTP ${response.status}`);
|
|
118
|
+
let body;
|
|
119
|
+
try { body = await response.json(); } catch (cause) {
|
|
120
|
+
throw new LicenseError('invalid_response', 'License verify response must be JSON', { cause });
|
|
121
|
+
}
|
|
122
|
+
return validateResponse(body, expectedAddress, product);
|
|
123
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getAddress, isAddress, zeroAddress } from 'viem';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LICENSE_ORIGIN = 'https://open-pay.jp';
|
|
4
|
+
export const MAX_LICENSE_UINT256 = (1n << 256n) - 1n;
|
|
5
|
+
|
|
6
|
+
export class LicenseError extends Error {
|
|
7
|
+
constructor(code, message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = 'LicenseError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class LicenseRpcError extends LicenseError {
|
|
15
|
+
constructor(message = 'License RPC failed', options) {
|
|
16
|
+
super('rpc_error', message, options);
|
|
17
|
+
this.name = 'LicenseRpcError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function licenseAddress(value, label = 'address') {
|
|
22
|
+
if (typeof value !== 'string' || !isAddress(value) || value.toLowerCase() === zeroAddress) {
|
|
23
|
+
throw new TypeError(`${label} must be a non-zero EVM address`);
|
|
24
|
+
}
|
|
25
|
+
return getAddress(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function licenseIdentity({ chainId, contract, tokenId }) {
|
|
29
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0) {
|
|
30
|
+
throw new TypeError('chainId must be a positive safe integer');
|
|
31
|
+
}
|
|
32
|
+
if (typeof tokenId !== 'bigint' &&
|
|
33
|
+
!(typeof tokenId === 'string' && /^0x[0-9a-fA-F]{1,64}$/.test(tokenId))) {
|
|
34
|
+
throw new TypeError('tokenId must be a uint256 bigint or 0x-hex string, never a number');
|
|
35
|
+
}
|
|
36
|
+
const id = BigInt(tokenId);
|
|
37
|
+
if (id < 0n || id > MAX_LICENSE_UINT256) throw new TypeError('tokenId must fit uint256');
|
|
38
|
+
return { chainId, contract: licenseAddress(contract, 'contract'), tokenId: id };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function licenseOrigin(value) {
|
|
42
|
+
let url;
|
|
43
|
+
try { url = new URL(value); } catch {
|
|
44
|
+
throw new TypeError('origin must be an HTTPS origin');
|
|
45
|
+
}
|
|
46
|
+
// The status authority and signing domain must not be substituted over plaintext.
|
|
47
|
+
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
48
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) ||
|
|
49
|
+
url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
50
|
+
throw new TypeError('origin must use HTTPS (HTTP only for localhost/127.0.0.1), without credentials or a path');
|
|
51
|
+
}
|
|
52
|
+
return url.origin;
|
|
53
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { verifyMessage } from 'viem';
|
|
3
|
+
import { createSiweMessage, parseSiweMessage } from 'viem/siwe';
|
|
4
|
+
import { hasLicense } from './license.mjs';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_LICENSE_ORIGIN, LicenseError, licenseAddress, licenseIdentity, licenseOrigin,
|
|
7
|
+
} from './licenseCommon.mjs';
|
|
8
|
+
|
|
9
|
+
const CHALLENGE_TTL_MS = 5 * 60_000;
|
|
10
|
+
const MAX_PENDING_NONCES = 10_000;
|
|
11
|
+
|
|
12
|
+
function memoryNonceStore(now) {
|
|
13
|
+
const entries = new Map();
|
|
14
|
+
return {
|
|
15
|
+
set(nonce, record) {
|
|
16
|
+
for (const [key, value] of entries) {
|
|
17
|
+
if (value.expiresAt <= now()) entries.delete(key);
|
|
18
|
+
}
|
|
19
|
+
if (entries.size >= MAX_PENDING_NONCES) throw new Error('Too many pending license challenges');
|
|
20
|
+
entries.set(nonce, record);
|
|
21
|
+
},
|
|
22
|
+
consume(nonce) {
|
|
23
|
+
const record = entries.get(nonce);
|
|
24
|
+
entries.delete(nonce);
|
|
25
|
+
return record;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createLicenseGate({
|
|
31
|
+
chainId, contract, tokenId, rpcUrl, publicClient,
|
|
32
|
+
session, origin = DEFAULT_LICENSE_ORIGIN,
|
|
33
|
+
statement = 'Sign in to use this license.', nonceStore, now = Date.now,
|
|
34
|
+
}) {
|
|
35
|
+
const identity = licenseIdentity({ chainId, contract, tokenId });
|
|
36
|
+
const audience = licenseOrigin(origin);
|
|
37
|
+
const url = new URL(audience);
|
|
38
|
+
const idHex = `0x${identity.tokenId.toString(16)}`;
|
|
39
|
+
const resource = `urn:openpay:license:${chainId}:${identity.contract.toLowerCase()}:${idHex}`;
|
|
40
|
+
if (typeof session?.secret !== 'string' || Buffer.byteLength(session.secret, 'utf8') < 32) {
|
|
41
|
+
throw new TypeError('session.secret must contain at least 32 bytes of secret key material');
|
|
42
|
+
}
|
|
43
|
+
const secret = session.secret;
|
|
44
|
+
const ttlSeconds = session.ttlSeconds ?? 300;
|
|
45
|
+
if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0 || ttlSeconds > 86_400) {
|
|
46
|
+
throw new TypeError('session.ttlSeconds must be an integer between 1 and 86400');
|
|
47
|
+
}
|
|
48
|
+
if (typeof statement !== 'string' || !/^[\x20-\x7e]*$/.test(statement)) {
|
|
49
|
+
throw new TypeError('statement must be a single-line ASCII string');
|
|
50
|
+
}
|
|
51
|
+
const store = nonceStore ?? memoryNonceStore(now);
|
|
52
|
+
if (typeof store.set !== 'function' || typeof store.consume !== 'function') {
|
|
53
|
+
throw new TypeError('nonceStore must implement set and atomic consume');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function messageFor(address, nonce, issuedAt) {
|
|
57
|
+
return createSiweMessage({
|
|
58
|
+
address, chainId, domain: url.host, scheme: url.protocol.slice(0, -1),
|
|
59
|
+
uri: audience, version: '1', statement, nonce, issuedAt: new Date(issuedAt),
|
|
60
|
+
expirationTime: new Date(issuedAt + CHALLENGE_TTL_MS), resources: [resource],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function storeCall(method, ...args) {
|
|
65
|
+
try { return await store[method](...args); } catch (cause) {
|
|
66
|
+
// A failed nonce write/consume must not produce a usable authentication session.
|
|
67
|
+
throw new LicenseError('nonce_store_error', 'License nonce store failed', { cause });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mac(value) {
|
|
72
|
+
return createHmac('sha256', secret).update(value).digest();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function challenge(address) {
|
|
76
|
+
const nonce = randomBytes(32).toString('hex');
|
|
77
|
+
const issuedAt = now();
|
|
78
|
+
const message = messageFor(licenseAddress(address), nonce, issuedAt);
|
|
79
|
+
await storeCall('set', nonce, { message, expiresAt: issuedAt + CHALLENGE_TTL_MS });
|
|
80
|
+
return message;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function verify({ message, signature }) {
|
|
84
|
+
if (typeof message !== 'string' || message.length > 8192) {
|
|
85
|
+
throw new LicenseError('invalid_challenge', 'Invalid license challenge');
|
|
86
|
+
}
|
|
87
|
+
const parsed = parseSiweMessage(message);
|
|
88
|
+
const issuedAt = parsed.issuedAt?.getTime();
|
|
89
|
+
const expiresAt = parsed.expirationTime?.getTime();
|
|
90
|
+
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > now()) {
|
|
91
|
+
throw new LicenseError('invalid_challenge', 'Invalid license challenge time');
|
|
92
|
+
}
|
|
93
|
+
if (expiresAt <= now()) throw new LicenseError('challenge_expired', 'License challenge expired');
|
|
94
|
+
let expected;
|
|
95
|
+
try { expected = messageFor(licenseAddress(parsed.address), parsed.nonce, issuedAt); } catch {
|
|
96
|
+
throw new LicenseError('invalid_challenge', 'Invalid license challenge fields');
|
|
97
|
+
}
|
|
98
|
+
// Exact reconstruction binds domain, URI, chain, contract, token, statement and
|
|
99
|
+
// expiry, including fields a permissive SIWE parser would otherwise ignore.
|
|
100
|
+
if (message !== expected) throw new LicenseError('invalid_challenge', 'License challenge does not match this gate');
|
|
101
|
+
let valid = false;
|
|
102
|
+
try {
|
|
103
|
+
valid = typeof signature === 'string' && /^0x[0-9a-fA-F]+$/.test(signature) &&
|
|
104
|
+
await verifyMessage({ address: parsed.address, message, signature });
|
|
105
|
+
} catch {
|
|
106
|
+
// Malformed signatures and signatures from a different EOA both deny entry.
|
|
107
|
+
throw new LicenseError('invalid_signature', 'Invalid license signature');
|
|
108
|
+
}
|
|
109
|
+
if (!valid) throw new LicenseError('invalid_signature', 'Invalid license signature');
|
|
110
|
+
// consume must be atomic across callers; a get/delete pair permits concurrent replay.
|
|
111
|
+
const record = await storeCall('consume', parsed.nonce);
|
|
112
|
+
if (!record || record.message !== message || record.expiresAt !== expiresAt) {
|
|
113
|
+
throw new LicenseError('invalid_nonce', 'License nonce is missing, used or mismatched');
|
|
114
|
+
}
|
|
115
|
+
if (expiresAt <= now()) throw new LicenseError('challenge_expired', 'License challenge expired');
|
|
116
|
+
const address = licenseAddress(parsed.address);
|
|
117
|
+
const ownership = await hasLicense({ ...identity, address, rpcUrl, publicClient });
|
|
118
|
+
if (expiresAt <= now()) throw new LicenseError('challenge_expired', 'License challenge expired');
|
|
119
|
+
if (!ownership.holder) throw new LicenseError('no_license', 'Wallet does not hold this license');
|
|
120
|
+
const iat = Math.floor(now() / 1000);
|
|
121
|
+
const payload = { version: 1, aud: audience, chainId, contract: identity.contract,
|
|
122
|
+
tokenId: idHex, address, iat, exp: iat + ttlSeconds };
|
|
123
|
+
const value = `opl1.${Buffer.from(JSON.stringify(payload)).toString('base64url')}`;
|
|
124
|
+
return `${value}.${mac(value).toString('base64url')}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function check(token) {
|
|
128
|
+
const invalid = () => new LicenseError('invalid_session', 'Invalid license session');
|
|
129
|
+
if (typeof token !== 'string' || token.length > 4096) throw invalid();
|
|
130
|
+
const match = /^(opl1\.[A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/.exec(token);
|
|
131
|
+
if (!match) throw invalid();
|
|
132
|
+
const tag = Buffer.from(match[2], 'base64url');
|
|
133
|
+
if (tag.toString('base64url') !== match[2] || !timingSafeEqual(tag, mac(match[1]))) throw invalid();
|
|
134
|
+
let payload;
|
|
135
|
+
try { payload = JSON.parse(Buffer.from(match[1].slice(5), 'base64url').toString('utf8')); } catch {
|
|
136
|
+
throw invalid();
|
|
137
|
+
}
|
|
138
|
+
if (!payload || payload.version !== 1 || payload.aud !== audience ||
|
|
139
|
+
payload.chainId !== chainId || payload.contract !== identity.contract || payload.tokenId !== idHex ||
|
|
140
|
+
!Number.isSafeInteger(payload.iat) || !Number.isSafeInteger(payload.exp) ||
|
|
141
|
+
payload.iat < 0 || payload.iat > Math.floor(now() / 1000) ||
|
|
142
|
+
payload.exp <= payload.iat || payload.exp - payload.iat > ttlSeconds) throw invalid();
|
|
143
|
+
let address;
|
|
144
|
+
try { address = licenseAddress(payload.address); } catch { throw invalid(); }
|
|
145
|
+
if (payload.exp <= Math.floor(now() / 1000)) throw new LicenseError('session_expired', 'License session expired');
|
|
146
|
+
return { address, tokenId: identity.tokenId, exp: payload.exp };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { challenge, verify, check };
|
|
150
|
+
}
|