openpay-x402-sdk 0.4.0 → 0.6.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/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(path, paymentPayload, paymentRequirements) {
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 verification = await facilitator(
83
- 'verify',
84
- paymentPayload,
85
- paymentRequirements,
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 { normalizePaymentRequirements } from './payment.mjs';
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
- export const SUPPORTED_NETWORKS = new Set([
46
- 'eip155:137',
47
- 'eip155:80002',
48
- 'eip155:8217',
49
- 'eip155:1001',
50
- 'eip155:43114',
51
- 'eip155:43113',
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)) {
@@ -121,9 +167,20 @@ function parseHttpUrl(raw, label) {
121
167
  }
122
168
  }
123
169
 
124
- function requireHttpUrl(raw, label) {
170
+ // DISCOVERY_URL は catalog trust の権威 (ここに載る URL は ALLOWED_HOSTS への手動追加なしで
171
+ // 支払える)。平文 http はネットワーク上で差し替え可能なので、攻撃者のカタログが「審査済み」に
172
+ // 化けて支払い先を乗っ取る波及を断つため https に限る。例外はローカル開発 (localhost /
173
+ // 127.0.0.1) のみ — 自機の facilitator/カタログを立てて配線を確かめる用途。
174
+ function requireDiscoveryUrl(raw, label) {
125
175
  const url = parseHttpUrl(raw, label);
126
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
+ }
127
184
  return url.toString();
128
185
  }
129
186
 
@@ -141,6 +198,10 @@ export function readMoneyConfig(env = process.env) {
141
198
  nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
142
199
  'MAX_SESSION_JPYC',
143
200
  ),
201
+ maxTimeoutSeconds: parseMaxTimeoutSeconds(
202
+ nonEmpty(env.MAX_TIMEOUT_SECONDS) ?? DEFAULT_MAX_TIMEOUT_SECONDS,
203
+ 'MAX_TIMEOUT_SECONDS',
204
+ ),
144
205
  maxDailyAtomic:
145
206
  nonEmpty(env.MAX_DAILY_JPYC) === undefined
146
207
  ? null
@@ -156,7 +217,7 @@ export function readMoneyConfig(env = process.env) {
156
217
  export function readRuntimeConfig(env = process.env) {
157
218
  return {
158
219
  ...readMoneyConfig(env),
159
- discoveryUrl: requireHttpUrl(
220
+ discoveryUrl: requireDiscoveryUrl(
160
221
  nonEmpty(env.DISCOVERY_URL) ?? DEFAULT_DISCOVERY_URL,
161
222
  'DISCOVERY_URL',
162
223
  ),
@@ -204,13 +265,17 @@ export function parseClientOptions(options = {}) {
204
265
  optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
205
266
  'MAX_SESSION_JPYC',
206
267
  ),
268
+ maxTimeoutSeconds: parseMaxTimeoutSeconds(
269
+ optionAmount(options.maxTimeoutSeconds, DEFAULT_MAX_TIMEOUT_SECONDS),
270
+ 'maxTimeoutSeconds',
271
+ ),
207
272
  maxDailyAtomic:
208
273
  options.maxDailyJpyc === undefined || options.maxDailyJpyc === ''
209
274
  ? null
210
275
  : parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
211
276
  allowedHosts: parseAllowedHosts(options.allowedHosts),
212
277
  catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
213
- discoveryUrl: requireHttpUrl(
278
+ discoveryUrl: requireDiscoveryUrl(
214
279
  nonEmpty(options.discoveryUrl) ?? DEFAULT_DISCOVERY_URL,
215
280
  'DISCOVERY_URL',
216
281
  ),
@@ -250,12 +315,17 @@ function reasonFromNormalizeError(error) {
250
315
  return REASONS.invalidAccept;
251
316
  }
252
317
 
253
- function addAssetReasons(reasons, rawAccept) {
318
+ function addAssetReasons(reasons, rawAccept, accept) {
254
319
  const extra = isObject(rawAccept) ? rawAccept.extra : undefined;
320
+ const expected =
321
+ accept === null ? undefined : SUPPORTED_JPYC_ASSETS[accept.network];
255
322
  if (
323
+ expected === undefined ||
324
+ accept.asset !== expected.address ||
256
325
  !isObject(extra) ||
257
- extra.name !== 'JPY Coin' ||
258
- extra.decimals !== JPYC_DECIMALS
326
+ extra.name !== expected.name ||
327
+ extra.version !== expected.version ||
328
+ extra.decimals !== expected.decimals
259
329
  ) {
260
330
  reasons.push(REASONS.invalidJpycAsset);
261
331
  }
@@ -326,7 +396,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
326
396
  if (accept !== null && !SUPPORTED_NETWORKS.has(accept.network)) {
327
397
  reasons.push(REASONS.unsupportedNetwork);
328
398
  }
329
- addAssetReasons(reasons, rawAccept);
399
+ addAssetReasons(reasons, rawAccept, accept);
330
400
  addResourceReason(reasons, rawAccept, requestUrl);
331
401
 
332
402
  return {
@@ -338,7 +408,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
338
408
  }
339
409
 
340
410
  // catalog trust の掲載 accept (discovery = OpenPay サーバー権威) とライブ accept が、金銭に効く
341
- // 全フィールド (asset/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
411
+ // 全フィールド (asset/有効期限/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
342
412
  // 掲載時と別の forwarder/asset を bait-and-switch して buyer に攻撃者宛の署名を作らせる P0 を塞ぐ。
343
413
  // どちらかが正規化不能なら不一致 (fail-close)。
344
414
  function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
@@ -348,6 +418,7 @@ function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
348
418
  return (
349
419
  a.network === b.network &&
350
420
  a.asset === b.asset &&
421
+ a.maxTimeoutSeconds === b.maxTimeoutSeconds &&
351
422
  a.extra.openpay.forwarder === b.extra.openpay.forwarder &&
352
423
  a.extra.openpay.merchant === b.extra.openpay.merchant &&
353
424
  a.extra.openpay.merchantValue === b.extra.openpay.merchantValue &&
@@ -390,31 +461,39 @@ export function evaluatePaymentGuards({
390
461
  }) {
391
462
  const reasons = [];
392
463
  const maxDailyAtomic = config.maxDailyAtomic ?? null;
464
+ const configuredMaxTimeoutSeconds =
465
+ config.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
466
+ // 公開 primitive では config を手組みできるため、parser を迂回した不正な上限値が
467
+ // authorization の絶対上限を広げる波及を断ち、無効値は 0 扱いで fail-close する。
468
+ const maxTimeoutSeconds =
469
+ Number.isSafeInteger(configuredMaxTimeoutSeconds) &&
470
+ configuredMaxTimeoutSeconds > 0
471
+ ? Math.min(
472
+ configuredMaxTimeoutSeconds,
473
+ MAX_SUPPORTED_TIMEOUT_SECONDS,
474
+ )
475
+ : 0;
393
476
  const parsedUrl = parseHttpUrl(url, 'url');
477
+ let catalogAcceptMatches = false;
394
478
  if (parsedUrl === null) {
395
479
  reasons.push(REASONS.invalidUrl);
396
480
  } else {
397
481
  const hostAllowed = config.allowedHosts.includes(parsedUrl.hostname.toLowerCase());
398
- // 完全一致を先に維持し、miss 時だけ query/hash を除いた同一 origin+pathname を引く。
399
- // userinfo は origin に含まれないため、query variant ではない credential 付き URL へは緩和しない。
482
+ // catalog admission は完全一致だけ。query-free 掲載を任意 query へ広げると、
483
+ // GET 副作用を持つ未審査 URL へ buyer を到達させる波及が残る。
400
484
  let listedAccept;
401
485
  if (config.catalogTrust && catalogListings instanceof Map) {
402
486
  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
487
  }
411
488
  const catalogListed = listedAccept !== undefined;
489
+ catalogAcceptMatches =
490
+ catalogListed && catalogAcceptConsistent(accept, listedAccept);
412
491
  if (!hostAllowed && !catalogListed) {
413
492
  reasons.push(REASONS.hostNotAllowed);
414
493
  } else if (!hostAllowed && catalogListed) {
415
494
  // ALLOWED_HOSTS 直 (open-pay.jp) はサーバー権威ゆえ照合不要。catalog trust 経由 (第三者
416
495
  // ドメイン) でのみ、ライブ accept が掲載 accept と金銭フィールドまで一致するか照合する。
417
- if (!catalogAcceptConsistent(accept, listedAccept)) {
496
+ if (!catalogAcceptMatches) {
418
497
  reasons.push(REASONS.catalogAcceptMismatch);
419
498
  }
420
499
  }
@@ -422,6 +501,16 @@ export function evaluatePaymentGuards({
422
501
 
423
502
  const acceptValidation = validateAcceptForPayment(accept, url);
424
503
  reasons.push(...acceptValidation.reasons);
504
+ if (acceptValidation.accept !== null) {
505
+ const knownForwarder =
506
+ SUPPORTED_JPYC_FORWARDERS[acceptValidation.accept.network];
507
+ if (
508
+ acceptValidation.accept.extra.openpay.forwarder !== knownForwarder &&
509
+ !catalogAcceptMatches
510
+ ) {
511
+ reasons.push(REASONS.invalidOpenpayForwarder);
512
+ }
513
+ }
425
514
 
426
515
  let maxTotalAtomic = null;
427
516
  if (requireMaxTotal) {
@@ -451,6 +540,13 @@ export function evaluatePaymentGuards({
451
540
  }
452
541
  }
453
542
 
543
+ if (
544
+ acceptValidation.accept !== null &&
545
+ acceptValidation.accept.maxTimeoutSeconds > maxTimeoutSeconds
546
+ ) {
547
+ reasons.push(REASONS.timeoutTooLong);
548
+ }
549
+
454
550
  if (maxDailyAtomic !== null && dailySpentAtomic === null) {
455
551
  reasons.push(REASONS.dailySpendUnavailable);
456
552
  }
package/src/index.mjs CHANGED
@@ -1,8 +1,12 @@
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';
5
7
  export * from './guards.mjs';
8
+ export * from './network.mjs';
6
9
  export * from './payment.mjs';
10
+ export * from './receipt.mjs';
7
11
  export * from './signer.mjs';
8
12
  export * from './spendStore.mjs';
@@ -0,0 +1,173 @@
1
+ // コード出品クライアント: 出品フォームを開かずに、SIWE サインイン込みで AI ストアの
2
+ // 出品を登録・一覧・更新・無効化する (POST/GET/PATCH/DELETE /api/facilitator/resources)。
3
+ //
4
+ // 法的に重要: register には **attested: true の明示が必須** (自動付与しない)。これは
5
+ // 「登録するリソースを提供・課金する正当な権利を有し、支払い (HTTP 402 等) でゲートして
6
+ // いる」という出品者本人の表明であり、SDK が代わりに宣言してよいものではない。
7
+ //
8
+ // セッション: 初回操作時に SIWE (EIP-4361) でサインインし cookie を保持・再利用する。
9
+ // 鍵はメモリ内でのみ使用し、どこにも送信しない (署名のみ)。
10
+
11
+ import { privateKeyToAccount } from 'viem/accounts';
12
+ import { createSiweMessage } from 'viem/siwe';
13
+
14
+ const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
15
+ const SIWE_MESSAGE_TTL_MS = 5 * 60_000;
16
+ const DEFAULT_SIWE_CHAIN_ID = 137; // Polygon (OpenPay の常時サポートチェーン)
17
+ const DEFAULT_STATEMENT = 'Sign in to OpenPay to manage your x402 listings.';
18
+
19
+ async function readJson(response) {
20
+ try {
21
+ return await response.json();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function createListingClient({
28
+ privateKey,
29
+ openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
30
+ fetchImpl = globalThis.fetch,
31
+ chainId = DEFAULT_SIWE_CHAIN_ID,
32
+ statement = DEFAULT_STATEMENT,
33
+ now = Date.now,
34
+ }) {
35
+ if (typeof privateKey !== 'string' || !privateKey.startsWith('0x')) {
36
+ throw new Error('privateKey (0x...) is required');
37
+ }
38
+ const account = privateKeyToAccount(privateKey);
39
+ const origin = openpayOrigin.replace(/\/+$/, '');
40
+ let cookie = null;
41
+
42
+ async function signIn() {
43
+ const nonceResponse = await fetchImpl(`${origin}/api/auth/siwe/nonce`, {
44
+ method: 'POST',
45
+ });
46
+ const nonceBody = await readJson(nonceResponse);
47
+ if (!nonceResponse.ok || typeof nonceBody?.nonce !== 'string') {
48
+ throw new Error(`siwe nonce failed: HTTP ${nonceResponse.status}`);
49
+ }
50
+ const issuedAt = new Date(now());
51
+ const message = createSiweMessage({
52
+ domain: new URL(origin).host,
53
+ address: account.address,
54
+ statement,
55
+ uri: origin,
56
+ version: '1',
57
+ chainId,
58
+ nonce: nonceBody.nonce,
59
+ issuedAt,
60
+ expirationTime: new Date(issuedAt.getTime() + SIWE_MESSAGE_TTL_MS),
61
+ });
62
+ const signature = await account.signMessage({ message });
63
+ const verifyResponse = await fetchImpl(`${origin}/api/auth/siwe/verify`, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify({ message, signature }),
67
+ });
68
+ if (!verifyResponse.ok) {
69
+ const body = await readJson(verifyResponse);
70
+ throw new Error(`siwe verify failed: ${body?.error ?? `HTTP ${verifyResponse.status}`}`);
71
+ }
72
+ const setCookie = verifyResponse.headers.get('set-cookie') ?? '';
73
+ const sessionCookie = setCookie.split(';')[0];
74
+ if (!sessionCookie.includes('=')) {
75
+ throw new Error('siwe verify returned no session cookie');
76
+ }
77
+ cookie = sessionCookie;
78
+ }
79
+
80
+ async function authedFetch(path, init = {}, retried = false) {
81
+ if (cookie === null) await signIn();
82
+ const response = await fetchImpl(`${origin}${path}`, {
83
+ ...init,
84
+ headers: { ...(init.headers ?? {}), cookie },
85
+ });
86
+ // セッション失効 (401) は一度だけサインインし直して再試行する。
87
+ if (response.status === 401 && !retried) {
88
+ cookie = null;
89
+ return authedFetch(path, init, true);
90
+ }
91
+ return response;
92
+ }
93
+
94
+ function buildBody(input) {
95
+ const { url, description, priceJpyc, category, docsUrl, license, payTo, usdc } = input;
96
+ return {
97
+ url,
98
+ description,
99
+ priceJpyc,
100
+ category,
101
+ ...(docsUrl ? { docsUrl } : {}),
102
+ ...(license ? { license } : {}),
103
+ ...(payTo ? { payTo } : {}),
104
+ ...(usdc ? { usdc } : {}),
105
+ };
106
+ }
107
+
108
+ async function expectOk(response, okStatus) {
109
+ const body = await readJson(response);
110
+ if (response.status !== okStatus) {
111
+ throw new Error(
112
+ `openpay listing API failed: HTTP ${response.status} ${body?.error ?? ''}`.trim(),
113
+ );
114
+ }
115
+ return body;
116
+ }
117
+
118
+ return {
119
+ /** SIWE でサインインする出品者アドレス (checksum)。 */
120
+ address: account.address,
121
+
122
+ /**
123
+ * 出品を登録する。attested: true の明示が必須 —
124
+ * 「このリソースを提供・課金する正当な権利があり、402 等の支払いゲートを実装している」
125
+ * という出品者本人の表明で、SDK は代行しない。
126
+ * 戻り値: { resource, paywallSnippet } (usdc 面つきなら dual-rail スニペット)。
127
+ */
128
+ async register(input) {
129
+ if (input?.attested !== true) {
130
+ throw new Error(
131
+ 'attested: true is required — you must personally affirm that you have the right ' +
132
+ 'to provide and charge for this resource and that it is payment-gated (HTTP 402).',
133
+ );
134
+ }
135
+ const response = await authedFetch('/api/facilitator/resources', {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify({ ...buildBody(input), attested: true }),
139
+ });
140
+ return expectOk(response, 201);
141
+ },
142
+
143
+ /** 自分の出品一覧 (paywallSnippet つき)。 */
144
+ async list() {
145
+ const response = await authedFetch('/api/facilitator/resources');
146
+ const body = await expectOk(response, 200);
147
+ return body.resources ?? [];
148
+ },
149
+
150
+ /** 出品を編集する (usdc を省略すると USDC 面は外れる — 現状維持は前回の値を渡す)。 */
151
+ async update(id, input) {
152
+ const response = await authedFetch(
153
+ `/api/facilitator/resources/${encodeURIComponent(id)}`,
154
+ {
155
+ method: 'PATCH',
156
+ headers: { 'content-type': 'application/json' },
157
+ body: JSON.stringify(buildBody(input)),
158
+ },
159
+ );
160
+ return expectOk(response, 200);
161
+ },
162
+
163
+ /** 出品を無効化する (公開カタログから外す・履歴は残る)。 */
164
+ async deactivate(id) {
165
+ const response = await authedFetch(
166
+ `/api/facilitator/resources/${encodeURIComponent(id)}`,
167
+ { method: 'DELETE' },
168
+ );
169
+ await expectOk(response, 200);
170
+ return true;
171
+ },
172
+ };
173
+ }