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

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.
Files changed (55) hide show
  1. package/README.md +2 -0
  2. package/dist/commerce/alerts.d.ts +37 -0
  3. package/dist/commerce/alerts.js +127 -0
  4. package/dist/commerce/buyer-store.d.ts +8 -0
  5. package/dist/commerce/buyer.d.ts +12 -3
  6. package/dist/commerce/buyer.js +151 -7
  7. package/dist/commerce/card.js +2 -2
  8. package/dist/commerce/cli.js +74 -9
  9. package/dist/commerce/client-runtime.d.ts +1 -1
  10. package/dist/commerce/config.d.ts +5 -1
  11. package/dist/commerce/config.js +37 -1
  12. package/dist/commerce/configure.d.ts +3 -0
  13. package/dist/commerce/configure.js +158 -0
  14. package/dist/commerce/discovery.d.ts +23 -0
  15. package/dist/commerce/discovery.js +162 -0
  16. package/dist/commerce/envar-runtime.d.ts +3 -1
  17. package/dist/commerce/envar-runtime.js +28 -1
  18. package/dist/commerce/envar.d.ts +5 -0
  19. package/dist/commerce/envar.js +13 -0
  20. package/dist/commerce/index.d.ts +1 -0
  21. package/dist/commerce/index.js +1 -0
  22. package/dist/commerce/management.js +3 -1
  23. package/dist/commerce/mpp-client.d.ts +11 -0
  24. package/dist/commerce/mpp-client.js +5 -0
  25. package/dist/commerce/mpp-runtime.d.ts +1 -0
  26. package/dist/commerce/mpp-runtime.js +1 -0
  27. package/dist/commerce/native-skills.d.ts +31 -0
  28. package/dist/commerce/native-skills.js +174 -0
  29. package/dist/commerce/operations.d.ts +22 -0
  30. package/dist/commerce/operations.js +39 -0
  31. package/dist/commerce/peer-proxy.js +11 -5
  32. package/dist/commerce/pricing.js +2 -2
  33. package/dist/commerce/runtime.d.ts +2 -0
  34. package/dist/commerce/runtime.js +2 -0
  35. package/dist/commerce/server.d.ts +4 -1
  36. package/dist/commerce/server.js +40 -9
  37. package/dist/commerce/setup.d.ts +18 -0
  38. package/dist/commerce/setup.js +344 -0
  39. package/dist/commerce/skill-gate.d.ts +54 -0
  40. package/dist/commerce/skill-gate.js +153 -0
  41. package/dist/commerce/store.d.ts +2 -0
  42. package/dist/commerce/store.js +7 -0
  43. package/dist/commerce/stripe-issuer.d.ts +41 -0
  44. package/dist/commerce/stripe-issuer.js +351 -0
  45. package/dist/commerce/stripe-provider.d.ts +2 -0
  46. package/dist/commerce/stripe-provider.js +57 -22
  47. package/dist/commerce/types.d.ts +27 -2
  48. package/dist/commerce/x402.d.ts +1 -0
  49. package/dist/commerce/x402.js +8 -1
  50. package/examples/hermes-setup-probe.py +105 -0
  51. package/examples/hermes-skill-inventory.py +107 -0
  52. package/examples/native-skill-runner.py +197 -0
  53. package/package.json +1 -1
  54. package/schemas/buyer-policy.schema.json +105 -0
  55. package/schemas/envarpay.config.schema.json +22 -15
package/README.md CHANGED
@@ -11,6 +11,8 @@ not evidence that its npm/OCI artifact is published.
11
11
 
12
12
  ## Runtime and entry points
13
13
 
14
+ For Hermes onboarding, see [guided Skill setup](../../docs/hermes-skill-setup.md). The `setup --file ...` command reads the owner's local model configuration and creates a private seller with an empty catalog, ready for the first service to be configured in Envar.
15
+
14
16
  Node.js 22.14+ (Node 24 recommended). SQLite is single-instance. Contracts can be
15
17
  read without loading the seller or buyer runtime; Bun validation covers the
16
18
  contracts entry, not the SQLite server.
@@ -0,0 +1,37 @@
1
+ type SignalName = 'ledger_read' | 'payment_unknown' | 'execution_unknown' | 'recorded_provider_failure' | 'budget_near_limit' | 'budget_exhausted' | 'report_backlog' | 'report_failure';
2
+ type Signal = {
3
+ name: SignalName;
4
+ status: 'clear' | 'alert' | 'unavailable' | 'not_applicable';
5
+ severity: 'info' | 'warning' | 'critical';
6
+ count: number | null;
7
+ };
8
+ type Budget = {
9
+ unit: string;
10
+ maxTotal: string;
11
+ reserved: string;
12
+ spent: string;
13
+ remaining: string;
14
+ state: 'available' | 'near_limit' | 'exhausted';
15
+ };
16
+ export interface LocalAlertOptions {
17
+ policyPath?: string;
18
+ integrationPath?: string;
19
+ integrationDisabled?: boolean;
20
+ }
21
+ /** Owner-local aggregate evidence only. Never opens a runtime store, signer, vault or network. */
22
+ export declare function inspectLocalAlerts(path: string, options?: LocalAlertOptions): {
23
+ role: "unavailable" | "buyer" | "seller";
24
+ view: "alerts";
25
+ observation: "local_persisted_state_only";
26
+ status: string;
27
+ signals: Signal[];
28
+ budgets: Budget[];
29
+ alertCount: number;
30
+ unavailableCount: number;
31
+ providerHealth: "unavailable_not_probed";
32
+ recommendedExitCode: number;
33
+ paymentPerformed: boolean;
34
+ refundPerformed: boolean;
35
+ networkRequests: number;
36
+ };
37
+ export {};
@@ -0,0 +1,127 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { lstatSync, readFileSync } from 'node:fs';
3
+ import { loadBuyerPolicy } from './config.js';
4
+ const CURRENCIES = {
5
+ usd: 'usd_cents',
6
+ 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 'base_usdc_atomic',
7
+ 'eip155:84532/erc20:0x036cbd53842c5426634e7929541ec2318f3dcf7e': 'base_sepolia_usdc_atomic',
8
+ };
9
+ function privateFile(path, limit) {
10
+ const stat = lstatSync(path);
11
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()) || (limit !== undefined && stat.size > limit))
12
+ throw new Error('private_source_unavailable');
13
+ }
14
+ function database(path) {
15
+ privateFile(path);
16
+ const db = new DatabaseSync(path, { readOnly: true });
17
+ try {
18
+ db.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=1000; BEGIN');
19
+ return db;
20
+ }
21
+ catch (error) {
22
+ db.close();
23
+ throw error;
24
+ }
25
+ }
26
+ const count = (db, sql) => Number(db.prepare(sql).get().n);
27
+ const signal = (name, value, severity = 'warning') => ({ name, status: value ? 'alert' : 'clear', severity: value ? severity : 'info', count: value });
28
+ const missing = (name) => ({ name, status: 'unavailable', severity: 'warning', count: null });
29
+ const irrelevant = (name) => ({ name, status: 'not_applicable', severity: 'info', count: null });
30
+ /** Owner-local aggregate evidence only. Never opens a runtime store, signer, vault or network. */
31
+ export function inspectLocalAlerts(path, options = {}) {
32
+ const signals = [], budgets = [];
33
+ let role = 'unavailable', db;
34
+ try {
35
+ db = database(path);
36
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name));
37
+ if (tables.has('buyer_purchases') === tables.has('commerce_orders'))
38
+ throw new Error('unknown_ledger');
39
+ role = tables.has('buyer_purchases') ? 'buyer' : 'seller';
40
+ if (db.prepare(`SELECT version FROM ${role === 'buyer' ? 'buyer_meta' : 'commerce_meta'}`).get()?.version !== (role === 'buyer' ? 1 : 2))
41
+ throw new Error('unknown_version');
42
+ const unknownPayments = count(db, role === 'buyer' ? "SELECT COUNT(*) n FROM buyer_purchases WHERE json_extract(record_json,'$.paymentState')='unknown'" : "SELECT COUNT(*) n FROM commerce_orders WHERE payment_state='unknown'");
43
+ const unknownExecution = count(db, role === 'buyer' ? "SELECT COUNT(*) n FROM buyer_purchases WHERE json_extract(record_json,'$.executionState')='unknown'" : "SELECT COUNT(*) n FROM commerce_orders WHERE execution_state='unknown'");
44
+ // Only concrete persisted provider errors count. Generic unknown is not proof of provider failure.
45
+ const providerFailures = count(db, role === 'buyer'
46
+ ? "SELECT COUNT(*) n FROM buyer_purchases WHERE json_extract(record_json,'$.paymentState')!='confirmed' AND json_extract(record_json,'$.errorCode') IN ('stripe_transport_unknown','stripe_request_failed','stripe_rate_limit','stripe_response','stripe_response_size')"
47
+ : "SELECT COUNT(*) n FROM commerce_payment_attempts WHERE state!='confirmed' AND json_extract(receipt_json,'$.success')=0 AND length(json_extract(receipt_json,'$.errorReason'))>0");
48
+ signals.push(signal('ledger_read', 0), signal('payment_unknown', unknownPayments), signal('execution_unknown', unknownExecution), signal('recorded_provider_failure', providerFailures));
49
+ if (role === 'seller')
50
+ signals.push(irrelevant('budget_near_limit'), irrelevant('budget_exhausted'));
51
+ else {
52
+ try {
53
+ if (!options.policyPath)
54
+ throw new Error('policy_required');
55
+ privateFile(options.policyPath, 1024 * 1024);
56
+ const policy = loadBuyerPolicy(JSON.parse(readFileSync(options.policyPath, 'utf8')));
57
+ const usage = new Map();
58
+ for (const b of policy.budgets)
59
+ usage.set(b.currency, { reserved: 0n, spent: 0n });
60
+ for (const row of db.prepare("SELECT currency,amount,budget_state FROM buyer_purchases WHERE budget_state!='none'").iterate()) {
61
+ const currency = String(row.currency), amount = String(row.amount), state = row.budget_state;
62
+ const target = usage.get(currency);
63
+ if (!target || !/^[1-9][0-9]{0,77}$/.test(amount) || !['spent', 'reserved'].includes(String(state)))
64
+ throw new Error('budget_binding');
65
+ target[state === 'spent' ? 'spent' : 'reserved'] += BigInt(amount);
66
+ }
67
+ const checked = policy.budgets.map(b => {
68
+ const u = usage.get(b.currency), cap = BigInt(b.maxTotal), used = u.reserved + u.spent;
69
+ const state = used >= cap ? 'exhausted' : used * 10n >= cap * 9n ? 'near_limit' : 'available';
70
+ return { unit: CURRENCIES[b.currency], maxTotal: cap.toString(), reserved: u.reserved.toString(), spent: u.spent.toString(), remaining: (used < cap ? cap - used : 0n).toString(), state };
71
+ });
72
+ budgets.push(...checked);
73
+ signals.push(signal('budget_near_limit', checked.filter(b => b.state === 'near_limit').length), signal('budget_exhausted', checked.filter(b => b.state === 'exhausted').length, 'critical'));
74
+ }
75
+ catch {
76
+ signals.push(missing('budget_near_limit'), missing('budget_exhausted'));
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ role = 'unavailable';
82
+ signals.length = 0;
83
+ budgets.length = 0;
84
+ signals.push(...['ledger_read', 'payment_unknown', 'execution_unknown', 'recorded_provider_failure', 'budget_near_limit', 'budget_exhausted'].map(missing));
85
+ }
86
+ finally {
87
+ db?.close();
88
+ }
89
+ let integration;
90
+ try {
91
+ if (options.integrationDisabled && !options.integrationPath) {
92
+ signals.push(irrelevant('report_backlog'), irrelevant('report_failure'));
93
+ }
94
+ else {
95
+ if (options.integrationDisabled)
96
+ throw new Error('ambiguous_integration');
97
+ if (!options.integrationPath)
98
+ throw new Error('integration_not_supplied');
99
+ integration = database(options.integrationPath);
100
+ const binding = integration.prepare("SELECT value FROM meta WHERE key='binding'").get();
101
+ const source = integration.prepare("SELECT value FROM meta WHERE key='source_instance'").get();
102
+ if (!binding || !source || typeof binding.value !== 'string' || typeof source.value !== 'string')
103
+ throw new Error('integration_schema');
104
+ const parsed = JSON.parse(binding.value);
105
+ if (!parsed || typeof parsed.platformOrigin !== 'string' || typeof parsed.agentId !== 'string' || typeof parsed.runtimeAgentId !== 'string')
106
+ throw new Error('integration_schema');
107
+ const pending = count(integration, "SELECT COUNT(*) n FROM outbox WHERE state='pending'");
108
+ const failed = count(integration, "SELECT COUNT(*) n FROM outbox WHERE state='pending' AND attempts>0 AND last_error<>''");
109
+ signals.push(signal('report_backlog', pending), signal('report_failure', failed));
110
+ }
111
+ }
112
+ catch {
113
+ signals.push(missing('report_backlog'), missing('report_failure'));
114
+ }
115
+ finally {
116
+ integration?.close();
117
+ }
118
+ const alertCount = signals.filter(s => s.status === 'alert').length, unavailableCount = signals.filter(s => s.status === 'unavailable').length;
119
+ return {
120
+ role, view: 'alerts', observation: 'local_persisted_state_only',
121
+ status: role === 'unavailable' ? 'unavailable' : unavailableCount ? 'partial' : 'available',
122
+ signals, budgets, alertCount, unavailableCount,
123
+ providerHealth: 'unavailable_not_probed',
124
+ recommendedExitCode: unavailableCount ? 3 : alertCount ? 2 : 0,
125
+ paymentPerformed: false, refundPerformed: false, networkRequests: 0,
126
+ };
127
+ }
@@ -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,9 +1,9 @@
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
- import { type MppBuyerOptions, type MppReceipt } from './mpp-client.js';
6
+ import { type MppAuthenticationAction, type MppBuyerOptions, type MppReceipt } from './mpp-client.js';
7
7
  export interface BuyerPreviewInput {
8
8
  cardUrl: string;
9
9
  messageId: string;
@@ -15,6 +15,10 @@ export interface BuyerConfirmInput {
15
15
  quoteToken: string;
16
16
  messageId: string;
17
17
  }
18
+ export interface BuyerAuthentication {
19
+ purchaseId: string;
20
+ action: MppAuthenticationAction | null;
21
+ }
18
22
  export interface CommerceBuyerOptions {
19
23
  policy: BuyerPolicy;
20
24
  store: BuyerStore;
@@ -78,9 +82,10 @@ export declare class CommerceBuyer {
78
82
  policyVersion: 1;
79
83
  paymentsEnabled: boolean;
80
84
  approval: "per_purchase" | "within_preapproved_limits";
81
- peers: ({
85
+ peers: (StandardA2APeer | {
82
86
  id: string;
83
87
  cardUrl: string;
88
+ mode?: "envarpay";
84
89
  protocol: "x402" | "mpp";
85
90
  currency: string;
86
91
  recipient: string;
@@ -88,6 +93,7 @@ export declare class CommerceBuyer {
88
93
  } | {
89
94
  id: string;
90
95
  cardUrl: string;
96
+ mode?: "envarpay";
91
97
  protocol: "free";
92
98
  currency: null;
93
99
  recipient: null;
@@ -102,6 +108,7 @@ export declare class CommerceBuyer {
102
108
  get(caller: string, id: string): BuyerSnapshot;
103
109
  preview(caller: string, input: BuyerPreviewInput): Promise<BuyerSnapshot>;
104
110
  private prepare;
111
+ private prepareStandard;
105
112
  private prepareFree;
106
113
  private replayFree;
107
114
  private prepareMpp;
@@ -111,6 +118,8 @@ export declare class CommerceBuyer {
111
118
  private verifyMpp;
112
119
  private replayMpp;
113
120
  private recoverMpp;
121
+ /** Owner-only provider read. No replay, token creation, reservation or payment transition. */
122
+ authentication(caller: string, id: string): Promise<BuyerAuthentication>;
114
123
  private assertCurrentPolicy;
115
124
  confirm(caller: string, input: BuyerConfirmInput): Promise<BuyerSnapshot>;
116
125
  private exclusive;
@@ -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';
@@ -10,7 +10,7 @@ import addFormatsImport from 'ajv-formats';
10
10
  import { atomic, digest, loadBuyerPolicy, validateUrl } from './config.js';
11
11
  import { CommerceError } from './types.js';
12
12
  import { Challenge, Receipt, PaymentRequest } from 'mppx';
13
- import { assertMppDigest, prepareMppCredential, publicMppReceipt, validateMppCredential } from './mpp-client.js';
13
+ import { assertMppDigest, isStripeTestPaymentMethod, prepareMppCredential, publicMppReceipt, validateMppCredential } from './mpp-client.js';
14
14
  const COMMERCE = 'urn:envarpay:commerce:1', QUOTE = 'urn:envarpay:quote:1';
15
15
  const addFormats = addFormatsImport;
16
16
  async function bounded(response, limit = 4 * 1024 * 1024) {
@@ -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;
@@ -83,11 +108,11 @@ export class CommerceBuyer {
83
108
  this.fetchImpl = options.fetchImpl ?? fetch;
84
109
  if (this.policy.peers.some(p => p.protocol === 'x402') && (!options.signer || !/^0x[0-9a-fA-F]{40}$/.test(options.signer.address) || BigInt(options.signer.address) === 0n || !options.verifyReceipt))
85
110
  throw new CommerceError('invalid_signer', 'x402 peers require an EVM wallet and independent receipt verifier');
86
- 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'))
111
+ if (this.policy.peers.some(p => p.protocol === 'mpp') && (!options.mpp || !options.mpp.payer || !(/^pm_[A-Za-z0-9]+$/.test(options.mpp.paymentMethod) || (options.mpp.mode === 'test' && options.mpp.issuance === 'test-helper' && isStripeTestPaymentMethod(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');
@@ -391,10 +485,41 @@ export class CommerceBuyer {
391
485
  return this.snapshot(this.options.store.update(record.id, { state: 'unknown', errorCode: error instanceof CommerceError ? error.code : 'mpp_reconciliation_pending' }));
392
486
  }
393
487
  }
488
+ /** Owner-only provider read. No replay, token creation, reservation or payment transition. */
489
+ async authentication(caller, id) {
490
+ const record = this.record(caller, id);
491
+ if (record.protocol !== 'mpp')
492
+ throw new CommerceError('mpp_authentication_unavailable', 'Original purchase does not use MPP');
493
+ this.assertCurrentPolicy(record, false);
494
+ if (!record.tokenOperationId || record.paymentState === 'confirmed')
495
+ return { purchaseId: id, action: null };
496
+ const provider = this.options.mpp;
497
+ if (!provider?.getAuthentication)
498
+ throw new CommerceError('mpp_authentication_unavailable', 'Configured provider does not support customer authentication');
499
+ const action = await provider.getAuthentication({ purchaseId: record.id, quote: structuredClone(record.quote), mode: record.mppMode, operationId: record.tokenOperationId });
500
+ if (!action)
501
+ return { purchaseId: id, action: null };
502
+ if (action.type !== 'use_stripe_sdk' || typeof action.hashedValue !== 'string' || !action.hashedValue || action.hashedValue.length > 16384 ||
503
+ typeof action.publishableKey !== 'string' || !new RegExp(`^pk_${record.mppMode}_[A-Za-z0-9]{1,256}$`).test(action.publishableKey)) {
504
+ throw new CommerceError('stripe_spt_action_unsupported', 'Provider returned an unsupported or mismatched customer action');
505
+ }
506
+ return { purchaseId: id, action: { type: 'use_stripe_sdk', hashedValue: action.hashedValue, publishableKey: action.publishableKey } };
507
+ }
394
508
  assertCurrentPolicy(record, signing) {
395
509
  const peer = this.peer(record.cardUrl);
396
510
  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
511
  throw new CommerceError('policy_changed', 'Original purchase no longer matches local wallet policy');
512
+ if ((record.peerMode === 'standard-a2a') !== (peer.mode === 'standard-a2a'))
513
+ throw new CommerceError('policy_changed', 'Original peer protocol profile changed');
514
+ if (record.peerMode === 'standard-a2a') {
515
+ if (peer.mode !== 'standard-a2a' || !record.standardBinding || record.standardBinding.policyDigest !== standardPolicyDigest(peer))
516
+ throw new CommerceError('policy_changed', 'Original local standard-peer contract or authentication changed');
517
+ const quote = record.quote, contract = peer.localContract;
518
+ 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))
519
+ throw new CommerceError('recovery_conflict', 'Local review quote no longer matches its frozen standard-peer contract and input');
520
+ if (record.standardBinding.bodyDigest !== digest(record.body) || record.standardBinding.challengeDigest !== digest(record.required) || digest(standardRequirements(peer, record.required)) !== digest(record.requirements))
521
+ throw new CommerceError('recovery_conflict', 'Original standard request or native challenge changed');
522
+ }
398
523
  if (signing && peer.protocol !== 'free' && !this.policy.paymentsEnabled)
399
524
  throw new CommerceError('payments_disabled', 'Enable payments in local wallet policy before confirming');
400
525
  return peer;
@@ -470,6 +595,8 @@ export class CommerceBuyer {
470
595
  async verify(record, saved, receipt) {
471
596
  if (record.paymentState === 'confirmed')
472
597
  return record;
598
+ if (record.peerMode === 'standard-a2a' && receipt && (receipt.network !== saved.requirements.network || (receipt.payer && receipt.payer.toLowerCase() !== record.quote.payer)))
599
+ throw new CommerceError('receipt_binding', 'Receipt differs from the original standard-peer chain or payer');
473
600
  if (receipt)
474
601
  record = this.options.store.update(record.id, { receipt });
475
602
  if (!receipt?.transaction && record.fromBlock && this.options.findOriginalReceipt)
@@ -505,8 +632,16 @@ export class CommerceBuyer {
505
632
  }
506
633
  catch { /* retain the original receipt and reservation for reconciliation */ }
507
634
  const raw = await response.json();
635
+ if (record.peerMode === 'standard-a2a' && (raw.jsonrpc !== '2.0' || raw.id !== JSON.parse(record.body).id))
636
+ throw new CommerceError('rpc_response_binding', 'Peer response does not match the original standard JSON-RPC request');
508
637
  if (response.ok && raw.result?.task)
509
638
  record = this.task(record, raw.result.task);
639
+ else if (record.peerMode === 'standard-a2a' && response.ok && raw.result?.message) {
640
+ const message = Message.fromJSON(raw.result.message);
641
+ if (!message.messageId || message.role !== Role.ROLE_AGENT || !message.parts.length)
642
+ throw new CommerceError('message_binding', 'Peer returned an invalid A2A response message');
643
+ record = this.options.store.update(record.id, { result: { message: Message.toJSON(message) }, executionState: 'completed', state: record.paymentState === 'confirmed' ? 'completed' : 'unknown' });
644
+ }
510
645
  else
511
646
  record = this.options.store.update(record.id, { state: 'unknown', errorCode: 'original_purchase_pending' });
512
647
  return this.snapshot(record);
@@ -529,6 +664,8 @@ export class CommerceBuyer {
529
664
  }
530
665
  async continue(caller, id, input) {
531
666
  const record = this.record(caller, id);
667
+ if (record.peerMode === 'standard-a2a')
668
+ throw new CommerceError('standard_continuation_unavailable', 'A standard peer has not promised included clarification; inspect its original Task without a new submission');
532
669
  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
670
  throw new CommerceError('invalid_continuation', 'A distinct stable message ID and full object input are required');
534
671
  const running = this.active.get(id);
@@ -621,6 +758,13 @@ export class CommerceBuyer {
621
758
  if (original.task?.id) {
622
759
  return this.observe(original);
623
760
  }
761
+ if (original.peerMode === 'standard-a2a') {
762
+ // A2A message IDs do not guarantee idempotent execution at an unrelated seller.
763
+ // Keep the exact original authorization, but never POST it again after an ambiguous outcome.
764
+ if (original.paymentState === 'confirmed' && original.executionState === 'completed' && original.result && typeof original.result === 'object' && 'message' in original.result)
765
+ return this.snapshot(this.options.store.update(id, { state: 'completed', errorCode: undefined }));
766
+ return this.snapshot(this.options.store.update(id, { state: 'unknown', executionState: 'unknown', errorCode: 'standard_task_recovery_required' }));
767
+ }
624
768
  return await this.replay(original);
625
769
  }
626
770
  catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { AgentCard } from '@a2a-js/sdk';
2
2
  import { CommerceError } from './types.js';
3
- import { validateUrl, digest } from './config.js';
3
+ import { validateUrl, serviceTermsDigest } from './config.js';
4
4
  /** One deterministic offer route, serialized by the official A2A 1.0 codec. */
5
5
  export function offerPath(serviceId, revision, offerId) {
6
6
  return `/services/${encodeURIComponent(serviceId)}/v${revision}/offers/${encodeURIComponent(offerId)}`;
@@ -20,7 +20,7 @@ export function createOfferCard(config, serviceId, offerId, origin) {
20
20
  description: `Deliverables: ${service.contract.deliverables.join(', ')}`,
21
21
  version: String(service.revision),
22
22
  supportedInterfaces: [{ url: new URL(`${offerPath(service.id, service.revision, offer.id)}/a2a`, base).href, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }],
23
- capabilities: { streaming: false, pushNotifications: false, extensions: [{ uri: 'urn:envarpay:commerce:1', required: false, description: 'Optional service offer metadata; standard A2A and payment transports remain independently usable', params: { serviceId: service.id, serviceRevision: service.revision, offerId: offer.id, pricing: offer.pricing, collection: offer.collection, contract: service.contract, payment, termsDigest: digest({ contract: service.contract, offer, profile }) } }] },
23
+ capabilities: { streaming: false, pushNotifications: false, extensions: [{ uri: 'urn:envarpay:commerce:1', required: false, description: 'Optional service offer metadata; standard A2A and payment transports remain independently usable', params: { serviceId: service.id, serviceRevision: service.revision, offerId: offer.id, pricing: offer.pricing, collection: offer.collection, contract: service.contract, payment, termsDigest: serviceTermsDigest(config.configVersion, service, offer, profile), ...(config.configVersion === 2 && service.execution.type === 'skill' ? { skillDigest: service.execution.skillDigest } : {}) } }] },
24
24
  defaultInputModes: ['application/json'], defaultOutputModes: ['application/json', 'text/plain'],
25
25
  skills: [{ id: service.id, name: service.name, description: `Purchase ${service.name}`, tags: [service.id] }],
26
26
  securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: 'bearer' } } },