@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.
- package/README.md +2 -0
- package/dist/commerce/alerts.d.ts +37 -0
- package/dist/commerce/alerts.js +127 -0
- package/dist/commerce/buyer-store.d.ts +8 -0
- package/dist/commerce/buyer.d.ts +12 -3
- package/dist/commerce/buyer.js +151 -7
- package/dist/commerce/card.js +2 -2
- package/dist/commerce/cli.js +74 -9
- package/dist/commerce/client-runtime.d.ts +1 -1
- package/dist/commerce/config.d.ts +5 -1
- package/dist/commerce/config.js +37 -1
- package/dist/commerce/configure.d.ts +3 -0
- package/dist/commerce/configure.js +158 -0
- package/dist/commerce/discovery.d.ts +23 -0
- package/dist/commerce/discovery.js +162 -0
- package/dist/commerce/envar-runtime.d.ts +3 -1
- package/dist/commerce/envar-runtime.js +28 -1
- package/dist/commerce/envar.d.ts +5 -0
- package/dist/commerce/envar.js +13 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/commerce/management.js +3 -1
- package/dist/commerce/mpp-client.d.ts +11 -0
- package/dist/commerce/mpp-client.js +5 -0
- package/dist/commerce/mpp-runtime.d.ts +1 -0
- package/dist/commerce/mpp-runtime.js +1 -0
- package/dist/commerce/native-skills.d.ts +31 -0
- package/dist/commerce/native-skills.js +174 -0
- package/dist/commerce/operations.d.ts +22 -0
- package/dist/commerce/operations.js +39 -0
- package/dist/commerce/peer-proxy.js +11 -5
- package/dist/commerce/pricing.js +2 -2
- package/dist/commerce/runtime.d.ts +2 -0
- package/dist/commerce/runtime.js +2 -0
- package/dist/commerce/server.d.ts +4 -1
- package/dist/commerce/server.js +40 -9
- package/dist/commerce/setup.d.ts +18 -0
- package/dist/commerce/setup.js +344 -0
- package/dist/commerce/skill-gate.d.ts +54 -0
- package/dist/commerce/skill-gate.js +153 -0
- package/dist/commerce/store.d.ts +2 -0
- package/dist/commerce/store.js +7 -0
- package/dist/commerce/stripe-issuer.d.ts +41 -0
- package/dist/commerce/stripe-issuer.js +351 -0
- package/dist/commerce/stripe-provider.d.ts +2 -0
- package/dist/commerce/stripe-provider.js +57 -22
- package/dist/commerce/types.d.ts +27 -2
- package/dist/commerce/x402.d.ts +1 -0
- package/dist/commerce/x402.js +8 -1
- package/examples/hermes-setup-probe.py +105 -0
- package/examples/hermes-skill-inventory.py +107 -0
- package/examples/native-skill-runner.py +197 -0
- package/package.json +1 -1
- package/schemas/buyer-policy.schema.json +105 -0
- 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;
|
package/dist/commerce/buyer.d.ts
CHANGED
|
@@ -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;
|
package/dist/commerce/buyer.js
CHANGED
|
@@ -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 ||
|
|
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
|
|
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
|
-
|
|
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)
|
|
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) {
|
package/dist/commerce/card.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AgentCard } from '@a2a-js/sdk';
|
|
2
2
|
import { CommerceError } from './types.js';
|
|
3
|
-
import { validateUrl,
|
|
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:
|
|
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' } } },
|