openpay-x402-sdk 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Add an opt-in persistent daily buyer limit with UTC signer/date keys, file and
6
+ injectable spend stores, quote-time visibility, and fail-closed reads.
7
+ - Record daily spend only after successful 2xx unlocks while isolating store
8
+ write failures from already completed payment responses.
9
+
10
+ ## 0.3.0
11
+
12
+ - Add `createJpycGate` for seller-side x402 gates backed by the OpenPay catalog,
13
+ including five-minute `accepts` caching and request-specific resource URLs.
14
+ - Support both one-shot verify-to-settle handling and split verification followed
15
+ by settlement after an expensive upstream operation succeeds.
16
+ - Use Edge-compatible UTF-8 base64 handling for payment and settlement headers.
17
+
3
18
  ## 0.2.1
4
19
 
5
20
  - Compare `accept.resource` against the requested URL using decoded query
package/README.md CHANGED
@@ -16,6 +16,7 @@ const client = createOpenPayClient({
16
16
  privateKey: process.env.BUYER_PRIVATE_KEY,
17
17
  maxPerCallJpyc: '10',
18
18
  maxSessionJpyc: '100',
19
+ maxDailyJpyc: '250',
19
20
  allowedHosts: 'open-pay.jp',
20
21
  });
21
22
 
@@ -32,12 +33,74 @@ inside `{ ok, status, body }`. `quote()` fetches and validates a 402 challenge b
32
33
  does not need a signer and never pays. `pay()` requires a signer and serializes
33
34
  concurrent calls so every call sees the latest session total.
34
35
 
36
+ ## Sell with the SDK
37
+
38
+ Create a gate with the exact resource URL registered in OpenPay discovery. For
39
+ inexpensive content, `handle()` verifies and settles the payment in one call:
40
+
41
+ ```js
42
+ import { createJpycGate } from 'openpay-x402-sdk';
43
+
44
+ const gate = createJpycGate({
45
+ resourceUrl: process.env.MY_RESOURCE_URL,
46
+ });
47
+
48
+ export async function GET(request) {
49
+ const payment = await gate.handle(request);
50
+ if (payment instanceof Response) return payment;
51
+
52
+ const response = Response.json({ your: 'paid content' });
53
+ response.headers.set(
54
+ 'X-PAYMENT-RESPONSE',
55
+ payment.paymentResponseHeader,
56
+ );
57
+ return response;
58
+ }
59
+ ```
60
+
61
+ For an expensive upstream operation, verify first and settle only after the
62
+ operation succeeds. If `callUpstream()` fails, return an error before calling
63
+ `settle()` so the buyer remains uncharged:
64
+
65
+ ```js
66
+ export async function GET(request) {
67
+ const payment = await gate.verify(request);
68
+ if (payment instanceof Response) return payment;
69
+
70
+ let data;
71
+ try {
72
+ data = await callUpstream();
73
+ } catch {
74
+ return Response.json({ error: 'upstream_failed' }, { status: 502 });
75
+ }
76
+
77
+ const settlement = await payment.settle();
78
+ if (settlement instanceof Response) return settlement;
79
+
80
+ const response = Response.json(data);
81
+ response.headers.set(
82
+ 'X-PAYMENT-RESPONSE',
83
+ settlement.paymentResponseHeader,
84
+ );
85
+ return response;
86
+ }
87
+ ```
88
+
89
+ `createJpycGate` fetches `accepts` from `/api/discovery` and caches it for five
90
+ minutes. Until `resourceUrl` is listed with a non-empty `accepts`, `handle()` and
91
+ `verify()` throw; map that bootstrap condition to an HTTP 500 response. Pass
92
+ `openpayOrigin` to use an origin other than `https://open-pay.jp`.
93
+
94
+ The copy-paste paywall snippet generated by OpenPay provides the same one-shot
95
+ gate; `createJpycGate` is its importable SDK counterpart with split settlement.
96
+
35
97
  ## Money guards
36
98
 
37
99
  | Option | Default | Guard |
38
100
  |---|---:|---|
39
101
  | `maxPerCallJpyc` | `10` | Upper bound for the caller-provided `maxTotalJpyc`. |
40
102
  | `maxSessionJpyc` | `100` | Cumulative cap for successful payments made by this client instance. |
103
+ | `maxDailyJpyc` | Not set | Persistent cumulative cap per signer and UTC calendar date. |
41
104
  | `allowedHosts` | `open-pay.jp` | Comma-separated bare host allowlist. |
42
105
  | `catalogTrust` | `true` | Also allows catalog URLs after the live challenge matches the catalog challenge. |
43
106
  | `discoveryUrl` | `https://open-pay.jp/api/discovery` | Catalog and OpenPay origin used by the client. |
@@ -48,7 +111,21 @@ money-field verification. Exact query-bearing catalog entries remain exact-only.
48
111
  `pay(url, { maxTotalJpyc })` always requires `maxTotalJpyc`. It is the maximum
49
112
  total—including the resource price and x402 fee—that this individual call is
50
113
  authorized to pay. It does not disable or raise `maxPerCallJpyc` or
51
- `maxSessionJpyc`; all three limits must allow the payment.
114
+ `maxSessionJpyc`; every configured limit must allow the payment.
115
+
116
+ `maxDailyJpyc` is opt-in. When set, the client stores successful 2xx unlocks in
117
+ `~/.openpay-x402/spend.json`, keyed by the lower-cased signer address and UTC
118
+ date. A missing entry starts at zero. A corrupt/unreadable store or a custom
119
+ store returning `null` rejects quotes and payments with `daily_spend_unavailable`
120
+ (fail-closed). Use `spendStore` to inject another implementation of
121
+ `{ load(key), save(key, atomicString) }`; `MAX_DAILY_JPYC` is the equivalent
122
+ optional setting for the exported environment config readers.
123
+
124
+ The file store uses best-effort read-modify-write across processes: payments are
125
+ serialized within one client process, but separate processes can race and lose
126
+ an increment. Use an atomic shared store when multiple processes share a signer.
127
+ Persistence runs only after a successful unlock; a save failure cannot change an
128
+ already completed payment response.
52
129
 
53
130
  The client also rejects non-JPYC metadata, unsupported networks or schemes,
54
131
  non-OpenPay forwarder splits, amount inconsistencies, resource URL mismatches,
package/index.d.ts CHANGED
@@ -37,13 +37,41 @@ export interface PaymentSigner {
37
37
  signTypedData(typedData: PaymentTypedData): Hex | Promise<Hex>;
38
38
  }
39
39
 
40
+ export interface SpendStore {
41
+ /**
42
+ * Return the atomic JPYC spent for the key, `'0'` when no record exists, or
43
+ * `null` only on read failure. `null` fails closed (payments are refused),
44
+ * so an absent entry must be reported as `'0'`, never `null`.
45
+ */
46
+ load(key: string): Promise<string | null>;
47
+ /** Persist the new cumulative atomic amount. Failures must not throw. */
48
+ save(key: string, atomicString: string): Promise<void>;
49
+ }
50
+
51
+ export interface FileSpendStoreOptions {
52
+ path?: string;
53
+ fsImpl?: {
54
+ readFile(path: string, encoding: 'utf8'): Promise<string>;
55
+ mkdir(path: string, options: { recursive: true }): Promise<unknown>;
56
+ writeFile(
57
+ path: string,
58
+ data: string,
59
+ encoding: 'utf8',
60
+ ): Promise<unknown>;
61
+ };
62
+ }
63
+
40
64
  interface ClientCommonOptions {
41
65
  maxPerCallJpyc?: JpycAmount;
42
66
  maxSessionJpyc?: JpycAmount;
67
+ maxDailyJpyc?: JpycAmount;
68
+ spendStore?: SpendStore;
43
69
  allowedHosts?: string;
44
70
  catalogTrust?: boolean;
45
71
  discoveryUrl?: string;
46
72
  fetchImpl?: typeof globalThis.fetch;
73
+ nowSec?: () => number;
74
+ now?: () => Date | number;
47
75
  }
48
76
 
49
77
  type NoSignerOptions = {
@@ -85,6 +113,7 @@ export interface RuntimeConfig {
85
113
  stewardSignerSecret: string | null;
86
114
  maxPerCallAtomic: bigint;
87
115
  maxSessionAtomic: bigint;
116
+ maxDailyAtomic: bigint | null;
88
117
  allowedHosts: string[];
89
118
  catalogTrust: boolean;
90
119
  discoveryUrl: string;
@@ -194,6 +223,28 @@ export function createOpenPayClient(
194
223
  options?: OpenPayClientOptions,
195
224
  ): OpenPayClient;
196
225
 
226
+ export interface JpycGateOptions {
227
+ resourceUrl: string;
228
+ openpayOrigin?: string;
229
+ fetchImpl?: typeof globalThis.fetch;
230
+ now?: () => number;
231
+ }
232
+
233
+ export interface JpycGatePaymentResponse {
234
+ paymentResponseHeader: string;
235
+ }
236
+
237
+ export interface VerifiedJpycPayment {
238
+ settle(): Promise<Response | JpycGatePaymentResponse>;
239
+ }
240
+
241
+ export interface JpycGate {
242
+ handle(request: Request): Promise<Response | JpycGatePaymentResponse>;
243
+ verify(request: Request): Promise<Response | VerifiedJpycPayment>;
244
+ }
245
+
246
+ export function createJpycGate(options: JpycGateOptions): JpycGate;
247
+
197
248
  export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
198
249
  ReceiveWithAuthorization: Array<{ name: string; type: string }>;
199
250
  };
@@ -299,6 +350,8 @@ export const REASONS: {
299
350
  maxTotalAbovePerCallLimit: 'max_total_above_per_call_limit';
300
351
  perCallLimitExceeded: 'per_call_limit_exceeded';
301
352
  sessionLimitExceeded: 'session_limit_exceeded';
353
+ dailyLimitExceeded: 'daily_limit_exceeded';
354
+ dailySpendUnavailable: 'daily_spend_unavailable';
302
355
  buyerPrivateKeyMissing: 'buyer_private_key_missing';
303
356
  stewardSignerUnconfigured: 'steward_signer_unconfigured';
304
357
  catalogAcceptMismatch: 'catalog_accept_mismatch';
@@ -336,6 +389,9 @@ export function parseClientOptions(
336
389
  options?: OpenPayClientOptions,
337
390
  ): RuntimeConfig;
338
391
  export function createPaymentSession(initialSpentAtomic?: bigint): PaymentSession;
392
+ export function createFileSpendStore(
393
+ options?: FileSpendStoreOptions,
394
+ ): SpendStore;
339
395
  export function recordSuccessfulPayment(
340
396
  session: PaymentSession,
341
397
  amountAtomic: bigint,
@@ -353,6 +409,7 @@ export function evaluatePaymentGuards(options: {
353
409
  accept: unknown;
354
410
  config: Omit<RuntimeConfig, 'discoveryUrl'> | RuntimeConfig;
355
411
  sessionSpentAtomic?: bigint;
412
+ dailySpentAtomic?: bigint | null;
356
413
  maxTotalJpyc?: JpycAmount;
357
414
  requireMaxTotal?: boolean;
358
415
  requirePrivateKey?: boolean;
@@ -422,7 +479,10 @@ export function createPaymentExecutor(options: {
422
479
  config: RuntimeConfig;
423
480
  session: PaymentSession;
424
481
  signer?: PaymentSigner | null;
482
+ signerAddress?: Address | null;
483
+ spendStore?: SpendStore | null;
425
484
  fetchImpl?: typeof globalThis.fetch;
426
485
  nowSec?: () => number;
486
+ now?: () => Date | number;
427
487
  resolveCatalogListings?: () => Promise<Map<string, unknown> | null>;
428
488
  }): PaymentExecutor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/client.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  safeErrorMessage,
8
8
  } from './guards.mjs';
9
9
  import { createSignerFromOptions } from './signer.mjs';
10
+ import { createFileSpendStore } from './spendStore.mjs';
10
11
 
11
12
  function isObject(value) {
12
13
  return typeof value === 'object' && value !== null;
@@ -63,13 +64,20 @@ export function createOpenPayClient(options = {}) {
63
64
  }
64
65
  const signer = createSignerFromOptions(options, { fetchImpl });
65
66
  const session = createPaymentSession();
67
+ const spendStore =
68
+ config.maxDailyAtomic === null
69
+ ? null
70
+ : options.spendStore ?? createFileSpendStore();
66
71
  const resolveCatalogListings = createCatalogResolver({ config, fetchImpl });
67
72
  const executor = createPaymentExecutor({
68
73
  config,
69
74
  session,
70
75
  signer,
76
+ signerAddress: signer?.address ?? null,
77
+ spendStore,
71
78
  fetchImpl,
72
79
  nowSec: options.nowSec,
80
+ now: options.now,
73
81
  resolveCatalogListings,
74
82
  });
75
83
 
package/src/executor.mjs CHANGED
@@ -55,10 +55,54 @@ export function createPaymentExecutor({
55
55
  config,
56
56
  session,
57
57
  signer = null,
58
+ signerAddress = signer?.address ?? null,
59
+ spendStore = null,
58
60
  fetchImpl = fetch,
59
61
  nowSec = () => Math.floor(Date.now() / 1000),
62
+ now = () => new Date(),
60
63
  resolveCatalogListings = async () => null,
61
64
  }) {
65
+ const dailyLimitEnabled =
66
+ config.maxDailyAtomic !== null &&
67
+ config.maxDailyAtomic !== undefined &&
68
+ spendStore !== null;
69
+ const guardConfig =
70
+ !dailyLimitEnabled && config.maxDailyAtomic != null
71
+ ? { ...config, maxDailyAtomic: null }
72
+ : config;
73
+
74
+ async function loadDailySpend() {
75
+ if (!dailyLimitEnabled || typeof signerAddress !== 'string') {
76
+ return { key: null, spentAtomic: null };
77
+ }
78
+ const current = now();
79
+ const date = (current instanceof Date ? current : new Date(current))
80
+ .toISOString()
81
+ .slice(0, 10);
82
+ const key = `${signerAddress.toLowerCase()}:${date}`;
83
+ try {
84
+ const stored = await spendStore.load(key);
85
+ if (typeof stored !== 'string' || !/^[0-9]+$/.test(stored)) {
86
+ return { key, spentAtomic: null };
87
+ }
88
+ return { key, spentAtomic: BigInt(stored) };
89
+ } catch {
90
+ return { key, spentAtomic: null };
91
+ }
92
+ }
93
+
94
+ async function saveDailySpend(dailySpend, amountAtomic) {
95
+ if (dailySpend.key === null || dailySpend.spentAtomic === null) return;
96
+ try {
97
+ await spendStore.save(
98
+ dailySpend.key,
99
+ (dailySpend.spentAtomic + amountAtomic).toString(),
100
+ );
101
+ } catch {
102
+ // The unlock already succeeded; store failure must not replace the payment response.
103
+ }
104
+ }
105
+
62
106
  async function quoteImpl(url) {
63
107
  if (typeof url !== 'string') throw new Error('url is required');
64
108
  const response = await fetchImpl(url, {
@@ -74,11 +118,13 @@ export function createPaymentExecutor({
74
118
  reasons: ['expected_402_with_accepts'],
75
119
  };
76
120
  }
121
+ const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
77
122
  const guard = evaluatePaymentGuards({
78
123
  url,
79
124
  accept,
80
- config,
125
+ config: guardConfig,
81
126
  sessionSpentAtomic: session.spentAtomic,
127
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
82
128
  catalogListings: await resolveCatalogListings(),
83
129
  });
84
130
  return quoteShape(url, response.status, guard);
@@ -107,11 +153,13 @@ export function createPaymentExecutor({
107
153
  reasons: ['expected_402_with_accepts'],
108
154
  };
109
155
  }
156
+ const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
110
157
  const guard = evaluatePaymentGuards({
111
158
  url,
112
159
  accept,
113
- config,
160
+ config: guardConfig,
114
161
  sessionSpentAtomic: session.spentAtomic,
162
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
115
163
  maxTotalJpyc,
116
164
  requireMaxTotal: true,
117
165
  requireSigner: true,
@@ -142,6 +190,9 @@ export function createPaymentExecutor({
142
190
  const unlockedBody = await readJson(unlocked);
143
191
  if (unlocked.status >= 200 && unlocked.status < 300) {
144
192
  recordSuccessfulPayment(session, guard.summary.totalAtomic);
193
+ if (dailySpend !== null) {
194
+ await saveDailySpend(dailySpend, guard.summary.totalAtomic);
195
+ }
145
196
  }
146
197
  const receipt = decodePaymentResponse(
147
198
  unlocked.headers.get('x-payment-response'),
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
@@ -33,6 +33,8 @@ export const REASONS = {
33
33
  maxTotalAbovePerCallLimit: 'max_total_above_per_call_limit',
34
34
  perCallLimitExceeded: 'per_call_limit_exceeded',
35
35
  sessionLimitExceeded: 'session_limit_exceeded',
36
+ dailyLimitExceeded: 'daily_limit_exceeded',
37
+ dailySpendUnavailable: 'daily_spend_unavailable',
36
38
  buyerPrivateKeyMissing: 'buyer_private_key_missing',
37
39
  stewardSignerUnconfigured: 'steward_signer_unconfigured',
38
40
  // catalog trust 経由 (第三者ドメイン) の URL で、支払い時にライブ fetch した accept が
@@ -139,6 +141,10 @@ export function readMoneyConfig(env = process.env) {
139
141
  nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
140
142
  'MAX_SESSION_JPYC',
141
143
  ),
144
+ maxDailyAtomic:
145
+ nonEmpty(env.MAX_DAILY_JPYC) === undefined
146
+ ? null
147
+ : parseJpycToAtomic(env.MAX_DAILY_JPYC, 'MAX_DAILY_JPYC'),
142
148
  allowedHosts: parseAllowedHosts(env.ALLOWED_HOSTS),
143
149
  catalogTrust:
144
150
  env.CATALOG_TRUST === undefined || env.CATALOG_TRUST === ''
@@ -198,6 +204,10 @@ export function parseClientOptions(options = {}) {
198
204
  optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
199
205
  'MAX_SESSION_JPYC',
200
206
  ),
207
+ maxDailyAtomic:
208
+ options.maxDailyJpyc === undefined || options.maxDailyJpyc === ''
209
+ ? null
210
+ : parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
201
211
  allowedHosts: parseAllowedHosts(options.allowedHosts),
202
212
  catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
203
213
  discoveryUrl: requireHttpUrl(
@@ -368,6 +378,7 @@ export function evaluatePaymentGuards({
368
378
  accept,
369
379
  config,
370
380
  sessionSpentAtomic = 0n,
381
+ dailySpentAtomic = null,
371
382
  maxTotalJpyc,
372
383
  requireMaxTotal = false,
373
384
  requirePrivateKey = false,
@@ -378,6 +389,7 @@ export function evaluatePaymentGuards({
378
389
  catalogListings = null,
379
390
  }) {
380
391
  const reasons = [];
392
+ const maxDailyAtomic = config.maxDailyAtomic ?? null;
381
393
  const parsedUrl = parseHttpUrl(url, 'url');
382
394
  if (parsedUrl === null) {
383
395
  reasons.push(REASONS.invalidUrl);
@@ -430,6 +442,17 @@ export function evaluatePaymentGuards({
430
442
  if (sessionSpentAtomic + total > config.maxSessionAtomic) {
431
443
  reasons.push(REASONS.sessionLimitExceeded);
432
444
  }
445
+ if (
446
+ maxDailyAtomic !== null &&
447
+ dailySpentAtomic !== null &&
448
+ dailySpentAtomic + total > maxDailyAtomic
449
+ ) {
450
+ reasons.push(REASONS.dailyLimitExceeded);
451
+ }
452
+ }
453
+
454
+ if (maxDailyAtomic !== null && dailySpentAtomic === null) {
455
+ reasons.push(REASONS.dailySpendUnavailable);
433
456
  }
434
457
 
435
458
  if (requirePrivateKey || requireSigner) {
package/src/index.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './catalog.mjs';
2
2
  export * from './client.mjs';
3
3
  export * from './executor.mjs';
4
+ export * from './gate.mjs';
4
5
  export * from './guards.mjs';
5
6
  export * from './payment.mjs';
6
7
  export * from './signer.mjs';
8
+ export * from './spendStore.mjs';
@@ -0,0 +1,86 @@
1
+ function isObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+
5
+ function isMissingFile(error) {
6
+ return isObject(error) && error.code === 'ENOENT';
7
+ }
8
+
9
+ function isAtomicString(value) {
10
+ return typeof value === 'string' && /^[0-9]+$/.test(value);
11
+ }
12
+
13
+ export function createFileSpendStore({ path, fsImpl } = {}) {
14
+ let runtimePromise;
15
+
16
+ async function resolveRuntime() {
17
+ runtimePromise ??= (async () => {
18
+ const fileSystem = fsImpl ?? (await import('node:fs/promises'));
19
+ const pathModule = await import('node:path');
20
+ const targetPath =
21
+ path ??
22
+ pathModule.join(
23
+ (await import('node:os')).homedir(),
24
+ '.openpay-x402',
25
+ 'spend.json',
26
+ );
27
+ return {
28
+ fileSystem,
29
+ targetPath,
30
+ directory: pathModule.dirname(targetPath),
31
+ };
32
+ })();
33
+ return runtimePromise;
34
+ }
35
+
36
+ async function load(key) {
37
+ try {
38
+ const { fileSystem, targetPath } = await resolveRuntime();
39
+ let raw;
40
+ try {
41
+ raw = await fileSystem.readFile(targetPath, 'utf8');
42
+ } catch (error) {
43
+ return isMissingFile(error) ? '0' : null;
44
+ }
45
+ const document = JSON.parse(raw);
46
+ if (!isObject(document)) return null;
47
+ const value = document[key];
48
+ return value === undefined ? '0' : isAtomicString(value) ? value : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ async function save(key, atomicString) {
55
+ try {
56
+ const { fileSystem, targetPath, directory } = await resolveRuntime();
57
+ let document = {};
58
+ try {
59
+ const raw = await fileSystem.readFile(targetPath, 'utf8');
60
+ const parsed = JSON.parse(raw);
61
+ if (isObject(parsed)) document = parsed;
62
+ } catch {
63
+ document = {};
64
+ }
65
+
66
+ const date = key.slice(-10);
67
+ const current = Object.fromEntries(
68
+ Object.entries(document).filter(
69
+ ([storedKey, value]) =>
70
+ storedKey.endsWith(`:${date}`) && isAtomicString(value),
71
+ ),
72
+ );
73
+ current[key] = atomicString;
74
+ await fileSystem.mkdir(directory, { recursive: true });
75
+ await fileSystem.writeFile(
76
+ targetPath,
77
+ `${JSON.stringify(current, null, 2)}\n`,
78
+ 'utf8',
79
+ );
80
+ } catch {
81
+ // The payment is already complete; persistence failure must not replace its response.
82
+ }
83
+ }
84
+
85
+ return { load, save };
86
+ }