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 +19 -0
- package/README.md +70 -19
- package/index.d.ts +113 -0
- package/package.json +1 -1
- package/src/catalog.mjs +27 -3
- package/src/client.mjs +16 -1
- package/src/executor.mjs +399 -57
- package/src/gate.mjs +151 -6
- package/src/guards.mjs +109 -24
- package/src/index.mjs +2 -0
- package/src/network.mjs +254 -0
- package/src/payment.mjs +20 -1
- package/src/receipt.mjs +201 -0
- package/src/spendStore.mjs +251 -32
package/src/gate.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
2
2
|
const ACCEPTS_CACHE_MS = 5 * 60_000;
|
|
3
|
+
const DEFAULT_MAX_UPSTREAM_SECONDS = 60;
|
|
4
|
+
const DEFAULT_SETTLEMENT_GRACE_SECONDS = 30;
|
|
3
5
|
|
|
4
6
|
function json402(accepts, error) {
|
|
5
7
|
return new Response(JSON.stringify({ x402Version: 1, accepts, error }), {
|
|
@@ -21,15 +23,134 @@ function encodeBase64Json(value) {
|
|
|
21
23
|
return btoa(binary);
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function isObject(value) {
|
|
27
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalAddress(value) {
|
|
31
|
+
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
|
|
32
|
+
? value.toLowerCase()
|
|
33
|
+
: null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function canonicalBytes32(value) {
|
|
37
|
+
return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)
|
|
38
|
+
? value.toLowerCase()
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canonicalDecimal(value) {
|
|
43
|
+
return typeof value === 'string' && /^[0-9]+$/.test(value)
|
|
44
|
+
? BigInt(value).toString()
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canonicalNetwork(value) {
|
|
49
|
+
if (typeof value !== 'string') return null;
|
|
50
|
+
const match = /^eip155:([0-9]+)$/.exec(value);
|
|
51
|
+
return match ? `eip155:${BigInt(match[1]).toString()}` : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function authorizationClaim(paymentPayload, paymentRequirements) {
|
|
55
|
+
if (!isObject(paymentPayload) || !isObject(paymentRequirements)) return null;
|
|
56
|
+
const inner = paymentPayload.payload;
|
|
57
|
+
const extra = paymentRequirements.extra;
|
|
58
|
+
if (!isObject(inner) || !isObject(extra)) return null;
|
|
59
|
+
const authorization = inner.authorization;
|
|
60
|
+
const openpay = extra.openpay;
|
|
61
|
+
if (!isObject(authorization) || !isObject(openpay)) return null;
|
|
62
|
+
|
|
63
|
+
const network = canonicalNetwork(paymentPayload.network);
|
|
64
|
+
const asset = canonicalAddress(paymentRequirements.asset);
|
|
65
|
+
const from = canonicalAddress(authorization.from);
|
|
66
|
+
const forwarder = canonicalAddress(openpay.forwarder);
|
|
67
|
+
const merchant = canonicalAddress(openpay.merchant);
|
|
68
|
+
const merchantValue = canonicalDecimal(openpay.merchantValue);
|
|
69
|
+
const feeReceiver = canonicalAddress(openpay.feeReceiver);
|
|
70
|
+
const feeValue = canonicalDecimal(openpay.feeValue);
|
|
71
|
+
const validAfter = canonicalDecimal(authorization.validAfter);
|
|
72
|
+
const validBefore = canonicalDecimal(authorization.validBefore);
|
|
73
|
+
const intentSalt = canonicalBytes32(authorization.intentSalt);
|
|
74
|
+
const parts = [
|
|
75
|
+
network,
|
|
76
|
+
asset,
|
|
77
|
+
from,
|
|
78
|
+
forwarder,
|
|
79
|
+
merchant,
|
|
80
|
+
merchantValue,
|
|
81
|
+
feeReceiver,
|
|
82
|
+
feeValue,
|
|
83
|
+
validAfter,
|
|
84
|
+
validBefore,
|
|
85
|
+
intentSalt,
|
|
86
|
+
];
|
|
87
|
+
if (parts.some((part) => part === null)) return null;
|
|
88
|
+
return {
|
|
89
|
+
key: JSON.stringify(parts),
|
|
90
|
+
validBefore: BigInt(validBefore),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
24
94
|
export function createJpycGate({
|
|
25
95
|
resourceUrl,
|
|
26
96
|
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
27
97
|
fetchImpl = globalThis.fetch,
|
|
28
98
|
now = Date.now,
|
|
99
|
+
maxUpstreamSeconds = DEFAULT_MAX_UPSTREAM_SECONDS,
|
|
100
|
+
settlementGraceSeconds = DEFAULT_SETTLEMENT_GRACE_SECONDS,
|
|
29
101
|
}) {
|
|
102
|
+
if (!Number.isSafeInteger(maxUpstreamSeconds) || maxUpstreamSeconds < 0) {
|
|
103
|
+
throw new Error('maxUpstreamSeconds must be a non-negative integer');
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
!Number.isSafeInteger(settlementGraceSeconds) ||
|
|
107
|
+
settlementGraceSeconds <= 0
|
|
108
|
+
) {
|
|
109
|
+
throw new Error('settlementGraceSeconds must be a positive integer');
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isSafeInteger(maxUpstreamSeconds + settlementGraceSeconds)) {
|
|
112
|
+
throw new Error('reservation validity window must be a safe integer');
|
|
113
|
+
}
|
|
30
114
|
const origin = openpayOrigin.replace(/\/+$/, '');
|
|
31
115
|
let acceptsCache = null;
|
|
32
116
|
let acceptsCachedAt = 0;
|
|
117
|
+
const authorizationClaims = new Map();
|
|
118
|
+
|
|
119
|
+
function claimAuthorization(paymentPayload, paymentRequirements) {
|
|
120
|
+
const claim = authorizationClaim(paymentPayload, paymentRequirements);
|
|
121
|
+
if (claim === null) return { ok: true, claim: null };
|
|
122
|
+
|
|
123
|
+
const nowSec = BigInt(Math.ceil(now() / 1000));
|
|
124
|
+
for (const [key, existing] of authorizationClaims) {
|
|
125
|
+
if (existing.validBefore <= nowSec) authorizationClaims.delete(key);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const requiredValidity = BigInt(
|
|
129
|
+
maxUpstreamSeconds + settlementGraceSeconds,
|
|
130
|
+
);
|
|
131
|
+
if (claim.validBefore - nowSec < requiredValidity) {
|
|
132
|
+
return { ok: false, reason: 'insufficient_validity_window' };
|
|
133
|
+
}
|
|
134
|
+
if (authorizationClaims.has(claim.key)) {
|
|
135
|
+
return { ok: false, reason: 'authorization_reserved' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const owner = Symbol();
|
|
139
|
+
authorizationClaims.set(claim.key, {
|
|
140
|
+
owner,
|
|
141
|
+
validBefore: claim.validBefore,
|
|
142
|
+
});
|
|
143
|
+
return { ok: true, claim: { key: claim.key, owner } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function releaseAuthorization(claim) {
|
|
147
|
+
if (
|
|
148
|
+
claim !== null &&
|
|
149
|
+
authorizationClaims.get(claim.key)?.owner === claim.owner
|
|
150
|
+
) {
|
|
151
|
+
authorizationClaims.delete(claim.key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
33
154
|
|
|
34
155
|
async function catalogAccepts() {
|
|
35
156
|
if (
|
|
@@ -50,7 +171,12 @@ export function createJpycGate({
|
|
|
50
171
|
return acceptsCache;
|
|
51
172
|
}
|
|
52
173
|
|
|
53
|
-
async function facilitator(
|
|
174
|
+
async function facilitator(
|
|
175
|
+
path,
|
|
176
|
+
paymentPayload,
|
|
177
|
+
paymentRequirements,
|
|
178
|
+
reservationToken,
|
|
179
|
+
) {
|
|
54
180
|
const response = await fetchImpl(`${origin}/api/facilitator/${path}`, {
|
|
55
181
|
method: 'POST',
|
|
56
182
|
headers: { 'content-type': 'application/json' },
|
|
@@ -58,6 +184,7 @@ export function createJpycGate({
|
|
|
58
184
|
x402Version: 1,
|
|
59
185
|
paymentPayload,
|
|
60
186
|
paymentRequirements,
|
|
187
|
+
...(reservationToken === undefined ? {} : { reservationToken }),
|
|
61
188
|
}),
|
|
62
189
|
});
|
|
63
190
|
return response.json();
|
|
@@ -79,17 +206,34 @@ export function createJpycGate({
|
|
|
79
206
|
}
|
|
80
207
|
|
|
81
208
|
const paymentRequirements = accepts[0];
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
209
|
+
const claimed = claimAuthorization(paymentPayload, paymentRequirements);
|
|
210
|
+
if (!claimed.ok) return json402(accepts, claimed.reason);
|
|
211
|
+
|
|
212
|
+
let verification;
|
|
213
|
+
try {
|
|
214
|
+
verification = await facilitator(
|
|
215
|
+
'verify',
|
|
216
|
+
paymentPayload,
|
|
217
|
+
paymentRequirements,
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
releaseAuthorization(claimed.claim);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
87
223
|
if (verification.isValid !== true) {
|
|
224
|
+
releaseAuthorization(claimed.claim);
|
|
88
225
|
return json402(
|
|
89
226
|
accepts,
|
|
90
227
|
verification.invalidReason || 'payment_invalid',
|
|
91
228
|
);
|
|
92
229
|
}
|
|
230
|
+
// 検証成功後は settle の結果にかかわらず期限まで claim を残し、結果不明や再試行が
|
|
231
|
+
// 同じ authorization で別の upstream 実行へ波及するのを断つ。
|
|
232
|
+
const reservationToken =
|
|
233
|
+
typeof verification.reservationToken === 'string' &&
|
|
234
|
+
verification.reservationToken.length > 0
|
|
235
|
+
? verification.reservationToken
|
|
236
|
+
: undefined;
|
|
93
237
|
|
|
94
238
|
return {
|
|
95
239
|
async settle() {
|
|
@@ -97,6 +241,7 @@ export function createJpycGate({
|
|
|
97
241
|
'settle',
|
|
98
242
|
paymentPayload,
|
|
99
243
|
paymentRequirements,
|
|
244
|
+
reservationToken,
|
|
100
245
|
);
|
|
101
246
|
if (settlement.success !== true) {
|
|
102
247
|
return json402(
|
package/src/guards.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { formatUnits, isHex } from 'viem';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MAX_AUTHORIZATION_TIMEOUT_SECONDS,
|
|
4
|
+
normalizePaymentRequirements,
|
|
5
|
+
} from './payment.mjs';
|
|
3
6
|
import {
|
|
4
7
|
parseSignerOptions,
|
|
5
8
|
readSignerMode,
|
|
@@ -9,6 +12,10 @@ import {
|
|
|
9
12
|
export const JPYC_DECIMALS = 18;
|
|
10
13
|
export const DEFAULT_MAX_PER_CALL_JPYC = '10';
|
|
11
14
|
export const DEFAULT_MAX_SESSION_JPYC = '100';
|
|
15
|
+
export const DEFAULT_MAX_TIMEOUT_SECONDS = 600;
|
|
16
|
+
// OpenPay facilitator の有効期限上限 (20分) より長い authorization を設定で
|
|
17
|
+
// 許可すると、売り手が facilitator を迂回して後日直接 settle できる波及を残す。
|
|
18
|
+
export const MAX_SUPPORTED_TIMEOUT_SECONDS = MAX_AUTHORIZATION_TIMEOUT_SECONDS;
|
|
12
19
|
export const DEFAULT_ALLOWED_HOSTS = 'open-pay.jp';
|
|
13
20
|
// カタログ信頼 (既定 ON): OpenPay の審査済みカタログ (/api/discovery) に載っている URL への
|
|
14
21
|
// 支払いを、ALLOWED_HOSTS への手動追加なしで許可する。掲載は 402 ゲート実在 + OpenPay 方式
|
|
@@ -23,8 +30,10 @@ export const REASONS = {
|
|
|
23
30
|
unsupportedScheme: 'unsupported_scheme',
|
|
24
31
|
unsupportedNetwork: 'unsupported_network',
|
|
25
32
|
invalidOpenpayMode: 'invalid_openpay_mode',
|
|
33
|
+
invalidOpenpayForwarder: 'invalid_openpay_forwarder',
|
|
26
34
|
amountMismatch: 'amount_mismatch',
|
|
27
35
|
invalidJpycAsset: 'invalid_jpyc_asset',
|
|
36
|
+
timeoutTooLong: 'timeout_too_long',
|
|
28
37
|
resourceMismatch: 'resource_mismatch',
|
|
29
38
|
invalidAccept: 'invalid_accept',
|
|
30
39
|
maxTotalRequired: 'max_total_required',
|
|
@@ -35,6 +44,7 @@ export const REASONS = {
|
|
|
35
44
|
sessionLimitExceeded: 'session_limit_exceeded',
|
|
36
45
|
dailyLimitExceeded: 'daily_limit_exceeded',
|
|
37
46
|
dailySpendUnavailable: 'daily_spend_unavailable',
|
|
47
|
+
dailyAuthorizationCrossesUtcDay: 'daily_authorization_crosses_utc_day',
|
|
38
48
|
buyerPrivateKeyMissing: 'buyer_private_key_missing',
|
|
39
49
|
stewardSignerUnconfigured: 'steward_signer_unconfigured',
|
|
40
50
|
// catalog trust 経由 (第三者ドメイン) の URL で、支払い時にライブ fetch した accept が
|
|
@@ -42,14 +52,35 @@ export const REASONS = {
|
|
|
42
52
|
catalogAcceptMismatch: 'catalog_accept_mismatch',
|
|
43
53
|
};
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
const JPYC_V3_ADDRESS = '0xE7C3D8C9a439feDe00D2600032D5dB0Be71C3c29';
|
|
56
|
+
const JPYC_V3_DEFINITION = Object.freeze({
|
|
57
|
+
address: JPYC_V3_ADDRESS,
|
|
58
|
+
name: 'JPY Coin',
|
|
59
|
+
version: '1',
|
|
60
|
+
decimals: JPYC_DECIMALS,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Buyer が署名してよい JPYC v3 domain の単一情報源。asset と自己申告 metadata を
|
|
64
|
+
// network ごとの既知値へ束縛し、allowlist 済み売り手が別 token の署名を得る波及を断つ。
|
|
65
|
+
export const SUPPORTED_JPYC_ASSETS = Object.freeze({
|
|
66
|
+
'eip155:137': JPYC_V3_DEFINITION,
|
|
67
|
+
'eip155:80002': JPYC_V3_DEFINITION,
|
|
68
|
+
'eip155:8217': JPYC_V3_DEFINITION,
|
|
69
|
+
'eip155:1001': JPYC_V3_DEFINITION,
|
|
70
|
+
'eip155:43114': JPYC_V3_DEFINITION,
|
|
71
|
+
'eip155:43113': JPYC_V3_DEFINITION,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const SUPPORTED_NETWORKS = new Set(Object.keys(SUPPORTED_JPYC_ASSETS));
|
|
75
|
+
|
|
76
|
+
// OpenPay が deploy 済みとして公開している Eip3009Forwarder。未掲載 chain の
|
|
77
|
+
// forwarder は審査済み catalog accept との完全一致が取れた場合だけ許可する。
|
|
78
|
+
// ホスト allowlist だけで署名の `to` を攻撃者 EOA へ差し替え、表示した分配を
|
|
79
|
+
// 迂回して総額を直接受領する波及を断つ。
|
|
80
|
+
export const SUPPORTED_JPYC_FORWARDERS = Object.freeze({
|
|
81
|
+
'eip155:137': '0x0F4560a777415580F0680F8B56a79B0022C6B848',
|
|
82
|
+
'eip155:80002': '0x752B7AaD0089286EB7b553d84D05233d80c9FCB4',
|
|
83
|
+
});
|
|
53
84
|
|
|
54
85
|
function nonEmpty(raw) {
|
|
55
86
|
return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
|
|
@@ -63,6 +94,21 @@ function isObject(value) {
|
|
|
63
94
|
return typeof value === 'object' && value !== null;
|
|
64
95
|
}
|
|
65
96
|
|
|
97
|
+
function parseMaxTimeoutSeconds(value, label) {
|
|
98
|
+
const parsed =
|
|
99
|
+
typeof value === 'string' && /^[0-9]+$/.test(value) ? Number(value) : value;
|
|
100
|
+
if (
|
|
101
|
+
!Number.isSafeInteger(parsed) ||
|
|
102
|
+
parsed <= 0 ||
|
|
103
|
+
parsed > MAX_SUPPORTED_TIMEOUT_SECONDS
|
|
104
|
+
) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${label} must be an integer between 1 and ${MAX_SUPPORTED_TIMEOUT_SECONDS}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return parsed;
|
|
110
|
+
}
|
|
111
|
+
|
|
66
112
|
export function parseJpycToAtomic(value, label) {
|
|
67
113
|
const raw = typeof value === 'number' ? String(value) : value;
|
|
68
114
|
if (typeof raw !== 'string' || !/^[0-9]+(?:\.[0-9]{1,18})?$/.test(raw)) {
|
|
@@ -141,6 +187,10 @@ export function readMoneyConfig(env = process.env) {
|
|
|
141
187
|
nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
|
|
142
188
|
'MAX_SESSION_JPYC',
|
|
143
189
|
),
|
|
190
|
+
maxTimeoutSeconds: parseMaxTimeoutSeconds(
|
|
191
|
+
nonEmpty(env.MAX_TIMEOUT_SECONDS) ?? DEFAULT_MAX_TIMEOUT_SECONDS,
|
|
192
|
+
'MAX_TIMEOUT_SECONDS',
|
|
193
|
+
),
|
|
144
194
|
maxDailyAtomic:
|
|
145
195
|
nonEmpty(env.MAX_DAILY_JPYC) === undefined
|
|
146
196
|
? null
|
|
@@ -204,6 +254,10 @@ export function parseClientOptions(options = {}) {
|
|
|
204
254
|
optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
|
|
205
255
|
'MAX_SESSION_JPYC',
|
|
206
256
|
),
|
|
257
|
+
maxTimeoutSeconds: parseMaxTimeoutSeconds(
|
|
258
|
+
optionAmount(options.maxTimeoutSeconds, DEFAULT_MAX_TIMEOUT_SECONDS),
|
|
259
|
+
'maxTimeoutSeconds',
|
|
260
|
+
),
|
|
207
261
|
maxDailyAtomic:
|
|
208
262
|
options.maxDailyJpyc === undefined || options.maxDailyJpyc === ''
|
|
209
263
|
? null
|
|
@@ -250,12 +304,17 @@ function reasonFromNormalizeError(error) {
|
|
|
250
304
|
return REASONS.invalidAccept;
|
|
251
305
|
}
|
|
252
306
|
|
|
253
|
-
function addAssetReasons(reasons, rawAccept) {
|
|
307
|
+
function addAssetReasons(reasons, rawAccept, accept) {
|
|
254
308
|
const extra = isObject(rawAccept) ? rawAccept.extra : undefined;
|
|
309
|
+
const expected =
|
|
310
|
+
accept === null ? undefined : SUPPORTED_JPYC_ASSETS[accept.network];
|
|
255
311
|
if (
|
|
312
|
+
expected === undefined ||
|
|
313
|
+
accept.asset !== expected.address ||
|
|
256
314
|
!isObject(extra) ||
|
|
257
|
-
extra.name !==
|
|
258
|
-
extra.
|
|
315
|
+
extra.name !== expected.name ||
|
|
316
|
+
extra.version !== expected.version ||
|
|
317
|
+
extra.decimals !== expected.decimals
|
|
259
318
|
) {
|
|
260
319
|
reasons.push(REASONS.invalidJpycAsset);
|
|
261
320
|
}
|
|
@@ -326,7 +385,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
|
|
|
326
385
|
if (accept !== null && !SUPPORTED_NETWORKS.has(accept.network)) {
|
|
327
386
|
reasons.push(REASONS.unsupportedNetwork);
|
|
328
387
|
}
|
|
329
|
-
addAssetReasons(reasons, rawAccept);
|
|
388
|
+
addAssetReasons(reasons, rawAccept, accept);
|
|
330
389
|
addResourceReason(reasons, rawAccept, requestUrl);
|
|
331
390
|
|
|
332
391
|
return {
|
|
@@ -338,7 +397,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
|
|
|
338
397
|
}
|
|
339
398
|
|
|
340
399
|
// catalog trust の掲載 accept (discovery = OpenPay サーバー権威) とライブ accept が、金銭に効く
|
|
341
|
-
// 全フィールド (asset
|
|
400
|
+
// 全フィールド (asset/有効期限/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
|
|
342
401
|
// 掲載時と別の forwarder/asset を bait-and-switch して buyer に攻撃者宛の署名を作らせる P0 を塞ぐ。
|
|
343
402
|
// どちらかが正規化不能なら不一致 (fail-close)。
|
|
344
403
|
function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
|
|
@@ -348,6 +407,7 @@ function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
|
|
|
348
407
|
return (
|
|
349
408
|
a.network === b.network &&
|
|
350
409
|
a.asset === b.asset &&
|
|
410
|
+
a.maxTimeoutSeconds === b.maxTimeoutSeconds &&
|
|
351
411
|
a.extra.openpay.forwarder === b.extra.openpay.forwarder &&
|
|
352
412
|
a.extra.openpay.merchant === b.extra.openpay.merchant &&
|
|
353
413
|
a.extra.openpay.merchantValue === b.extra.openpay.merchantValue &&
|
|
@@ -390,31 +450,39 @@ export function evaluatePaymentGuards({
|
|
|
390
450
|
}) {
|
|
391
451
|
const reasons = [];
|
|
392
452
|
const maxDailyAtomic = config.maxDailyAtomic ?? null;
|
|
453
|
+
const configuredMaxTimeoutSeconds =
|
|
454
|
+
config.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
|
|
455
|
+
// 公開 primitive では config を手組みできるため、parser を迂回した不正な上限値が
|
|
456
|
+
// authorization の絶対上限を広げる波及を断ち、無効値は 0 扱いで fail-close する。
|
|
457
|
+
const maxTimeoutSeconds =
|
|
458
|
+
Number.isSafeInteger(configuredMaxTimeoutSeconds) &&
|
|
459
|
+
configuredMaxTimeoutSeconds > 0
|
|
460
|
+
? Math.min(
|
|
461
|
+
configuredMaxTimeoutSeconds,
|
|
462
|
+
MAX_SUPPORTED_TIMEOUT_SECONDS,
|
|
463
|
+
)
|
|
464
|
+
: 0;
|
|
393
465
|
const parsedUrl = parseHttpUrl(url, 'url');
|
|
466
|
+
let catalogAcceptMatches = false;
|
|
394
467
|
if (parsedUrl === null) {
|
|
395
468
|
reasons.push(REASONS.invalidUrl);
|
|
396
469
|
} else {
|
|
397
470
|
const hostAllowed = config.allowedHosts.includes(parsedUrl.hostname.toLowerCase());
|
|
398
|
-
//
|
|
399
|
-
//
|
|
471
|
+
// catalog admission は完全一致だけ。query-free 掲載を任意 query へ広げると、
|
|
472
|
+
// GET 副作用を持つ未審査 URL へ buyer を到達させる波及が残る。
|
|
400
473
|
let listedAccept;
|
|
401
474
|
if (config.catalogTrust && catalogListings instanceof Map) {
|
|
402
475
|
listedAccept = catalogListings.get(parsedUrl.toString());
|
|
403
|
-
if (
|
|
404
|
-
listedAccept === undefined &&
|
|
405
|
-
parsedUrl.username === '' &&
|
|
406
|
-
parsedUrl.password === ''
|
|
407
|
-
) {
|
|
408
|
-
listedAccept = catalogListings.get(`${parsedUrl.origin}${parsedUrl.pathname}`);
|
|
409
|
-
}
|
|
410
476
|
}
|
|
411
477
|
const catalogListed = listedAccept !== undefined;
|
|
478
|
+
catalogAcceptMatches =
|
|
479
|
+
catalogListed && catalogAcceptConsistent(accept, listedAccept);
|
|
412
480
|
if (!hostAllowed && !catalogListed) {
|
|
413
481
|
reasons.push(REASONS.hostNotAllowed);
|
|
414
482
|
} else if (!hostAllowed && catalogListed) {
|
|
415
483
|
// ALLOWED_HOSTS 直 (open-pay.jp) はサーバー権威ゆえ照合不要。catalog trust 経由 (第三者
|
|
416
484
|
// ドメイン) でのみ、ライブ accept が掲載 accept と金銭フィールドまで一致するか照合する。
|
|
417
|
-
if (!
|
|
485
|
+
if (!catalogAcceptMatches) {
|
|
418
486
|
reasons.push(REASONS.catalogAcceptMismatch);
|
|
419
487
|
}
|
|
420
488
|
}
|
|
@@ -422,6 +490,16 @@ export function evaluatePaymentGuards({
|
|
|
422
490
|
|
|
423
491
|
const acceptValidation = validateAcceptForPayment(accept, url);
|
|
424
492
|
reasons.push(...acceptValidation.reasons);
|
|
493
|
+
if (acceptValidation.accept !== null) {
|
|
494
|
+
const knownForwarder =
|
|
495
|
+
SUPPORTED_JPYC_FORWARDERS[acceptValidation.accept.network];
|
|
496
|
+
if (
|
|
497
|
+
acceptValidation.accept.extra.openpay.forwarder !== knownForwarder &&
|
|
498
|
+
!catalogAcceptMatches
|
|
499
|
+
) {
|
|
500
|
+
reasons.push(REASONS.invalidOpenpayForwarder);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
425
503
|
|
|
426
504
|
let maxTotalAtomic = null;
|
|
427
505
|
if (requireMaxTotal) {
|
|
@@ -451,6 +529,13 @@ export function evaluatePaymentGuards({
|
|
|
451
529
|
}
|
|
452
530
|
}
|
|
453
531
|
|
|
532
|
+
if (
|
|
533
|
+
acceptValidation.accept !== null &&
|
|
534
|
+
acceptValidation.accept.maxTimeoutSeconds > maxTimeoutSeconds
|
|
535
|
+
) {
|
|
536
|
+
reasons.push(REASONS.timeoutTooLong);
|
|
537
|
+
}
|
|
538
|
+
|
|
454
539
|
if (maxDailyAtomic !== null && dailySpentAtomic === null) {
|
|
455
540
|
reasons.push(REASONS.dailySpendUnavailable);
|
|
456
541
|
}
|
package/src/index.mjs
CHANGED
|
@@ -3,6 +3,8 @@ export * from './client.mjs';
|
|
|
3
3
|
export * from './executor.mjs';
|
|
4
4
|
export * from './gate.mjs';
|
|
5
5
|
export * from './guards.mjs';
|
|
6
|
+
export * from './network.mjs';
|
|
6
7
|
export * from './payment.mjs';
|
|
8
|
+
export * from './receipt.mjs';
|
|
7
9
|
export * from './signer.mjs';
|
|
8
10
|
export * from './spendStore.mjs';
|