@envarai/envarpay 0.2.0-alpha.1 → 0.2.0-alpha.2

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.
@@ -15,6 +15,8 @@ export interface BuyerQuote {
15
15
  inputDigest: string;
16
16
  termsDigest: string;
17
17
  quoteId: string;
18
+ /** Absent for the original seller-advertised EnvarPay contract. */
19
+ termsSource?: 'local-policy';
18
20
  }
19
21
  export interface BuyerRecord {
20
22
  id: string;
@@ -29,6 +31,12 @@ export interface BuyerRecord {
29
31
  quote: BuyerQuote;
30
32
  input: Input;
31
33
  inputSchema: Record<string, unknown>;
34
+ peerMode?: 'standard-a2a';
35
+ standardBinding?: {
36
+ policyDigest: string;
37
+ bodyDigest: string;
38
+ challengeDigest: string;
39
+ };
32
40
  continuations?: {
33
41
  messageId: string;
34
42
  input: Input;
@@ -1,6 +1,6 @@
1
1
  import type { ClientEvmSigner } from '@x402/evm';
2
2
  import type { PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types';
3
- import { type BuyerPolicy, type Input } from './types.js';
3
+ import { type BuyerPolicy, type Input, type StandardA2APeer } from './types.js';
4
4
  import { CredentialVault } from './vault.js';
5
5
  import { BuyerStore, type BuyerQuote } from './buyer-store.js';
6
6
  import { type MppBuyerOptions, type MppReceipt } from './mpp-client.js';
@@ -78,9 +78,10 @@ export declare class CommerceBuyer {
78
78
  policyVersion: 1;
79
79
  paymentsEnabled: boolean;
80
80
  approval: "per_purchase" | "within_preapproved_limits";
81
- peers: ({
81
+ peers: (StandardA2APeer | {
82
82
  id: string;
83
83
  cardUrl: string;
84
+ mode?: "envarpay";
84
85
  protocol: "x402" | "mpp";
85
86
  currency: string;
86
87
  recipient: string;
@@ -88,6 +89,7 @@ export declare class CommerceBuyer {
88
89
  } | {
89
90
  id: string;
90
91
  cardUrl: string;
92
+ mode?: "envarpay";
91
93
  protocol: "free";
92
94
  currency: null;
93
95
  recipient: null;
@@ -102,6 +104,7 @@ export declare class CommerceBuyer {
102
104
  get(caller: string, id: string): BuyerSnapshot;
103
105
  preview(caller: string, input: BuyerPreviewInput): Promise<BuyerSnapshot>;
104
106
  private prepare;
107
+ private prepareStandard;
105
108
  private prepareFree;
106
109
  private replayFree;
107
110
  private prepareMpp;
@@ -1,6 +1,6 @@
1
1
  import { findDefaultAsset } from '@x402/evm';
2
2
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
- import { AgentCard, SendMessageRequest, GetTaskRequest, Task, TaskState } from '@a2a-js/sdk';
3
+ import { AgentCard, SendMessageRequest, GetTaskRequest, Task, TaskState, Message, Role } from '@a2a-js/sdk';
4
4
  import { ClientFactory, JsonRpcTransportFactory } from '@a2a-js/sdk/client';
5
5
  import { x402Client } from '@x402/core/client';
6
6
  import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentSignatureHeader } from '@x402/core/http';
@@ -67,6 +67,31 @@ function noExternalRefs(value) {
67
67
  noExternalRefs(child);
68
68
  }
69
69
  }
70
+ function standardPolicyDigest(peer) {
71
+ return digest({ mode: peer.mode, cardUrl: peer.cardUrl, endpoint: peer.endpoint, authentication: peer.authentication,
72
+ protocol: peer.protocol, currency: peer.currency, recipient: peer.recipient, localContract: peer.localContract });
73
+ }
74
+ function standardRequirements(peer, required) {
75
+ let resource;
76
+ try {
77
+ resource = new URL(required.resource?.url ?? '');
78
+ }
79
+ catch {
80
+ throw new CommerceError('quote_mismatch', 'Standard payment resource must name the approved A2A endpoint');
81
+ }
82
+ const candidates = Array.isArray(required.accepts) ? required.accepts.filter(r => {
83
+ if (!r || r.scheme !== 'exact' || typeof r.asset !== 'string' || typeof r.payTo !== 'string' || `${r.network}/erc20:${r.asset.toLowerCase()}` !== peer.currency || r.payTo.toLowerCase() !== peer.recipient || r.amount !== peer.localContract.amount || !Number.isInteger(r.maxTimeoutSeconds) || r.maxTimeoutSeconds < 1 || r.maxTimeoutSeconds > 300)
84
+ return false;
85
+ const asset = findDefaultAsset(r.asset, r.network), extra = r.extra;
86
+ return !!asset && !!extra && typeof extra === 'object' && !Array.isArray(extra)
87
+ && (extra.assetTransferMethod === undefined || extra.assetTransferMethod === 'eip3009')
88
+ && (extra.paymentFlow === undefined || extra.paymentFlow === 'upfront')
89
+ && extra.name === asset.name && extra.version === asset.version;
90
+ }) : [];
91
+ if (required.x402Version !== 2 || resource.href !== peer.endpoint || resource.username || resource.password || resource.hash || candidates.length !== 1)
92
+ throw new CommerceError('quote_mismatch', 'Native challenge differs from the exact locally approved resource, price, asset, payee or timeout');
93
+ return candidates[0];
94
+ }
70
95
  /** Independent x402 wallet; payment and execution state are deliberately separate. */
71
96
  export class CommerceBuyer {
72
97
  options;
@@ -86,8 +111,8 @@ export class CommerceBuyer {
86
111
  if (this.policy.peers.some(p => p.protocol === 'mpp') && (!options.mpp || !options.mpp.payer || !/^pm_[A-Za-z0-9]+$/.test(options.mpp.paymentMethod) || !['test', 'live'].includes(options.mpp.mode) || typeof options.mpp.createToken !== 'function' || typeof options.mpp.verifyReceipt !== 'function'))
87
112
  throw new CommerceError('mpp_provider_missing', 'MPP peers require explicit token creation and independent provider verification');
88
113
  for (const peer of this.policy.peers)
89
- if (!options.peerTokens[peer.id] || options.peerTokens[peer.id].length < 16)
90
- throw new CommerceError('peer_credentials', 'Each allowed peer needs its own authentication token');
114
+ if (!(peer.mode === 'standard-a2a' && peer.authentication === 'none') && (!options.peerTokens[peer.id] || options.peerTokens[peer.id].length < 16))
115
+ throw new CommerceError('peer_credentials', 'Each authenticated peer needs its own authentication token');
91
116
  }
92
117
  /** Background observation only; never signs, replays credentials or changes reservations. */
93
118
  start(intervalMs = 2000) {
@@ -117,6 +142,11 @@ export class CommerceBuyer {
117
142
  }
118
143
  endpoint(peer) {
119
144
  const explicit = this.options.peerEndpoints?.[peer.id];
145
+ if (peer.mode === 'standard-a2a') {
146
+ if (explicit && explicit !== peer.endpoint)
147
+ throw new CommerceError('endpoint_policy', 'A standard peer endpoint must match its locally reviewed policy');
148
+ return peer.endpoint;
149
+ }
120
150
  const card = new URL(peer.cardUrl);
121
151
  if (!explicit && (!card.pathname.endsWith('/agent-card.json') || card.search))
122
152
  throw new CommerceError('endpoint_policy', 'Configure an explicit peer endpoint for this Agent Card URL');
@@ -133,7 +163,10 @@ export class CommerceBuyer {
133
163
  if ((request.url === peer.cardUrl && request.method !== 'GET') || (request.url === endpoint && request.method !== 'POST'))
134
164
  throw new CommerceError('peer_scope', 'Unsupported peer operation');
135
165
  const headers = new Headers(request.headers);
136
- headers.set('Authorization', `Bearer ${this.options.peerTokens[peer.id]}`);
166
+ if (peer.mode === 'standard-a2a' && peer.authentication === 'none')
167
+ headers.delete('Authorization');
168
+ else
169
+ headers.set('Authorization', `Bearer ${this.options.peerTokens[peer.id]}`);
137
170
  headers.set('A2A-Version', '1.0');
138
171
  const response = await this.fetchImpl(new Request(request, { headers, redirect: 'error', signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]) }));
139
172
  if (response.status >= 300 && response.status < 400)
@@ -183,7 +216,11 @@ export class CommerceBuyer {
183
216
  if (!response.ok)
184
217
  throw new CommerceError('peer_card', 'Unable to read allowed Agent Card');
185
218
  const card = AgentCard.fromJSON(await response.json());
186
- if (!card.supportedInterfaces.some(i => i.protocolVersion === '1.0' && i.protocolBinding === 'JSONRPC' && i.url === endpoint) || card.supportedInterfaces.some(i => i.url !== endpoint))
219
+ if (!card.supportedInterfaces.some(i => i.protocolVersion === '1.0' && i.protocolBinding === 'JSONRPC' && i.url === endpoint))
220
+ throw new CommerceError('peer_interface', 'Agent Card must expose the approved A2A 1.0 JSONRPC endpoint');
221
+ if (peer.mode === 'standard-a2a')
222
+ return this.prepareStandard(caller, input, fingerprint, peer, card);
223
+ if (card.supportedInterfaces.some(i => i.url !== endpoint))
187
224
  throw new CommerceError('peer_interface', 'Agent Card must expose only the approved A2A 1.0 JSONRPC endpoint');
188
225
  const advertised = card.capabilities?.extensions.find(e => e.uri === COMMERCE)?.params;
189
226
  if (!advertised || advertised.offerId !== input.offerId || !advertised.serviceId || !Number.isSafeInteger(advertised.serviceRevision) || advertised.serviceRevision < 1 || !/^[0-9a-f]{64}$/.test(advertised.termsDigest ?? '') || !advertised.contract?.inputSchema || (peer.protocol !== 'free' && (advertised.collection?.kind !== 'upfront' || advertised.payment?.adapter !== peer.protocol)))
@@ -247,6 +284,63 @@ export class CommerceBuyer {
247
284
  };
248
285
  return this.snapshot(this.options.store.insert({ protocol: 'x402', caller, peerId: peer.id, cardUrl: peer.cardUrl, endpoint, messageId: input.messageId, fingerprint, input: structuredClone(input.input), inputSchema: structuredClone(advertised.contract.inputSchema), body: originalBody, quoteToken: randomBytes(32).toString('base64url'), quote: frozenQuote, required, requirements: candidates[0] }));
249
286
  }
287
+ async prepareStandard(caller, input, fingerprint, peer, card) {
288
+ const contract = peer.localContract;
289
+ if (card.securityRequirements.length && !card.securityRequirements.some(requirement => {
290
+ const names = Object.keys(requirement.schemes);
291
+ if (!names.length)
292
+ return true;
293
+ if (peer.authentication !== 'bearer' || names.length !== 1)
294
+ return false;
295
+ const name = names[0], scheme = card.securitySchemes[name]?.scheme;
296
+ return scheme?.$case === 'httpAuthSecurityScheme' && scheme.value.scheme.toLowerCase() === 'bearer' && !requirement.schemes[name].list.length;
297
+ }))
298
+ throw new CommerceError('peer_authentication', 'Agent Card authentication is not satisfied by the locally approved none/bearer mode');
299
+ if (input.offerId !== contract.offerId)
300
+ throw new CommerceError('offer_not_allowed', 'Select the locally reviewed standard-peer offer');
301
+ const ajv = new Ajv2020({ strict: true });
302
+ addFormats(ajv);
303
+ if (!ajv.compile(contract.inputSchema)(input.input))
304
+ throw new CommerceError('invalid_input', 'Input does not satisfy the locally reviewed schema');
305
+ const amount = atomic(contract.amount).toString();
306
+ if (atomic(amount) > atomic(peer.maxPerPurchase))
307
+ throw new CommerceError('purchase_limit', 'Local price exceeds the per-purchase limit');
308
+ let body = '', required;
309
+ const capture = async (value, init) => {
310
+ const request = new Request(value, init);
311
+ body = await request.clone().text();
312
+ if (Buffer.byteLength(body) > 1024 * 1024)
313
+ throw new CommerceError('request_too_large', 'Purchase input exceeds 1 MiB');
314
+ const response = await this.scopedFetch(peer, peer.endpoint)(request);
315
+ if (response.status !== 402)
316
+ throw new CommerceError('paid_quote_required', 'The operator-approved paid-only endpoint must return 402 before any authorization');
317
+ const header = response.headers.get('PAYMENT-REQUIRED');
318
+ if (!header || header.length > 65536)
319
+ throw new CommerceError('invalid_challenge', 'Missing or oversized native x402 challenge');
320
+ required = decodePaymentRequiredHeader(header);
321
+ throw new CommerceError('preview_captured', 'Native standard payment challenge captured');
322
+ };
323
+ // Ignore every advertised alternative; only the operator-selected interface may receive requests.
324
+ const selected = AgentCard.fromJSON({ ...AgentCard.toJSON(card), supportedInterfaces: [{ url: peer.endpoint, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }] });
325
+ const client = await new ClientFactory({ transports: [new JsonRpcTransportFactory({ fetchImpl: capture })] }).createFromAgentCard(selected);
326
+ try {
327
+ await client.sendMessage(SendMessageRequest.fromJSON({ message: { messageId: input.messageId, role: 'ROLE_USER', parts: [{ data: input.input }] }, configuration: { returnImmediately: true } }));
328
+ }
329
+ catch (error) {
330
+ if (!required)
331
+ throw error;
332
+ }
333
+ if (!required || !body)
334
+ throw new CommerceError('invalid_challenge', 'The approved endpoint did not return a native payment challenge');
335
+ const requirements = standardRequirements(peer, required), policyDigest = standardPolicyDigest(peer);
336
+ const quote = { quoteId: randomUUID(), messageId: input.messageId, payer: this.options.signer.address.toLowerCase(), amount,
337
+ currency: peer.currency, recipient: peer.recipient, expiresAt: new Date(Date.now() + requirements.maxTimeoutSeconds * 1000).toISOString(),
338
+ serviceId: peer.id, serviceRevision: contract.revision, offerId: contract.offerId, inputDigest: digest(input.input), termsDigest: policyDigest, termsSource: 'local-policy' };
339
+ return this.snapshot(this.options.store.insert({ protocol: 'x402', peerMode: 'standard-a2a', caller, peerId: peer.id, cardUrl: peer.cardUrl, endpoint: peer.endpoint,
340
+ messageId: input.messageId, fingerprint, input: structuredClone(input.input), inputSchema: structuredClone(contract.inputSchema), body,
341
+ quoteToken: randomBytes(32).toString('base64url'), quote, required, requirements,
342
+ standardBinding: { policyDigest, bodyDigest: digest(body), challengeDigest: digest(required) } }));
343
+ }
250
344
  prepareFree(caller, input, fingerprint, peer, endpoint, advertised) {
251
345
  if (advertised.pricing?.kind !== 'free' || advertised.collection?.kind !== 'none' || advertised.payment !== null)
252
346
  throw new CommerceError('free_offer_required', 'This peer permits explicit free offers only');
@@ -395,6 +489,17 @@ export class CommerceBuyer {
395
489
  const peer = this.peer(record.cardUrl);
396
490
  if (peer.id !== record.peerId || this.endpoint(peer) !== record.endpoint || peer.currency !== record.quote.currency || peer.recipient !== record.quote.recipient || (peer.protocol !== 'free' && atomic(record.quote.amount) > atomic(peer.maxPerPurchase)) || record.quote.payer !== (peer.protocol === 'free' ? record.caller : peer.protocol === 'mpp' ? this.options.mpp?.payer : this.options.signer?.address.toLowerCase()) || (record.protocol === 'mpp' && record.mppMode !== this.options.mpp?.mode))
397
491
  throw new CommerceError('policy_changed', 'Original purchase no longer matches local wallet policy');
492
+ if ((record.peerMode === 'standard-a2a') !== (peer.mode === 'standard-a2a'))
493
+ throw new CommerceError('policy_changed', 'Original peer protocol profile changed');
494
+ if (record.peerMode === 'standard-a2a') {
495
+ if (peer.mode !== 'standard-a2a' || !record.standardBinding || record.standardBinding.policyDigest !== standardPolicyDigest(peer))
496
+ throw new CommerceError('policy_changed', 'Original local standard-peer contract or authentication changed');
497
+ const quote = record.quote, contract = peer.localContract;
498
+ if (quote.termsSource !== 'local-policy' || quote.termsDigest !== record.standardBinding.policyDigest || quote.inputDigest !== digest(record.input) || quote.messageId !== record.messageId || quote.amount !== contract.amount || quote.serviceId !== peer.id || quote.serviceRevision !== contract.revision || quote.offerId !== contract.offerId || digest(record.inputSchema) !== digest(contract.inputSchema))
499
+ throw new CommerceError('recovery_conflict', 'Local review quote no longer matches its frozen standard-peer contract and input');
500
+ if (record.standardBinding.bodyDigest !== digest(record.body) || record.standardBinding.challengeDigest !== digest(record.required) || digest(standardRequirements(peer, record.required)) !== digest(record.requirements))
501
+ throw new CommerceError('recovery_conflict', 'Original standard request or native challenge changed');
502
+ }
398
503
  if (signing && peer.protocol !== 'free' && !this.policy.paymentsEnabled)
399
504
  throw new CommerceError('payments_disabled', 'Enable payments in local wallet policy before confirming');
400
505
  return peer;
@@ -470,6 +575,8 @@ export class CommerceBuyer {
470
575
  async verify(record, saved, receipt) {
471
576
  if (record.paymentState === 'confirmed')
472
577
  return record;
578
+ if (record.peerMode === 'standard-a2a' && receipt && (receipt.network !== saved.requirements.network || (receipt.payer && receipt.payer.toLowerCase() !== record.quote.payer)))
579
+ throw new CommerceError('receipt_binding', 'Receipt differs from the original standard-peer chain or payer');
473
580
  if (receipt)
474
581
  record = this.options.store.update(record.id, { receipt });
475
582
  if (!receipt?.transaction && record.fromBlock && this.options.findOriginalReceipt)
@@ -505,8 +612,16 @@ export class CommerceBuyer {
505
612
  }
506
613
  catch { /* retain the original receipt and reservation for reconciliation */ }
507
614
  const raw = await response.json();
615
+ if (record.peerMode === 'standard-a2a' && (raw.jsonrpc !== '2.0' || raw.id !== JSON.parse(record.body).id))
616
+ throw new CommerceError('rpc_response_binding', 'Peer response does not match the original standard JSON-RPC request');
508
617
  if (response.ok && raw.result?.task)
509
618
  record = this.task(record, raw.result.task);
619
+ else if (record.peerMode === 'standard-a2a' && response.ok && raw.result?.message) {
620
+ const message = Message.fromJSON(raw.result.message);
621
+ if (!message.messageId || message.role !== Role.ROLE_AGENT || !message.parts.length)
622
+ throw new CommerceError('message_binding', 'Peer returned an invalid A2A response message');
623
+ record = this.options.store.update(record.id, { result: { message: Message.toJSON(message) }, executionState: 'completed', state: record.paymentState === 'confirmed' ? 'completed' : 'unknown' });
624
+ }
510
625
  else
511
626
  record = this.options.store.update(record.id, { state: 'unknown', errorCode: 'original_purchase_pending' });
512
627
  return this.snapshot(record);
@@ -529,6 +644,8 @@ export class CommerceBuyer {
529
644
  }
530
645
  async continue(caller, id, input) {
531
646
  const record = this.record(caller, id);
647
+ if (record.peerMode === 'standard-a2a')
648
+ throw new CommerceError('standard_continuation_unavailable', 'A standard peer has not promised included clarification; inspect its original Task without a new submission');
532
649
  if (typeof input.messageId !== 'string' || !input.messageId || input.messageId.length > 128 || input.messageId === record.messageId || !input.input || typeof input.input !== 'object' || Array.isArray(input.input))
533
650
  throw new CommerceError('invalid_continuation', 'A distinct stable message ID and full object input are required');
534
651
  const running = this.active.get(id);
@@ -621,6 +738,13 @@ export class CommerceBuyer {
621
738
  if (original.task?.id) {
622
739
  return this.observe(original);
623
740
  }
741
+ if (original.peerMode === 'standard-a2a') {
742
+ // A2A message IDs do not guarantee idempotent execution at an unrelated seller.
743
+ // Keep the exact original authorization, but never POST it again after an ambiguous outcome.
744
+ if (original.paymentState === 'confirmed' && original.executionState === 'completed' && original.result && typeof original.result === 'object' && 'message' in original.result)
745
+ return this.snapshot(this.options.store.update(id, { state: 'completed', errorCode: undefined }));
746
+ return this.snapshot(this.options.store.update(id, { state: 'unknown', executionState: 'unknown', errorCode: 'standard_task_recovery_required' }));
747
+ }
624
748
  return await this.replay(original);
625
749
  }
626
750
  catch (error) {
@@ -19,12 +19,14 @@ import { CredentialVault } from './vault.js';
19
19
  import { X402Gate } from './x402.js';
20
20
  import { evmReceiptVerifier, evmSettlementRecovery } from './chain.js';
21
21
  import { CommerceError } from './types.js';
22
+ import { inspectLedger } from './operations.js';
22
23
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
23
24
  config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
24
25
  version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
25
26
  port: { type: 'string' }, directory: { type: 'string' }, 'envar-config': { type: 'string' }, 'envar-credentials': { type: 'string' },
27
+ after: { type: 'string' }, limit: { type: 'string' },
26
28
  } });
27
- const usage = 'envarpay init --directory DIR | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
29
+ const usage = 'envarpay init --directory DIR | inspect --state DB [--after ROW --limit 100] | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
28
30
  const closers = [];
29
31
  function privateFile(path) {
30
32
  const stat = lstatSync(path);
@@ -84,6 +86,10 @@ async function run() {
84
86
  return;
85
87
  }
86
88
  const command = positionals[0];
89
+ if (command === 'inspect' && values.state) {
90
+ console.log(JSON.stringify(inspectLedger(values.state, Number(values.after ?? 0), Number(values.limit ?? 100)), null, 2));
91
+ return;
92
+ }
87
93
  if ((values['envar-config'] || values['envar-credentials']) && command !== 'serve')
88
94
  throw new CommerceError('envar_role', 'Configuration synchronization is available on seller serve only');
89
95
  if (Boolean(values['envar-config']) !== Boolean(values['envar-credentials']))
@@ -143,6 +143,24 @@ export function loadBuyerPolicy(value) {
143
143
  const ids = new Set();
144
144
  for (const peer of policy.peers) {
145
145
  validateUrl(peer.cardUrl);
146
+ if (peer.mode === 'standard-a2a') {
147
+ const endpoint = validateUrl(peer.endpoint), card = validateUrl(peer.cardUrl);
148
+ if (endpoint.origin !== card.origin || endpoint.search || endpoint.href !== peer.endpoint || card.href !== peer.cardUrl)
149
+ throw new CommerceError('standard_peer_endpoint', 'Review an exact same-origin HTTPS A2A endpoint without query parameters');
150
+ noExternalRefs(peer.localContract.inputSchema);
151
+ if (JSON.stringify(peer.localContract.inputSchema).length > 512 * 1024)
152
+ throw new CommerceError('standard_peer_schema', 'Local input schema exceeds 512 KiB');
153
+ try {
154
+ const inputAjv = new Ajv2020({ strict: true });
155
+ addFormats(inputAjv);
156
+ inputAjv.compile(peer.localContract.inputSchema);
157
+ }
158
+ catch {
159
+ throw new CommerceError('standard_peer_schema', 'Local input schema must be a valid supported JSON Schema');
160
+ }
161
+ if (atomic(peer.localContract.amount) > atomic(peer.maxPerPurchase))
162
+ throw new CommerceError('purchase_limit', 'Locally reviewed standard-peer price exceeds the per-purchase limit');
163
+ }
146
164
  if (ids.has(peer.id))
147
165
  throw new CommerceError('duplicate_peer', 'Peer IDs must be unique');
148
166
  ids.add(peer.id);
@@ -0,0 +1,16 @@
1
+ /** Local owner-only diagnostics: never opens a signer, vault, provider or network. */
2
+ export declare function inspectLedger(path: string, after?: number, limit?: number): {
3
+ role: string;
4
+ total: number;
5
+ attentionCount: number;
6
+ items: {
7
+ cursor: number;
8
+ id: string;
9
+ paymentState: string;
10
+ executionState: string;
11
+ taskId: string | null;
12
+ }[];
13
+ nextCursor: number;
14
+ hasMore: boolean;
15
+ paymentPerformed: boolean;
16
+ };
@@ -0,0 +1,33 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { lstatSync } from 'node:fs';
3
+ import { CommerceError } from './types.js';
4
+ /** Local owner-only diagnostics: never opens a signer, vault, provider or network. */
5
+ export function inspectLedger(path, after = 0, limit = 100) {
6
+ const stat = lstatSync(path);
7
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))
8
+ throw new CommerceError('private_file', 'Inspect an existing ledger owned only by the current user');
9
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
10
+ throw new CommerceError('inspection_cursor', 'Use a nonnegative row cursor and a limit from1 to100');
11
+ const db = new DatabaseSync(path, { readOnly: true });
12
+ try {
13
+ db.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=1000');
14
+ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name));
15
+ const role = names.has('buyer_purchases') ? 'buyer' : names.has('commerce_orders') ? 'seller' : null;
16
+ if (!role)
17
+ throw new CommerceError('store_version', 'Unrecognized commerce ledger');
18
+ const meta = role === 'buyer' ? 'buyer_meta' : 'commerce_meta', version = role === 'buyer' ? 1 : 2;
19
+ if (db.prepare(`SELECT version FROM ${meta}`).get()?.version !== version)
20
+ throw new CommerceError('store_version', 'Unsupported commerce ledger version');
21
+ const table = role === 'buyer' ? 'buyer_purchases' : 'commerce_orders';
22
+ const total = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
23
+ const filter = role === 'buyer' ? "json_extract(record_json,'$.state') IN ('signing','submitted','unknown') OR json_extract(record_json,'$.paymentState') IN ('reserved','unknown') OR json_extract(record_json,'$.executionState')='unknown'" : "payment_state IN ('settling','unknown') OR execution_state IN ('dispatching','unknown')";
24
+ const selected = role === 'buyer' ? "rowid AS cursor,id,json_extract(record_json,'$.paymentState') AS payment_state,json_extract(record_json,'$.executionState') AS execution_state,json_extract(record_json,'$.task.id') AS task_id" : "rowid AS cursor,id,payment_state,execution_state,task_id";
25
+ const rows = db.prepare(`SELECT ${selected} FROM ${table} WHERE rowid>? AND (${filter}) ORDER BY rowid LIMIT ?`).all(after, limit + 1);
26
+ const page = rows.slice(0, limit).map(row => ({ cursor: Number(row.cursor), id: String(row.id), paymentState: String(row.payment_state), executionState: String(row.execution_state), taskId: typeof row.task_id === 'string' ? row.task_id : null }));
27
+ const attentionCount = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${filter}`).get().n);
28
+ return { role, total, attentionCount, items: page, nextCursor: page.at(-1)?.cursor ?? after, hasMore: rows.length > limit, paymentPerformed: false };
29
+ }
30
+ finally {
31
+ db.close();
32
+ }
33
+ }
@@ -230,7 +230,7 @@ export class A2APeerProxy {
230
230
  let raw = snapshot?.task
231
231
  ? structuredClone(snapshot.task)
232
232
  : {};
233
- if (!code && ["failed", "unknown"].includes(String(row.phase))) {
233
+ if (!code && ["created", "attention", "failed", "unknown"].includes(String(row.phase))) {
234
234
  try {
235
235
  const previous = JSON.parse(String(row.task_json));
236
236
  if (typeof previous.metadata?.errorCode === "string")
@@ -251,9 +251,11 @@ export class A2APeerProxy {
251
251
  status: {
252
252
  state: needsApproval
253
253
  ? "TASK_STATE_AUTH_REQUIRED"
254
- : failed
255
- ? "TASK_STATE_FAILED"
256
- : "TASK_STATE_WORKING",
254
+ : phase === "attention"
255
+ ? "TASK_STATE_INPUT_REQUIRED"
256
+ : failed
257
+ ? "TASK_STATE_FAILED"
258
+ : "TASK_STATE_WORKING",
257
259
  },
258
260
  };
259
261
  }
@@ -458,9 +460,13 @@ export class A2APeerProxy {
458
460
  "peer_not_allowed",
459
461
  "invalid_input",
460
462
  "purchase_limit",
463
+ "paid_quote_required",
464
+ "offer_metadata_required",
465
+ "quote_mismatch",
466
+ "retired_revision",
461
467
  ].includes(error.code);
462
468
  this.phase(id, definitelyBlocked
463
- ? "failed"
469
+ ? state === "previewing" ? "attention" : "failed"
464
470
  : state === "confirming"
465
471
  ? "unknown"
466
472
  : state === "previewing"
@@ -36,6 +36,7 @@ export declare class CommerceServer {
36
36
  private running;
37
37
  private kickPending;
38
38
  private stopped;
39
+ private workerFailed;
39
40
  private waiters;
40
41
  private activeOrders;
41
42
  private recoveries;
@@ -171,6 +171,7 @@ export class CommerceServer {
171
171
  running = false;
172
172
  kickPending = false;
173
173
  stopped = false;
174
+ workerFailed = false;
174
175
  waiters = new Map();
175
176
  activeOrders = new Set();
176
177
  recoveries = new Map();
@@ -233,8 +234,19 @@ export class CommerceServer {
233
234
  const url = new URL(request.url);
234
235
  if (url.origin !== new URL(this.options.origin).origin)
235
236
  return Response.json({ error: 'invalid_host' }, { status: 421 });
236
- if (request.method === 'GET' && ['/healthz', '/readyz'].includes(url.pathname))
237
- return Response.json({ status: this.stopped ? 'draining' : 'ready' }, { status: this.stopped ? 503 : 200, headers: { 'Cache-Control': 'no-store' } });
237
+ if (request.method === 'GET' && ['/healthz', '/readyz'].includes(url.pathname)) {
238
+ let ready = !this.stopped;
239
+ if (url.pathname === '/readyz') {
240
+ ready = ready && !this.workerFailed;
241
+ try {
242
+ this.store.assertReady();
243
+ }
244
+ catch {
245
+ ready = false;
246
+ }
247
+ }
248
+ return Response.json({ status: ready ? 'ready' : this.stopped ? 'draining' : 'unavailable' }, { status: ready ? 200 : 503, headers: { 'Cache-Control': 'no-store' } });
249
+ }
238
250
  const proof = /^\/\.well-known\/envar\/([0-9a-f-]{36})$/.exec(url.pathname);
239
251
  if (request.method === 'GET' && proof) {
240
252
  const challenge = this.options.ownershipChallenges?.[proof[1]];
@@ -279,6 +291,8 @@ export class CommerceServer {
279
291
  let requestId = null;
280
292
  try {
281
293
  const raw = JSON.parse(body);
294
+ if (raw.method === 'SendMessage' && this.workerFailed)
295
+ return Response.json({ error: 'worker_unavailable' }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
282
296
  if (!raw || Array.isArray(raw) || typeof raw !== 'object')
283
297
  throw new RequestMalformedError('A single JSONRPC object is required');
284
298
  requestId = raw.id ?? null;
@@ -393,14 +407,14 @@ export class CommerceServer {
393
407
  return projected;
394
408
  }
395
409
  schedule() {
396
- if (this.stopped)
410
+ if (this.stopped || this.workerFailed)
397
411
  return;
398
412
  if (this.running) {
399
413
  this.kickPending = true;
400
414
  return;
401
415
  }
402
416
  this.running = true;
403
- void this.drain().catch(() => this.options.onError?.('worker_failure')).finally(() => { this.running = false; if (this.kickPending) {
417
+ void this.drain().catch(() => { this.workerFailed = true; this.options.onError?.('worker_failure'); }).finally(() => { this.running = false; if (this.kickPending) {
404
418
  this.kickPending = false;
405
419
  this.schedule();
406
420
  } });
@@ -36,6 +36,8 @@ export declare class CommerceStore implements TaskStore {
36
36
  constructor(path: string);
37
37
  private migrateV1;
38
38
  close(): void;
39
+ /** Local readiness only. External settlement is independently verified per purchase. */
40
+ assertReady(): void;
39
41
  registerCatalog(config: CommerceConfig): void;
40
42
  serviceRevision(id: string): Service | undefined;
41
43
  catalogHistory(): {
@@ -134,6 +134,13 @@ export class CommerceStore {
134
134
  this.ownerDb.close();
135
135
  }
136
136
  }
137
+ /** Local readiness only. External settlement is independently verified per purchase. */
138
+ assertReady() {
139
+ const row = this.db.prepare('SELECT version FROM commerce_meta').get();
140
+ if (row?.version !== 2)
141
+ throw new CommerceError('store_unavailable', 'Commerce ledger is unavailable');
142
+ this.db.prepare('SELECT id FROM commerce_orders LIMIT 1').get();
143
+ }
137
144
  registerCatalog(config) {
138
145
  this.transaction(() => {
139
146
  for (const service of config.services) {
@@ -65,13 +65,33 @@ export interface CommerceConfig {
65
65
  paymentProfiles: Record<string, PaymentProfile>;
66
66
  services: Service[];
67
67
  }
68
+ /** A separately reviewed transport profile; local terms are never advertised as seller promises. */
69
+ export interface StandardA2APeer {
70
+ id: string;
71
+ cardUrl: string;
72
+ mode: 'standard-a2a';
73
+ protocol: 'x402';
74
+ currency: string;
75
+ recipient: string;
76
+ maxPerPurchase: string;
77
+ endpoint: string;
78
+ authentication: 'none' | 'bearer';
79
+ localContract: {
80
+ revision: number;
81
+ offerId: string;
82
+ inputSchema: Record<string, unknown>;
83
+ amount: string;
84
+ paidOnly: true;
85
+ };
86
+ }
68
87
  export interface BuyerPolicy {
69
88
  policyVersion: 1;
70
89
  paymentsEnabled: boolean;
71
90
  approval: 'per_purchase' | 'within_preapproved_limits';
72
- peers: ({
91
+ peers: (StandardA2APeer | {
73
92
  id: string;
74
93
  cardUrl: string;
94
+ mode?: 'envarpay';
75
95
  protocol: 'x402' | 'mpp';
76
96
  currency: string;
77
97
  recipient: string;
@@ -79,6 +99,7 @@ export interface BuyerPolicy {
79
99
  } | {
80
100
  id: string;
81
101
  cardUrl: string;
102
+ mode?: 'envarpay';
82
103
  protocol: 'free';
83
104
  currency: null;
84
105
  recipient: null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envarai/envarpay",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.2",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,6 +70,54 @@
70
70
  "maxPerPurchase": {
71
71
  "type": "string",
72
72
  "pattern": "^(0|[1-9][0-9]{0,77})$"
73
+ },
74
+ "mode": {
75
+ "enum": [
76
+ "envarpay",
77
+ "standard-a2a"
78
+ ]
79
+ },
80
+ "endpoint": {
81
+ "type": "string",
82
+ "format": "uri",
83
+ "pattern": "^https://"
84
+ },
85
+ "authentication": {
86
+ "enum": [
87
+ "none",
88
+ "bearer"
89
+ ]
90
+ },
91
+ "localContract": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "required": [
95
+ "revision",
96
+ "offerId",
97
+ "inputSchema",
98
+ "amount",
99
+ "paidOnly"
100
+ ],
101
+ "properties": {
102
+ "revision": {
103
+ "type": "integer",
104
+ "minimum": 1,
105
+ "maximum": 9007199254740991
106
+ },
107
+ "offerId": {
108
+ "type": "string",
109
+ "pattern": "^[a-z][a-z0-9-]{0,63}$"
110
+ },
111
+ "inputSchema": {
112
+ "type": "object"
113
+ },
114
+ "amount": {
115
+ "$ref": "#/$defs/amount"
116
+ },
117
+ "paidOnly": {
118
+ "const": true
119
+ }
120
+ }
73
121
  }
74
122
  },
75
123
  "allOf": [
@@ -109,6 +157,63 @@
109
157
  }
110
158
  }
111
159
  }
160
+ },
161
+ {
162
+ "if": {
163
+ "required": [
164
+ "mode"
165
+ ],
166
+ "properties": {
167
+ "mode": {
168
+ "const": "standard-a2a"
169
+ }
170
+ }
171
+ },
172
+ "then": {
173
+ "required": [
174
+ "endpoint",
175
+ "authentication",
176
+ "localContract"
177
+ ],
178
+ "properties": {
179
+ "protocol": {
180
+ "const": "x402"
181
+ },
182
+ "endpoint": {},
183
+ "authentication": {},
184
+ "localContract": {}
185
+ }
186
+ },
187
+ "else": {
188
+ "not": {
189
+ "anyOf": [
190
+ {
191
+ "required": [
192
+ "endpoint"
193
+ ],
194
+ "properties": {
195
+ "endpoint": {}
196
+ }
197
+ },
198
+ {
199
+ "required": [
200
+ "authentication"
201
+ ],
202
+ "properties": {
203
+ "authentication": {}
204
+ }
205
+ },
206
+ {
207
+ "required": [
208
+ "localContract"
209
+ ],
210
+ "properties": {
211
+ "localContract": {}
212
+ }
213
+ }
214
+ ]
215
+ }
216
+ }
112
217
  }
113
218
  ]
114
219
  }